diff options
Diffstat (limited to 'diplomacy/web/src/diplomacy/client')
-rw-r--r-- | diplomacy/web/src/diplomacy/client/channel.js | 256 | ||||
-rw-r--r-- | diplomacy/web/src/diplomacy/client/connection.js | 340 | ||||
-rw-r--r-- | diplomacy/web/src/diplomacy/client/game_instance_set.js | 76 | ||||
-rw-r--r-- | diplomacy/web/src/diplomacy/client/network_game.js | 297 | ||||
-rw-r--r-- | diplomacy/web/src/diplomacy/client/notification_managers.js | 127 | ||||
-rw-r--r-- | diplomacy/web/src/diplomacy/client/request_future_context.js | 63 | ||||
-rw-r--r-- | diplomacy/web/src/diplomacy/client/response_managers.js | 118 |
7 files changed, 1277 insertions, 0 deletions
diff --git a/diplomacy/web/src/diplomacy/client/channel.js b/diplomacy/web/src/diplomacy/client/channel.js new file mode 100644 index 0000000..eb8692a --- /dev/null +++ b/diplomacy/web/src/diplomacy/client/channel.js @@ -0,0 +1,256 @@ +// ============================================================================== +// 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/>. +// ============================================================================== +import {STRINGS} from "../utils/strings"; +import {UTILS} from "../utils/utils"; +import {REQUESTS} from "../communication/requests"; + +/** Class Channel. **/ +export class Channel { + constructor(connection, username, token) { + this.connection = connection; + this.token = token; + this.username = username; + this.game_id_to_instances = {}; + } + + localJoinGame(joinParameters) { + // Game ID must be known. + if (this.game_id_to_instances.hasOwnProperty(joinParameters.game_id)) { + // If there is a power name, we return associated power game. + if (joinParameters.power_name) + return this.game_id_to_instances[joinParameters.game_id].get(joinParameters.power_name); + // Otherwise, we return current special game (if exists). + return this.game_id_to_instances[joinParameters.game_id].getSpecial(); + } + return null; + } + + _req(name, forcedParameters, localChannelFunction, parameters, game) { + /** Send a request object for given request name with (optional) given forced parameters.. + * If a local channel function is given, it will be used to try retrieving a data + * locally instead of sending a request. If local channel function returns something, this value is returned. + * Otherwise, normal procedure (request sending) is used. Local channel function would be called with + * request parameters passed to channel request method. + * **/ + parameters = Object.assign(parameters || {}, forcedParameters || {}); + const level = REQUESTS.getLevel(name); + if (level === STRINGS.GAME) { + if (!game) + throw new Error('A game object is required to send a game request.'); + parameters.token = this.token; + parameters.game_id = game.local.game_id; + parameters.game_role = game.local.role; + parameters.phase = game.local.phase; + } else { + if (game) + throw new Error('A game object should not be provided for a non-game request.'); + if (level === STRINGS.CHANNEL) + parameters.token = this.token; + } + if (localChannelFunction) { + const localResult = localChannelFunction.apply(this, [parameters]); + if (localResult) + return localResult; + } + const request = REQUESTS.create(name, parameters); + const future = this.connection.send(request, game); + const timeoutID = setTimeout(function () { + if (!future.done()) + future.setException('Timeout reached when trying to send a request ' + name + '/' + request.request_id + '.'); + }, UTILS.REQUEST_TIMEOUT_SECONDS * 1000); + return future.promise().then((result) => { + clearTimeout(timeoutID); + return result; + }); + } + + //// Public channel API. + + createGame(parameters) { + return this._req('create_game', undefined, undefined, parameters, undefined); + } + + getAvailableMaps(parameters) { + return this._req('get_available_maps', undefined, undefined, parameters, undefined); + } + + getPlayablePowers(parameters) { + return this._req('get_playable_powers', undefined, undefined, parameters, undefined); + } + + joinGame(parameters) { + return this._req('join_game', null, this.localJoinGame, parameters, undefined); + } + + listGames(parameters) { + return this._req('list_games', undefined, undefined, parameters, undefined); + } + + getGamesInfo(parameters) { + return this._req('get_games_info', undefined, undefined, parameters, undefined); + } + + // User account API. + + deleteAccount(parameters) { + return this._req('delete_account', undefined, undefined, parameters, undefined); + } + + logout(parameters) { + return this._req('logout', undefined, undefined, parameters, undefined); + } + + // Admin/moderator API. + + makeOmniscient(parameters) { + return this._req('set_grade', { + grade: STRINGS.OMNISCIENT, + grade_update: STRINGS.PROMOTE + }, undefined, parameters, undefined); + } + + removeOmniscient(parameters) { + return this._req('set_grade', { + grade: STRINGS.OMNISCIENT, + grade_update: STRINGS.DEMOTE + }, undefined, parameters, undefined); + } + + promoteAdministrator(parameters) { + return this._req('set_grade', { + grade: STRINGS.ADMIN, + grade_update: STRINGS.PROMOTE + }, undefined, parameters, undefined); + } + + demoteAdministrator(parameters) { + return this._req('set_grade', { + grade: STRINGS.ADMIN, + grade_update: STRINGS.DEMOTE + }, undefined, parameters, undefined); + } + + promoteModerator(parameters) { + return this._req('set_grade', { + grade: STRINGS.MODERATOR, + grade_update: STRINGS.PROMOTE + }, undefined, parameters, undefined); + } + + demoteModerator(parameters) { + return this._req('set_grade', { + grade: STRINGS.MODERATOR, + grade_update: STRINGS.DEMOTE + }, undefined, parameters, undefined); + } + + //// Public game API. + + getAllPossibleOrders(parameters, game) { + return this._req('get_all_possible_orders', undefined, undefined, parameters, game); + } + + getPhaseHistory(parameters, game) { + return this._req('get_phase_history', undefined, undefined, parameters, game); + } + + leaveGame(parameters, game) { + return this._req('leave_game', undefined, undefined, parameters, game); + } + + sendGameMessage(parameters, game) { + return this._req('send_game_message', undefined, undefined, parameters, game); + } + + setOrders(parameters, game) { + return this._req('set_orders', undefined, undefined, parameters, game); + } + + clearCenters(parameters, game) { + return this._req('clear_centers', undefined, undefined, parameters, game); + } + + clearOrders(parameters, game) { + return this._req('clear_orders', undefined, undefined, parameters, game); + } + + clearUnits(parameters, game) { + return this._req('clear_units', undefined, undefined, parameters, game); + } + + wait(parameters, game) { + return this._req('set_wait_flag', {wait: true}, undefined, parameters, game); + } + + noWait(parameters, game) { + return this._req('set_wait_flag', {wait: false}, undefined, parameters, game); + } + + vote(parameters, game) { + return this._req('vote', undefined, undefined, parameters, game); + } + + save(parameters, game) { + return this._req('save_game', undefined, undefined, parameters, game); + } + + synchronize(parameters, game) { + return this._req('synchronize', undefined, undefined, parameters, game); + } + + // Admin/moderator game API. + + deleteGame(parameters, game) { + return this._req('delete_game', undefined, undefined, parameters, game); + } + + kickPowers(parameters, game) { + return this._req('set_dummy_powers', undefined, undefined, parameters, game); + } + + setState(parameters, game) { + return this._req('set_game_state', undefined, undefined, parameters, game); + } + + process(parameters, game) { + return this._req('process_game', undefined, undefined, parameters, game); + } + + querySchedule(parameters, game) { + return this._req('query_schedule', undefined, undefined, parameters, game); + } + + start(parameters, game) { + return this._req('set_game_status', {status: STRINGS.ACTIVE}, undefined, parameters, game); + } + + pause(parameters, game) { + return this._req('set_game_status', {status: STRINGS.PAUSED}, undefined, parameters, game); + } + + resume(parameters, game) { + return this._req('set_game_status', {status: STRINGS.ACTIVE}, undefined, parameters, game); + } + + cancel(parameters, game) { + return this._req('set_game_status', {status: STRINGS.CANCELED}, undefined, parameters, game); + } + + draw(parameters, game) { + return this._req('set_game_status', {status: STRINGS.COMPLETED}, undefined, parameters, game); + } +} diff --git a/diplomacy/web/src/diplomacy/client/connection.js b/diplomacy/web/src/diplomacy/client/connection.js new file mode 100644 index 0000000..8931df5 --- /dev/null +++ b/diplomacy/web/src/diplomacy/client/connection.js @@ -0,0 +1,340 @@ +// ============================================================================== +// 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/>. +// ============================================================================== +/*eslint no-unused-vars: ["error", { "args": "none" }]*/ +import {STRINGS} from "../utils/strings"; +import {UTILS} from "../utils/utils"; +import {REQUESTS} from "../communication/requests"; +import {RESPONSES} from "../communication/responses"; +import {NOTIFICATIONS} from "../communication/notifications"; +import {RESPONSE_MANAGERS} from "./response_managers"; +import {NOTIFICATION_MANAGERS} from "./notification_managers"; +import {Future} from "../utils/future"; +import {FutureEvent} from "../utils/future_event"; +import {RequestFutureContext} from "./request_future_context"; +import {Diplog} from "../utils/diplog"; + +class Reconnection { + constructor(connection) { + this.connection = connection; + this.games_phases = {}; + this.n_expected_games = 0; + this.n_synchronized_games = 0; + } + + genSyncCallback(game) { + const reconnection = this; + return ((serverSyncResponse) => { + reconnection.games_phases[game.local.game_id][game.local.game_role] = serverSyncResponse; + ++reconnection.n_synchronized_games; + if (reconnection.n_synchronized_games === reconnection.n_expected_games) + reconnection.syncDone(); + }); + } + + reconnect() { + for (let waitingContext of Object.values(this.connection.requestsWaitingResponses)) + waitingContext.request.re_sent = true; + const lenWaiting = Object.keys(this.connection.requestsWaitingResponses).length; + const lenBefore = Object.keys(this.connection.requestsToSend).length; + Object.assign(this.connection.requestsToSend, this.connection.requestsWaitingResponses); + const lenAfter = Object.keys(this.connection.requestsToSend).length; + if (lenAfter !== lenWaiting + lenBefore) + throw new Error('Programming error.'); + this.connection.requestsWaitingResponses = {}; + + const requestsToSendUpdated = {}; + for (let context of Object.values(this.connection.requestsToSend)) { + if (context.request.name === STRINGS.SYNCHRONIZE) + context.future.setException(new Error('Sync request invalidated for game ID ' + context.request.game_id)); + else + requestsToSendUpdated[context.request.request_id] = context; + } + this.connection.requestsToSend = requestsToSendUpdated; + + for (let channel of Object.values(this.connection.channels)) { + for (let gis of Object.values(channel.game_id_to_instances)) { + for (let game of gis.getGames()) { + const game_id = game.local.game_id; + const game_role = game.local.role; + if (!this.games_phases.hasOwnProperty(game_id)) + this.games_phases[game_id] = {}; + this.games_phases[game_id][game_role] = null; + ++this.n_expected_games; + } + } + } + + if (this.n_expected_games) { + for (let channel of Object.values(this.connection.channels)) + for (let gis of Object.values(channel.game_id_to_instances)) + for (let game of gis.getGames()) + game.synchronize().then(this.genSyncCallback(game)); + } else { + this.syncDone(); + } + } + + syncDone() { + const requestsToSendUpdated = {}; + for (let context of Object.values(this.connection.requestsToSend)) { + let keep = true; + if (REQUESTS.isPhaseDependent(context.request.name)) { + const request_phase = context.request.phase; + const server_phase = this.games_phases[context.request.game_id][context.request.game_role].phase; + if (request_phase !== server_phase) { + context.future.setException(new Error( + 'Game ' + context.request.game_id + ': request ' + context.request.name + + ': request phase ' + request_phase + ' does not match current server game phase ' + + server_phase + '.')); + keep = false; + } + } + if (keep) + requestsToSendUpdated[context.request.request_id] = context; + } + Diplog.info('Keep ' + Object.keys(requestsToSendUpdated).length + '/' + + Object.keys(this.connection.requestsToSend).length + ' old request(s) to send.'); + this.connection.requestsToSend = requestsToSendUpdated; + + for (let context of Object.values(requestsToSendUpdated)) { + this.connection.__write_request(context); + } + + this.connection.isReconnecting.set(); + + Diplog.info('Done reconnection work.'); + } +} + +class ConnectionProcessing { + constructor(connection, logger) { + this.connection = connection; + this.logger = logger || Diplog; + this.isConnected = false; + this.attemptIndex = 1; + this.timeoutID = null; + + this.onSocketOpen = this.onSocketOpen.bind(this); + this.onSocketTimeout = this.onSocketTimeout.bind(this); + this.tryConnect = this.tryConnect.bind(this); + } + + __on_error(error) { + this.connection.isConnecting.set(error); + } + + onSocketOpen(event) { + this.isConnected = true; + if (this.timeoutID) { + clearTimeout(this.timeoutID); + this.timeoutID = null; + } + // Socket open: set onMessage and onClose callbacks. + this.connection.socket.onmessage = this.connection.onSocketMessage; + this.connection.socket.onclose = this.connection.onSocketClose; + this.connection.currentConnectionProcessing = null; + this.connection.isConnecting.set(); + this.logger.info('Connection succeeds.'); + } + + onSocketTimeout() { + if (!this.isConnected) { + this.connection.socket.close(); + if (this.attemptIndex === UTILS.NB_CONNECTION_ATTEMPTS) { + this.connection.isConnecting.set( + new Error('Connection failed after ' + UTILS.NB_CONNECTION_ATTEMPTS + ' attempts.')); + return; + } + this.logger.warn('Connection failing (attempt ' + this.attemptIndex + '/' + + UTILS.NB_CONNECTION_ATTEMPTS + '), retrying ...'); + ++this.attemptIndex; + setTimeout(this.tryConnect, 0); + } + } + + tryConnect() { + // When opening a socket, we configure only onOpen callback. + // We will configure onMessage and onClose callbacks only when the socket will be effectively open. + try { + this.connection.socket = new WebSocket(this.connection.getUrl()); + this.connection.socket.onopen = this.onSocketOpen; + this.timeoutID = setTimeout(this.onSocketTimeout, UTILS.ATTEMPT_DELAY_SECONDS * 1000); + } catch (error) { + this.__on_error(error); + } + } + + process() { + this.connection.isConnecting.clear(); + if (this.connection.socket) + this.connection.socket.close(); + this.tryConnect(); + return this.connection.isConnecting.wait(); + } + + stop() { + if (!this.isConnected) { + if (this.connection.socket) + this.connection.socket.onopen = null; + if (this.timeoutID) { + clearTimeout(this.timeoutID); + this.timeoutID = null; + } + } + } +} + +/** Class Connection (like Python class diplomacy.client.connection.Connection). **/ +export class Connection { + constructor(hostname, port, useSSL) { + if (useSSL) + Diplog.info(`Using SSL.`); + this.protocol = useSSL ? 'wss' : 'ws'; + this.hostname = hostname; + this.port = port; + this.socket = null; + this.isConnecting = new FutureEvent(); + this.isReconnecting = new FutureEvent(); + this.channels = {}; + this.requestsToSend = {}; + this.requestsWaitingResponses = {}; + this.currentConnectionProcessing = null; + + // Attribute used to make distinction between a connection + // explicitly closed by client and a connection closed for + // other unexpected reasons (e.g. by server). + this.closed = false; + + this.onSocketMessage = this.onSocketMessage.bind(this); + this.onSocketClose = this.onSocketClose.bind(this); + + this.isReconnecting.set(); + } + + getUrl() { + return this.protocol + '://' + this.hostname + ':' + this.port; + } + + onSocketMessage(messageEvent) { + /** Callback used to manage a socket message string. + * Try-catch block will capture eventual: + * - JSON parsing errors + * - response parsing errors + * - response handling errors + * - notification parsing errors + * - notification handling errors + * **/ + try { + const message = messageEvent.data; + const jsonMessage = JSON.parse(message); + if (!(jsonMessage instanceof Object)) { + Diplog.error('Unable to convert a message to a JSON object.'); + return; + } + if (jsonMessage.request_id) { + const requestID = jsonMessage.request_id; + if (!this.requestsWaitingResponses.hasOwnProperty(requestID)) { + Diplog.error('Unknown request ' + requestID + '.'); + return; + } + const context = this.requestsWaitingResponses[requestID]; + delete this.requestsWaitingResponses[requestID]; + try { + context.future.setResult(RESPONSE_MANAGERS.handleResponse(context, RESPONSES.parse(jsonMessage))); + } catch (error) { + context.future.setException(error); + } + } else if (jsonMessage.hasOwnProperty('notification_id') && jsonMessage.notification_id) + NOTIFICATION_MANAGERS.handleNotification(this, NOTIFICATIONS.parse(jsonMessage)); + else + Diplog.error('Unknown socket message received.'); + } catch (error) { + Diplog.error(error); + } + } + + onSocketClose(closeEvent) { + if (this.closed) + Diplog.info('Disconnected.'); + else { + Diplog.error('Disconnected, trying to reconnect.'); + this.isReconnecting.clear(); + this.__connect().then(() => new Reconnection(this).reconnect()); + } + } + + __connect(logger) { + if (this.currentConnectionProcessing) { + this.currentConnectionProcessing.stop(); + this.currentConnectionProcessing = null; + } + this.currentConnectionProcessing = new ConnectionProcessing(this, logger); + return this.currentConnectionProcessing.process(); + } + + __write_request(requestContext) { + const writeFuture = new Future(); + const request = requestContext.request; + const requestID = request.request_id; + const connection = this; + + const onConnected = () => { + connection.socket.send(JSON.stringify(request)); + connection.requestsWaitingResponses[requestID] = requestContext; + if (connection.requestsToSend.hasOwnProperty(requestID)) { + delete connection.requestsToSend[requestID]; + } + writeFuture.setResult(null); + }; + const onAnyError = (error) => { + if (!connection.requestsToSend.hasOwnProperty(requestID)) { + connection.requestsToSend[requestID] = requestContext; + } + Diplog.info('Error occurred while sending a request ' + requestID); + writeFuture.setException(error); + }; + if (request.name === STRINGS.SYNCHRONIZE) + this.isConnecting.wait().then(onConnected, onAnyError); + else + this.isReconnecting.wait().then(onConnected, onAnyError); + return writeFuture.promise(); + } + + connect(logger) { + Diplog.info('Trying to connect.'); + return this.__connect(logger); + } + + send(request, game = null) { + const requestContext = new RequestFutureContext(request, this, game); + this.__write_request(requestContext); + return requestContext.future; + } + + authenticate(username, password, createUser = false) { + return this.send(REQUESTS.create('sign_in', { + username: username, + password: password, + create_user: createUser + })).promise(); + } + + close() { + this.closed = true; + this.socket.close(); + } +} diff --git a/diplomacy/web/src/diplomacy/client/game_instance_set.js b/diplomacy/web/src/diplomacy/client/game_instance_set.js new file mode 100644 index 0000000..ca92e48 --- /dev/null +++ b/diplomacy/web/src/diplomacy/client/game_instance_set.js @@ -0,0 +1,76 @@ +// ============================================================================== +// 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/>. +// ============================================================================== +import {STRINGS} from "../utils/strings"; +import {UTILS} from "../utils/utils"; + +export class GameInstanceSet { + constructor(gameID) { + this.__game_id = gameID; + this.__games = {}; + } + + getGames() { + return Object.values(this.__games); + } + + has(role) { + return this.__games.hasOwnProperty(role); + } + + get(role) { + return this.__games[role] || null; + } + + getSpecial() { + if (this.__games.hasOwnProperty(STRINGS.OBSERVER_TYPE)) + return this.__games[STRINGS.OBSERVER_TYPE]; + if (this.__games.hasOwnProperty(STRINGS.OMNISCIENT_TYPE)) + return this.__games[STRINGS.OMNISCIENT_TYPE]; + return null; + } + + remove(role) { + let old = null; + if (this.__games[role]) { + old = this.__games[role]; + delete this.__games[role]; + } + return old; + } + + removeSpecial() { + if (this.__games.hasOwnProperty(STRINGS.OBSERVER_TYPE)) + delete this.__games[STRINGS.OBSERVER_TYPE]; + if (this.__games.hasOwnProperty(STRINGS.OMNISCIENT_TYPE)) + delete this.__games[STRINGS.OMNISCIENT_TYPE]; + } + + add(game) { + if (game.local.game_id !== this.__game_id) + throw new Error('game ID to add does not match game instance set ID.'); + if (this.__games.hasOwnProperty(game.local.role)) + throw new Error('Role already in game instance set.'); + if (!game.local.isPlayerGame() && ( + this.__games.hasOwnProperty(STRINGS.OBSERVER_TYPE) || this.__games.hasOwnProperty(STRINGS.OMNISCIENT_TYPE))) + throw new Error('Previous special game must be removed before adding new one.'); + this.__games[game.local.role] = game; + } + + size() { + return UTILS.javascript.count(this.__games); + } +} diff --git a/diplomacy/web/src/diplomacy/client/network_game.js b/diplomacy/web/src/diplomacy/client/network_game.js new file mode 100644 index 0000000..9999093 --- /dev/null +++ b/diplomacy/web/src/diplomacy/client/network_game.js @@ -0,0 +1,297 @@ +// ============================================================================== +// 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/>. +// ============================================================================== +import {Channel} from "./channel"; +import {Game} from "../engine/game"; + +/** Class NetworkGame. **/ + +export class NetworkGame { + constructor(channel, serverGameState) { + // Let's use a "local" instance to manage game. + // This will help make distinction between network game request methods and local gme methods + // (e.g. for request set_orders). + this.local = new Game(serverGameState); + this.channel = channel; + this.notificationCallbacks = {}; + this.data = null; + this.local.client = this; + } + + addCallback(notificationName, notificationCallback) { + if (!this.notificationCallbacks.hasOwnProperty(notificationName)) + this.notificationCallbacks[notificationName] = [notificationCallback]; + else if (!this.notificationCallbacks[notificationName].includes(notificationCallback)) + this.notificationCallbacks[notificationName].push(notificationCallback); + } + + clearCallbacks(notificationName) { + if (this.notificationCallbacks.hasOwnProperty(notificationName)) + delete this.notificationCallbacks[notificationName]; + } + + clearAllCallbacks() { + this.notificationCallbacks = {}; + } + + notify(notification) { + if (this.notificationCallbacks.hasOwnProperty(notification.name)) { + for (let callback of this.notificationCallbacks[notification.name]) + setTimeout(() => callback(this, notification), 0); + } + } + + _req(channelMethod, parameters) { + /** Send a game request using given channel request method. **/ + if (!this.channel) + throw new Error('Invalid client game.'); + return channelMethod.apply(this.channel, [parameters, this]); + } + + //// Game requests API. + + getAllPossibleOrders(parameters) { + return this._req(Channel.prototype.getAllPossibleOrders, parameters); + } + + getPhaseHistory(parameters) { + return this._req(Channel.prototype.getPhaseHistory, parameters); + } + + leave(parameters) { + return this._req(Channel.prototype.leaveGame, parameters); + } + + sendGameMessage(parameters) { + return this._req(Channel.prototype.sendGameMessage, parameters); + } + + setOrders(parameters) { + return this._req(Channel.prototype.setOrders, parameters); + } + + clearCenters(parameters) { + return this._req(Channel.prototype.clearCenters, parameters); + } + + clearOrders(parameters) { + return this._req(Channel.prototype.clearOrders, parameters); + } + + clearUnits(parameters) { + return this._req(Channel.prototype.clearUnits, parameters); + } + + wait(parameters) { + return this._req(Channel.prototype.wait, parameters); + } + + noWait(parameters) { + return this._req(Channel.prototype.noWait, parameters); + } + + setWait(wait, parameters) { + return wait ? this.wait(parameters) : this.noWait(parameters); + } + + vote(parameters) { + return this._req(Channel.prototype.vote, parameters); + } + + save(parameters) { + return this._req(Channel.prototype.save, parameters); + } + + synchronize() { + if (!this.channel) + throw new Error('Invalid client game.'); + return Channel.prototype.synchronize.apply(this.channel, [{timestamp: this.local.getLatestTimestamp()}, this]); + } + + // Admin/moderator API. + + remove(parameters) { + return this._req(Channel.prototype.deleteGame, parameters); + } + + kickPowers(parameters) { + return this._req(Channel.prototype.kickPowers, parameters); + } + + setState(parameters) { + return this._req(Channel.prototype.setState, parameters); + } + + process(parameters) { + return this._req(Channel.prototype.process, parameters); + } + + querySchedule(parameters) { + return this._req(Channel.prototype.querySchedule, parameters); + } + + start(parameters) { + return this._req(Channel.prototype.start, parameters); + } + + pause(parameters) { + return this._req(Channel.prototype.pause, parameters); + } + + resume(parameters) { + return this._req(Channel.prototype.resume, parameters); + } + + cancel(parameters) { + return this._req(Channel.prototype.cancel, parameters); + } + + draw(parameters) { + return this._req(Channel.prototype.draw, parameters); + } + + //// Game callbacks setting API. + + addOnClearedCenters(callback) { + this.addCallback('cleared_centers', callback); + } + + addOnClearedOrders(callback) { + this.addCallback('cleared_orders', callback); + } + + addOnClearedUnits(callback) { + this.addCallback('cleared_units', callback); + } + + addOnPowersControllers(callback) { + this.addCallback('powers_controllers', callback); + } + + addOnGameDeleted(callback) { + this.addCallback('game_deleted', callback); + } + + addOnGameMessageReceived(callback) { + this.addCallback('game_message_received', callback); + } + + addOnGameProcessed(callback) { + this.addCallback('game_processed', callback); + } + + addOnGamePhaseUpdate(callback) { + this.addCallback('game_phase_update', callback); + } + + addOnGameStatusUpdate(callback) { + this.addCallback('game_status_update', callback); + } + + addOnOmniscientUpdated(callback) { + this.addCallback('omniscient_updated', callback); + } + + addOnPowerOrdersUpdate(callback) { + this.addCallback('power_orders_update', callback); + } + + addOnPowerOrdersFlag(callback) { + this.addCallback('power_orders_flag', callback); + } + + addOnPowerVoteUpdated(callback) { + this.addCallback('power_vote_updated', callback); + } + + addOnPowerWaitFlag(callback) { + this.addCallback('power_wait_flag', callback); + } + + addOnVoteCountUpdated(callback) { + this.addCallback('vote_count_updated', callback); + } + + addOnVoteUpdated(callback) { + this.addCallback('vote_updated', callback); + } + + //// Game callbacks clearing API. + + clearOnClearedCenters() { + this.clearCallbacks('cleared_centers'); + } + + clearOnClearedOrders() { + this.clearCallbacks('cleared_orders'); + } + + clearOnClearedUnits() { + this.clearCallbacks('cleared_units'); + } + + clearOnPowersControllers() { + this.clearCallbacks('powers_controllers'); + } + + clearOnGameDeleted() { + this.clearCallbacks('game_deleted'); + } + + clearOnGameMessageReceived() { + this.clearCallbacks('game_message_received'); + } + + clearOnGameProcessed() { + this.clearCallbacks('game_processed'); + } + + clearOnGamePhaseUpdate() { + this.clearCallbacks('game_phase_update'); + } + + clearOnGameStatusUpdate() { + this.clearCallbacks('game_status_update'); + } + + clearOnOmniscientUpdated() { + this.clearCallbacks('omniscient_updated'); + } + + clearOnPowerOrdersUpdate() { + this.clearCallbacks('power_orders_update'); + } + + clearOnPowerOrdersFlag() { + this.clearCallbacks('power_orders_flag'); + } + + clearOnPowerVoteUpdated() { + this.clearCallbacks('power_vote_updated'); + } + + clearOnPowerWaitFlag() { + this.clearCallbacks('power_wait_flag'); + } + + clearOnVoteCountUpdated() { + this.clearCallbacks('vote_count_updated'); + } + + clearOnVoteUpdated() { + this.clearCallbacks('vote_updated'); + } +} diff --git a/diplomacy/web/src/diplomacy/client/notification_managers.js b/diplomacy/web/src/diplomacy/client/notification_managers.js new file mode 100644 index 0000000..bbfe862 --- /dev/null +++ b/diplomacy/web/src/diplomacy/client/notification_managers.js @@ -0,0 +1,127 @@ +// ============================================================================== +// 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/>. +// ============================================================================== +/*eslint no-unused-vars: ["error", { "args": "none" }]*/ +import {STRINGS} from "../utils/strings"; +import {NOTIFICATIONS} from "../communication/notifications"; +import {Game} from "../engine/game"; + +/** Notification managers. **/ +export const NOTIFICATION_MANAGERS = { + account_deleted: function (channel, notification) { + const connection = channel.connection; + if (connection.channels.hasOwnProperty(channel.token)) + delete channel.connection.channels[channel.token]; + }, + cleared_centers: function (game, notification) { + game.local.clearCenters(notification.power_name); + }, + cleared_orders: function (game, notification) { + game.local.clearOrders(notification.power_name); + }, + cleared_units: function (game, notification) { + game.local.clearUnits(notification.power_name); + }, + powers_controllers: function (game, notification) { + if (game.local.isPlayerGame() && notification.powers[game.local.role] !== game.local.getRelatedPower().getController()) { + game.channel.game_id_to_instances[game.local.game_id].remove(game.local.role); + if (!game.channel.game_id_to_instances[game.local.game_id].size()) + delete game.channel.game_id_to_instances[game.local.game_id]; + } else { + game.local.updatePowersControllers(notification.powers, notification.timestamps); + } + }, + game_deleted: function (game, notification) { + game.channel.game_id_to_instances[game.local.game_id].remove(game.local.role); + }, + game_message_received: function (game, notification) { + game.local.addMessage(notification.message); + }, + game_processed: function (game, notification) { + game.local.extendPhaseHistory(notification.previous_phase_data); + game.local.setPhaseData(notification.current_phase_data); + game.local.clearVote(); + }, + game_phase_update: function (game, notification) { + if (notification.phase_data_type === STRINGS.STATE_HISTORY) + game.local.extendPhaseHistory(notification.phase_data); + else + game.local.setPhaseData(notification.phase_data); + }, + game_status_update: function (game, notification) { + if (game.local.status !== notification.status) { + game.local.setStatus(notification.status); + } + }, + omniscient_updated: function (game, notification) { + if (game.local.isPlayerGame()) return; + if (game.local.isObserverGame()) { + if (notification.grade_update !== STRINGS.PROMOTE || notification.game.role !== STRINGS.OMNISCIENT_TYPE) + throw new Error('Omniscient updated: expected promotion from observer to omniscient'); + } else { + if (notification.grade_update !== STRINGS.DEMOTE || notification.game.role !== STRINGS.OBSERVER_TYPE) + throw new Error('Omniscient updated: expected demotion from omniscient to observer.'); + } + const channel = game.channel; + const oldGame = channel.game_id_to_instances[game.local.game_id].remove(game.local.role); + oldGame.client = null; + game.local = new Game(notification.game); + game.local.client = game; + channel.game_id_to_instances[game.local.game_id].add(game); + }, + power_orders_update: function (game, notification) { + game.local.setOrders(notification.power_name, notification.orders); + }, + power_orders_flag: function (game, notification) { + game.local.getPower(notification.power_name).order_is_set = notification.order_is_set; + }, + power_vote_updated: function (game, notification) { + game.local.assertPlayerGame(); + game.local.getRelatedPower().vote = notification.vote; + }, + power_wait_flag: function (game, notification) { + game.local.setWait(notification.power_name, notification.wait); + }, + vote_count_updated: function (game, notification) { + // Nothing currently done. + }, + vote_updated: function (game, notification) { + game.assertOmniscientGame(); + for (let power_name of notification.vote) { + game.local.getPower(power_name).vote = notification.vote[power_name]; + } + }, + handleNotification: function (connection, notification) { + if (!NOTIFICATION_MANAGERS.hasOwnProperty(notification.name)) + throw new Error('No notification handler available for notification ' + notification.name); + const handler = NOTIFICATION_MANAGERS[notification.name]; + const level = NOTIFICATIONS.levels[notification.name]; + if (!connection.channels.hasOwnProperty(notification.token)) + throw new Error('Unable to find channel related to notification ' + notification.name); + let objectToNotify = connection.channels[notification.token]; + if (level === STRINGS.GAME) { + if (objectToNotify.game_id_to_instances.hasOwnProperty(notification.game_id) + && objectToNotify.game_id_to_instances[notification.game_id].has(notification.game_role)) + objectToNotify = objectToNotify.game_id_to_instances[notification.game_id].get(notification.game_role); + else + throw new Error('Unable to find game instance related to notification ' + + notification.name + '/' + notification.game_id + '/' + notification.game_role); + } + handler(objectToNotify, notification); + if (level === STRINGS.GAME) + objectToNotify.notify(notification); + } +}; diff --git a/diplomacy/web/src/diplomacy/client/request_future_context.js b/diplomacy/web/src/diplomacy/client/request_future_context.js new file mode 100644 index 0000000..16103fb --- /dev/null +++ b/diplomacy/web/src/diplomacy/client/request_future_context.js @@ -0,0 +1,63 @@ +// ============================================================================== +// 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/>. +// ============================================================================== +import {Future} from "../utils/future"; +import {Channel} from "./channel"; +import {GameInstanceSet} from "./game_instance_set"; +import {NetworkGame} from "./network_game"; + +/** Class RequestFutureContext. **/ +export class RequestFutureContext { + constructor(request, connection, game = null) { + this.request = request; + this.connection = connection; + this.game = game; + this.future = new Future(); + } + + getRequestId() { + return this.request.request_id; + } + + getChannel() { + return this.connection.channels[this.request.token]; + } + + newChannel(username, token) { + const channel = new Channel(this.connection, username, token); + this.connection.channels[token] = channel; + return channel; + } + + newGame(received_game) { + const channel = this.getChannel(); + const game = new NetworkGame(channel, received_game); + if (!channel.game_id_to_instances.hasOwnProperty(game.local.game_id)) + channel.game_id_to_instances[game.local.game_id] = new GameInstanceSet(game.local.game_id); + channel.game_id_to_instances[game.local.game_id].add(game); + return game; + } + + removeChannel() { + delete this.connection.channels[this.request.token]; + } + + deleteGame() { + const channel = this.getChannel(); + if (channel.game_id_to_instances.hasOwnProperty(this.request.game_id)) + delete channel.game_id_to_instances[this.request.game_id]; + } +} diff --git a/diplomacy/web/src/diplomacy/client/response_managers.js b/diplomacy/web/src/diplomacy/client/response_managers.js new file mode 100644 index 0000000..ba45938 --- /dev/null +++ b/diplomacy/web/src/diplomacy/client/response_managers.js @@ -0,0 +1,118 @@ +// ============================================================================== +// 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/>. +// ============================================================================== +/*eslint no-unused-vars: ["error", { "args": "none" }]*/ +import {RESPONSES} from "../communication/responses"; + +/** Default response manager. **/ +function defaultResponseManager(context, response) { + if (RESPONSES.isOk(response)) + return null; + if (RESPONSES.isUniqueData(response)) + return response.data; + return response; +} + +/** Response managers. **/ +export const RESPONSE_MANAGERS = { + get_all_possible_orders: defaultResponseManager, + get_available_maps: defaultResponseManager, + get_playable_powers: defaultResponseManager, + list_games: defaultResponseManager, + get_games_info: defaultResponseManager, + process_game: defaultResponseManager, + query_schedule: defaultResponseManager, + save_game: defaultResponseManager, + set_dummy_powers: defaultResponseManager, + set_grade: defaultResponseManager, + synchronize: defaultResponseManager, + create_game: function (context, response) { + return context.newGame(response.data); + }, + delete_account: function (context, response) { + context.removeChannel(); + }, + delete_game: function (context, response) { + context.deleteGame(); + }, + get_phase_history: function (context, response) { + for (let phaseData of response.data) { + context.game.local.extendPhaseHistory(phaseData); + } + return response.data; + }, + join_game: function (context, response) { + return context.newGame(response.data); + }, + leave_game: function (context, response) { + context.deleteGame(); + }, + logout: function (context, response) { + context.removeChannel(); + }, + send_game_message: function (context, response) { + const message = context.request.message; + message.time_sent = response.data; + context.game.local.addMessage(message); + }, + set_game_state: function (context, response) { + context.game.local.setPhaseData({ + name: context.request.state.name, + state: context.request.state, + orders: context.request.orders, + messages: context.request.messages, + results: context.request.results + }); + }, + set_game_status: function (context, response) { + context.game.local.setStatus(context.request.status); + }, + set_orders: function (context, response) { + const orders = context.request.orders; + if (context.game.local.isPlayerGame(context.request.game_role)) + context.game.local.setOrders(context.request.game_role, orders); + else + context.game.local.setOrders(context.request.power_name, orders); + }, + clear_orders: function (context, response) { + context.game.local.clearOrders(context.request.power_name); + }, + clear_units: function (context, response) { + context.game.local.clearUnits(context.request.power_name); + }, + clear_centers: function (context, response) { + context.game.local.clearCenters(context.request.power_name); + }, + set_wait_flag: function (context, response) { + const wait = context.request.wait; + if (context.game.local.isPlayerGame(context.request.game_role)) + context.game.local.setWait(context.request.game_role, wait); + else + context.game.local.setWait(context.request.power_name, wait); + }, + vote: function (context, response) { + context.game.local.getRelatedPower().vote = context.request.vote; + }, + sign_in: function (context, response) { + return context.newChannel(context.request.username, response.data); + }, + handleResponse: function (context, response) { + if (!RESPONSE_MANAGERS.hasOwnProperty(context.request.name)) + throw new Error('No response handler available for request ' + context.request.name); + const handler = RESPONSE_MANAGERS[context.request.name]; + return handler(context, response); + } +}; |