aboutsummaryrefslogtreecommitdiff
path: root/diplomacy/web/src/diplomacy/client
diff options
context:
space:
mode:
Diffstat (limited to 'diplomacy/web/src/diplomacy/client')
-rw-r--r--diplomacy/web/src/diplomacy/client/channel.js256
-rw-r--r--diplomacy/web/src/diplomacy/client/connection.js340
-rw-r--r--diplomacy/web/src/diplomacy/client/game_instance_set.js76
-rw-r--r--diplomacy/web/src/diplomacy/client/network_game.js297
-rw-r--r--diplomacy/web/src/diplomacy/client/notification_managers.js127
-rw-r--r--diplomacy/web/src/diplomacy/client/request_future_context.js63
-rw-r--r--diplomacy/web/src/diplomacy/client/response_managers.js118
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);
+ }
+};