aboutsummaryrefslogtreecommitdiff
path: root/diplomacy/web/src
diff options
context:
space:
mode:
Diffstat (limited to 'diplomacy/web/src')
-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
-rw-r--r--diplomacy/web/src/diplomacy/communication/notifications.js56
-rw-r--r--diplomacy/web/src/diplomacy/communication/requests.js120
-rw-r--r--diplomacy/web/src/diplomacy/communication/responses.js42
-rw-r--r--diplomacy/web/src/diplomacy/engine/game.js507
-rw-r--r--diplomacy/web/src/diplomacy/engine/message.js34
-rw-r--r--diplomacy/web/src/diplomacy/engine/power.js129
-rw-r--r--diplomacy/web/src/diplomacy/utils/diplog.js45
-rw-r--r--diplomacy/web/src/diplomacy/utils/future.js55
-rw-r--r--diplomacy/web/src/diplomacy/utils/future_event.js41
-rw-r--r--diplomacy/web/src/diplomacy/utils/sorted_dict.js109
-rw-r--r--diplomacy/web/src/diplomacy/utils/strings.js86
-rw-r--r--diplomacy/web/src/diplomacy/utils/utils.js188
-rw-r--r--diplomacy/web/src/gui/core/content.jsx51
-rw-r--r--diplomacy/web/src/gui/core/fancybox.jsx59
-rw-r--r--diplomacy/web/src/gui/core/forms.jsx116
-rw-r--r--diplomacy/web/src/gui/core/layouts.jsx55
-rw-r--r--diplomacy/web/src/gui/core/page.jsx434
-rw-r--r--diplomacy/web/src/gui/core/table.jsx112
-rw-r--r--diplomacy/web/src/gui/core/tabs.jsx96
-rw-r--r--diplomacy/web/src/gui/core/widgets.jsx102
-rw-r--r--diplomacy/web/src/gui/diplomacy/contents/content_connection.jsx91
-rw-r--r--diplomacy/web/src/gui/diplomacy/contents/content_game.jsx1235
-rw-r--r--diplomacy/web/src/gui/diplomacy/contents/content_games.jsx140
-rw-r--r--diplomacy/web/src/gui/diplomacy/forms/connection_form.jsx123
-rw-r--r--diplomacy/web/src/gui/diplomacy/forms/create_form.jsx95
-rw-r--r--diplomacy/web/src/gui/diplomacy/forms/find_form.jsx70
-rw-r--r--diplomacy/web/src/gui/diplomacy/forms/join_form.jsx77
-rw-r--r--diplomacy/web/src/gui/diplomacy/forms/message_form.jsx53
-rw-r--r--diplomacy/web/src/gui/diplomacy/forms/power_actions_form.jsx120
-rw-r--r--diplomacy/web/src/gui/diplomacy/forms/select_location_form.jsx36
-rw-r--r--diplomacy/web/src/gui/diplomacy/forms/select_via_form.jsx35
-rw-r--r--diplomacy/web/src/gui/diplomacy/map/dom_order_builder.js278
-rw-r--r--diplomacy/web/src/gui/diplomacy/map/dom_past_map.js112
-rw-r--r--diplomacy/web/src/gui/diplomacy/map/map.jsx94
-rw-r--r--diplomacy/web/src/gui/diplomacy/map/renderer.js615
-rw-r--r--diplomacy/web/src/gui/diplomacy/utils/dipStorage.jsx140
-rw-r--r--diplomacy/web/src/gui/diplomacy/utils/inline_game_view.jsx129
-rw-r--r--diplomacy/web/src/gui/diplomacy/utils/map_data.js98
-rw-r--r--diplomacy/web/src/gui/diplomacy/utils/order.js24
-rw-r--r--diplomacy/web/src/gui/diplomacy/utils/order_building.js211
-rw-r--r--diplomacy/web/src/gui/diplomacy/utils/power_view.jsx59
-rw-r--r--diplomacy/web/src/gui/diplomacy/utils/province.js117
-rw-r--r--diplomacy/web/src/gui/diplomacy/widgets/message_view.jsx57
-rw-r--r--diplomacy/web/src/gui/diplomacy/widgets/power_order.jsx79
-rw-r--r--diplomacy/web/src/index.css401
-rw-r--r--diplomacy/web/src/index.js28
l---------diplomacy/web/src/standard.svg1
54 files changed, 8232 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);
+ }
+};
diff --git a/diplomacy/web/src/diplomacy/communication/notifications.js b/diplomacy/web/src/diplomacy/communication/notifications.js
new file mode 100644
index 0000000..149df09
--- /dev/null
+++ b/diplomacy/web/src/diplomacy/communication/notifications.js
@@ -0,0 +1,56 @@
+// ==============================================================================
+// 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";
+
+/** Notifications. **/
+export const NOTIFICATIONS = {
+ levels: {
+ // Notification name to notification level ('channel' or 'game').
+ account_deleted: STRINGS.CHANNEL,
+ cleared_centers: STRINGS.GAME,
+ cleared_orders: STRINGS.GAME,
+ cleared_units: STRINGS.GAME,
+ game_deleted: STRINGS.GAME,
+ game_message_received: STRINGS.GAME,
+ game_processed: STRINGS.GAME,
+ game_phase_update: STRINGS.GAME,
+ game_status_update: STRINGS.GAME,
+ omniscient_updated: STRINGS.GAME,
+ power_orders_flag: STRINGS.GAME,
+ power_orders_update: STRINGS.GAME,
+ power_vote_updated: STRINGS.GAME,
+ power_wait_flag: STRINGS.GAME,
+ powers_controllers: STRINGS.GAME,
+ vote_count_updated: STRINGS.GAME,
+ vote_updated: STRINGS.GAME,
+ },
+ parse: function (jsonObject) {
+ if (!jsonObject.hasOwnProperty('name'))
+ throw new Error('No name field in expected notification object.');
+ if (!jsonObject.hasOwnProperty('token'))
+ throw new Error('No token field in expected notification object.');
+ if (!NOTIFICATIONS.levels.hasOwnProperty(jsonObject.name))
+ throw new Error('Invalid notification name ' + jsonObject.name);
+ if (NOTIFICATIONS.levels[jsonObject.name] === STRINGS.GAME) {
+ if (!jsonObject.hasOwnProperty('game_id'))
+ throw new Error('No game_id field in expected game notification object.');
+ if (!jsonObject.hasOwnProperty('game_role'))
+ throw new Error('No game_role field in expected game notification object.');
+ }
+ return jsonObject;
+ }
+};
diff --git a/diplomacy/web/src/diplomacy/communication/requests.js b/diplomacy/web/src/diplomacy/communication/requests.js
new file mode 100644
index 0000000..6902b5f
--- /dev/null
+++ b/diplomacy/web/src/diplomacy/communication/requests.js
@@ -0,0 +1,120 @@
+// ==============================================================================
+// 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";
+
+/** Requests. **/
+export const REQUESTS = {
+ /** Abstract request models, mapping base request field names with default values.
+ * Every request has at least basic fields.
+ * Every channel request has at least basic and channel fields.
+ * Every game request has at least basic, channel and game fields. **/
+ abstract: {
+ basic: {request_id: null, name: null, re_sent: false},
+ channel: {token: null},
+ game: {game_id: null, game_role: null, phase: null},
+ },
+
+ /** Request models. A request model is defined with:
+ * - request level: either null, 'channel' or 'game'
+ * - request model itself: a dictionary mapping each request field name with a default value.
+ * - request phase dependent (optional, for game requests): boolean (default, true)
+ * **/
+ models: {
+ sign_in: {level: null, model: {username: null, password: null, create_user: null}},
+ create_game: {
+ level: STRINGS.CHANNEL,
+ model: {
+ game_id: null, n_controls: null, deadline: 300, registration_password: null,
+ power_name: null, state: null, map_name: 'standard', rules: null
+ }
+ },
+ delete_account: {level: STRINGS.CHANNEL, model: {username: null}},
+ get_all_possible_orders: {level: STRINGS.GAME, model: {}},
+ get_available_maps: {level: STRINGS.CHANNEL, model: {}},
+ get_playable_powers: {level: STRINGS.CHANNEL, model: {game_id: null}},
+ join_game: {level: STRINGS.CHANNEL, model: {game_id: null, power_name: null, registration_password: null}},
+ list_games: {
+ level: STRINGS.CHANNEL,
+ model: {game_id: null, status: null, map_name: null, include_protected: true, for_omniscience: false}
+ },
+ get_games_info: {level: STRINGS.CHANNEL, model: {games: null}},
+ logout: {level: STRINGS.CHANNEL, model: {}},
+ set_grade: {level: STRINGS.CHANNEL, model: {grade: null, grade_update: null, username: null, game_id: null}},
+ clear_centers: {level: STRINGS.GAME, model: {power_name: null}},
+ clear_orders: {level: STRINGS.GAME, model: {power_name: null}},
+ clear_units: {level: STRINGS.GAME, model: {power_name: null}},
+ delete_game: {level: STRINGS.GAME, phase_dependent: false, model: {}},
+ get_phase_history: {
+ level: STRINGS.GAME,
+ phase_dependent: false,
+ model: {from_phase: null, to_phase: null}
+ },
+ leave_game: {level: STRINGS.GAME, model: {}},
+ process_game: {level: STRINGS.GAME, model: {}},
+ query_schedule: {level: STRINGS.GAME, model: {}},
+ send_game_message: {level: STRINGS.GAME, model: {message: null}},
+ set_dummy_powers: {level: STRINGS.GAME, model: {username: null, power_names: null}},
+ set_game_state: {level: STRINGS.GAME, model: {state: null, orders: null, results: null, messages: null}},
+ set_game_status: {level: STRINGS.GAME, model: {status: null}},
+ set_orders: {level: STRINGS.GAME, model: {power_name: null, orders: null}},
+ set_wait_flag: {level: STRINGS.GAME, model: {power_name: null, wait: null}},
+ synchronize: {level: STRINGS.GAME, phase_dependent: false, model: {timestamp: null}},
+ vote: {level: STRINGS.GAME, model: {vote: null}},
+ save_game: {level: STRINGS.GAME, model: {}},
+ },
+
+ isPhaseDependent: function (name) {
+ if (!REQUESTS.models.hasOwnProperty(name))
+ throw new Error('Unknown request name ' + name);
+ const model = REQUESTS.models[name];
+ return (model.level === STRINGS.GAME && (!model.hasOwnProperty('phase_dependent') || model.phase_dependent));
+ },
+
+ /** Return request level for given request name. Either null, 'channel' or 'game'. **/
+ getLevel: function (name) {
+ if (!REQUESTS.models.hasOwnProperty(name))
+ throw new Error('Unknown request name ' + name);
+ return REQUESTS.models[name].level;
+ },
+
+ /** Create a request object for given request name with given request field values.
+ * `parameters` is a dictionary mapping some request fields with values.
+ * Parameters may not contain values for optional request fields. See Python module
+ * diplomacy.communication.requests about requests definitions, required and optional fields.
+ * **/
+ create: function (name, parameters) {
+ if (!REQUESTS.models.hasOwnProperty(name))
+ throw new Error('Unknown request name ' + name);
+ let models = null;
+ const definition = REQUESTS.models[name];
+ if (definition.level === STRINGS.GAME)
+ models = [{}, definition.model, REQUESTS.abstract.game, REQUESTS.abstract.channel];
+ else if (definition.level === STRINGS.CHANNEL)
+ models = [{}, definition.model, REQUESTS.abstract.channel];
+ else
+ models = [{}, definition.model];
+ models.push(REQUESTS.abstract.basic);
+ models.push({name: name});
+ const request = Object.assign.apply(null, models);
+ if (parameters) for (let parameter of Object.keys(parameters)) if (request.hasOwnProperty(parameter))
+ request[parameter] = parameters[parameter];
+ if (!request.request_id)
+ request.request_id = UTILS.createID();
+ return request;
+ },
+};
diff --git a/diplomacy/web/src/diplomacy/communication/responses.js b/diplomacy/web/src/diplomacy/communication/responses.js
new file mode 100644
index 0000000..7c87c67
--- /dev/null
+++ b/diplomacy/web/src/diplomacy/communication/responses.js
@@ -0,0 +1,42 @@
+// ==============================================================================
+// 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";
+
+/** Responses. **/
+export const RESPONSES = {
+ names: new Set([
+ 'error', 'ok', 'data_game_phase', 'data_token', 'data_maps', 'data_power_names', 'data_games',
+ 'data_possible_orders', 'data_game_info', 'data_time_stamp', 'data_game_phases', 'data_game',
+ 'data_game_schedule', 'data_saved_game'
+ ]),
+ parse: function (jsonObject) {
+ if (!jsonObject.hasOwnProperty('name'))
+ throw new Error('No name field in expected response object');
+ if (!RESPONSES.names.has(jsonObject.name))
+ throw new Error('Invalid response name ' + jsonObject.name);
+ if (jsonObject.name === STRINGS.ERROR)
+ throw new Error(jsonObject.name + ': ' + jsonObject.message);
+ return jsonObject;
+ },
+ isOk: function (response) {
+ return response.name === STRINGS.OK;
+ },
+ isUniqueData: function (response) {
+ // Expected only 3 fields: name, request_id, data.
+ return (response.hasOwnProperty('data') && Object.keys(response).length === 3);
+ }
+};
diff --git a/diplomacy/web/src/diplomacy/engine/game.js b/diplomacy/web/src/diplomacy/engine/game.js
new file mode 100644
index 0000000..cc9803e
--- /dev/null
+++ b/diplomacy/web/src/diplomacy/engine/game.js
@@ -0,0 +1,507 @@
+// ==============================================================================
+// 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 {UTILS} from "../utils/utils";
+import {STRINGS} from "../utils/strings";
+import {SortedDict} from "../utils/sorted_dict";
+import {Power} from "./power";
+import {Message} from "./message";
+
+export function comparablePhase(shortPhaseName) {
+ /** Return a unique integer corresponding to given short phase name, so that
+ * phases can be compared using such integers.
+ * **/
+ // Phase 'FORMING' is assumed to be the smallest phase.
+ if (shortPhaseName === 'FORMING')
+ return 0;
+ // Phase 'COMPLETED' is assumed to be the greatest phase.
+ if (shortPhaseName === 'COMPLETED')
+ return Number.MAX_SAFE_INTEGER;
+ if (shortPhaseName.length !== 6)
+ throw new Error(`Invalid short phase name: ${shortPhaseName}`);
+ const seasonOrder = {S: 0, F: 1, W: 2};
+ const stepOrder = {M: 0, R: 1, A: 2};
+ const phaseSeason = shortPhaseName[0];
+ const phaseYear = parseInt(shortPhaseName.substring(1, 5), 10);
+ const phaseStep = shortPhaseName[5];
+ if (isNaN(phaseYear))
+ throw new Error(`Unable to parse phase year from ${shortPhaseName}`);
+ if (!seasonOrder.hasOwnProperty(phaseSeason))
+ throw new Error(`Unable to parse phase season from ${shortPhaseName}`);
+ if (!stepOrder.hasOwnProperty(phaseStep))
+ throw new Error(`Unable to parse phase step from ${shortPhaseName}`);
+ return (phaseYear * 100) + (seasonOrder[phaseSeason] * 10) + stepOrder[phaseStep];
+}
+
+export class Game {
+ constructor(gameData) {
+ ////// Instead of using: `Object.assign(this, gameState)`,
+ ////// we set each field separately to let IDE know all attributes expected for Game class.
+ //// We first check gameState.
+ // These fields must not be null.
+
+ const nonNullFields = [
+ 'game_id', 'map_name', 'messages', 'role', 'rules', 'status', 'timestamp_created', 'deadline',
+ 'message_history', 'order_history', 'state_history'
+ ];
+ // These fields may be null.
+ const nullFields = ['n_controls', 'registration_password'];
+ // All fields are required.
+ for (let field of nonNullFields)
+ if (!gameData.hasOwnProperty(field) || gameData[field] == null)
+ throw new Error('Game: given state must have field `' + field + '` with non-null value.');
+ for (let field of nullFields)
+ if (!gameData.hasOwnProperty(field))
+ throw new Error('Game: given state must have field `' + field + '`.');
+
+ this.game_id = gameData.game_id;
+ this.map_name = gameData.map_name;
+ this.messages = new SortedDict(gameData instanceof Game ? null : gameData.messages, parseInt);
+
+ // {short phase name => state}
+ this.state_history = gameData instanceof Game ? gameData.state_history : new SortedDict(gameData.state_history, comparablePhase);
+ // {short phase name => {power name => [orders]}}
+ this.order_history = gameData instanceof Game ? gameData.order_history : new SortedDict(gameData.order_history, comparablePhase);
+ // {short phase name => {unit => [results]}}
+ this.result_history = gameData instanceof Game ? gameData.result_history : new SortedDict(gameData.result_history, comparablePhase);
+ // {short phase name => {message.time_sent => message}}
+ if (gameData instanceof Game) {
+ this.message_history = gameData.message_history;
+ } else {
+ this.message_history = new SortedDict(null, comparablePhase);
+ for (let entry of Object.entries(gameData.message_history)) {
+ const shortPhaseName = entry[0];
+ const phaseMessages = entry[1];
+ const sortedPhaseMessages = new SortedDict(phaseMessages, parseInt);
+ this.message_history.put(shortPhaseName, sortedPhaseMessages);
+ }
+ }
+
+ this.role = gameData.role;
+ this.rules = gameData.rules;
+ this.status = gameData.status;
+ this.timestamp_created = gameData.timestamp_created;
+ this.deadline = gameData.deadline;
+ this.n_controls = gameData.n_controls;
+ this.registration_password = gameData.registration_password;
+ this.observer_level = gameData.observer_level;
+ this.controlled_powers = gameData.controlled_powers;
+ this.result = gameData.result || null;
+
+ this.phase = gameData.phase_abbr || null; // phase abbreviation
+
+ this.powers = {};
+ if (gameData.powers) {
+ for (let entry of Object.entries(gameData.powers)) {
+ const power_name = entry[0];
+ const powerState = entry[1];
+ if (powerState instanceof Power) {
+ this.powers[power_name] = powerState.copy();
+ } else {
+ this.powers[power_name] = new Power(power_name, (this.isPlayerGame() ? power_name : this.role), this);
+ this.powers[power_name].setState(powerState);
+ }
+ }
+ } else if(this.state_history.size()) {
+ const lastState = this.state_history.lastValue();
+ if (lastState.units) {
+ for (let powerName of Object.keys(lastState.units)) {
+ this.powers[powerName] = new Power(powerName, (this.isPlayerGame() ? powerName : this.role), this);
+ }
+ }
+ }
+
+ this.note = gameData.note;
+ this.builds = null;
+
+ // {location => [possible orders]}
+ this.possibleOrders = null;
+ // {power name => [orderable locations]}
+ this.orderableLocations = null;
+ this.ordersTree = null;
+ // {loc => order type}
+ this.orderableLocToTypes = null;
+ this.client = null; // Used as pointer to a NetworkGame.
+ }
+
+ get n_players() {
+ return this.countControlledPowers();
+ }
+
+ static createOrdersTree(possibleOrders, tree, locToTypes) {
+ for (let orders of Object.values(possibleOrders)) {
+ for (let order of orders) {
+ // We ignore WAIVE order.
+ if (order === 'WAIVE')
+ continue;
+ const pieces = order.split(/ +/);
+ const thirdPiece = pieces[2];
+ const lastPiece = pieces[pieces.length - 1];
+ switch (thirdPiece) {
+ case 'H':
+ // 'H', unit
+ UTILS.javascript.extendTreeValue(tree, ['H'], `${pieces[0]} ${pieces[1]}`);
+ UTILS.javascript.extendArrayWithUniqueValues(locToTypes, pieces[1], 'H');
+ break;
+ case '-':
+ // 'M', unit, province
+ // 'V', unit, province
+ UTILS.javascript.extendTreeValue(tree, ['M', `${pieces[0]} ${pieces[1]}`, pieces[3]], (lastPiece === 'VIA' ? 'V' : 'M'));
+ UTILS.javascript.extendArrayWithUniqueValues(locToTypes, pieces[1], 'M');
+ break;
+ case 'S':
+ // 'S', supporter unit, supported unit, province
+ UTILS.javascript.extendTreeValue(tree, ['S', `${pieces[0]} ${pieces[1]}`, `${pieces[3]} ${pieces[4]}`], lastPiece);
+ UTILS.javascript.extendArrayWithUniqueValues(locToTypes, pieces[1], 'S');
+ break;
+ case 'C':
+ // 'C', convoyer unit, convoyed unit, province
+ UTILS.javascript.extendTreeValue(tree, ['C', `${pieces[0]} ${pieces[1]}`, `${pieces[3]} ${pieces[4]}`], pieces[6]);
+ UTILS.javascript.extendArrayWithUniqueValues(locToTypes, pieces[1], 'C');
+ break;
+ case 'R':
+ // 'R', unit, province
+ UTILS.javascript.extendTreeValue(tree, ['R', `${pieces[0]} ${pieces[1]}`], pieces[3]);
+ UTILS.javascript.extendArrayWithUniqueValues(locToTypes, pieces[1], 'R');
+ break;
+ case 'D':
+ // D, unit
+ UTILS.javascript.extendTreeValue(tree, ['D'], `${pieces[0]} ${pieces[1]}`);
+ UTILS.javascript.extendArrayWithUniqueValues(locToTypes, pieces[1], 'D');
+ break;
+ case 'B':
+ // B, unit
+ UTILS.javascript.extendTreeValue(tree, [pieces[0]], pieces[1]);
+ UTILS.javascript.extendArrayWithUniqueValues(locToTypes, pieces[1], pieces[0]);
+ break;
+ default:
+ throw new Error(`Unable to parse order: ${order}`);
+ }
+ }
+ }
+ }
+
+ extendPhaseHistory(phaseData) {
+ if (this.state_history.contains(phaseData.name)) throw new Error(`Phase ${phaseData.phase} already in state history.`);
+ if (this.message_history.contains(phaseData.name)) throw new Error(`Phase ${phaseData.phase} already in message history.`);
+ if (this.order_history.contains(phaseData.name)) throw new Error(`Phase ${phaseData.phase} already in order history.`);
+ if (this.result_history.contains(phaseData.name)) throw new Error(`Phase ${phaseData.phase} already in result history.`);
+ this.state_history.put(phaseData.name, phaseData.state);
+ this.order_history.put(phaseData.name, phaseData.orders);
+ this.result_history.put(phaseData.name, phaseData.results);
+ this.message_history.put(phaseData.name, new SortedDict(phaseData.messages, parseInt));
+ }
+
+ addMessage(message) {
+ message = new Message(message);
+ if (!message.time_sent)
+ throw new Error('No time sent for given message.');
+ if (this.messages.hasOwnProperty(message.time_sent))
+ throw new Error('There is already a message with time sent ' + message.time_sent + ' in message history.');
+ if (this.isPlayerGame() && !message.isGlobal() && this.role !== message.sender && this.role !== message.recipient)
+ throw new Error('Given message is not related to current player ' + this.role);
+ this.messages.put(message.time_sent, message);
+ }
+
+ assertPlayerGame(powerName) {
+ if (!this.isPlayerGame(powerName))
+ throw new Error('Expected a player game' + (powerName ? (' ' + powerName) : '') + ', got role ' + this.role + '.');
+ }
+
+ assertObserverGame() {
+ if (!this.isObserverGame())
+ throw new Error('Expected an observer game, got role ' + this.role + '.');
+ }
+
+ assertOmniscientGame() {
+ if (!this.isOmniscientGame())
+ throw new Error('Expected an omniscient game, got role ' + this.role + '.');
+ }
+
+ clearCenters(powerName) {
+ for (let power_name of Object.keys(this.powers)) {
+ if (!powerName || power_name === powerName)
+ this.powers[power_name].clearCenters();
+ }
+ }
+
+ clearOrders(powerName) {
+ for (let power_name of Object.keys(this.powers))
+ if (!powerName || power_name === powerName)
+ this.powers[power_name].clearOrders();
+ }
+
+ clearUnits(powerName) {
+ for (let power_name of Object.keys(this.powers)) {
+ if (!powerName || power_name === powerName)
+ this.powers[power_name].clearUnits();
+ }
+ }
+
+ clearVote() {
+ for (let power_name of Object.keys(this.powers))
+ this.powers[power_name].vote = 'neutral';
+ }
+
+ countControlledPowers() {
+ let count = 0;
+ for (let power of Object.values(this.powers))
+ count += power.isControlled() ? 1 : 0;
+ return count;
+ }
+
+ extendStateHistory(state) {
+ if (this.state_history.contains(state.name))
+ throw new Error('There is already a state with phase ' + state.name + ' in state history.');
+ this.state_history.put(state.name, state);
+ }
+
+ getLatestTimestamp() {
+ return Math.max(
+ this.timestamp_created,
+ (this.state_history.size() ? this.state_history.lastValue().timestamp : 0),
+ (this.messages.size() ? this.messages.lastKey() : 0)
+ );
+ }
+
+ getPower(name) {
+ return this.powers.hasOwnProperty(name) ? this.powers[name] : null;
+ }
+
+ getRelatedPower() {
+ return this.getPower(this.role);
+ }
+
+ hasPower(powerName) {
+ return this.powers.hasOwnProperty(powerName);
+ }
+
+ isPlayerGame(powerName) {
+ return (this.hasPower(this.role) && (!powerName || this.role === powerName));
+ }
+
+ isObserverGame() {
+ return this.role === STRINGS.OBSERVER_TYPE;
+ }
+
+ isOmniscientGame() {
+ return this.role === STRINGS.OMNISCIENT_TYPE;
+ }
+
+ isRealTime() {
+ return this.rules.includes('REAL_TIME');
+ }
+
+ isNoCheck() {
+ return this.rules.includes('NO_CHECK');
+ }
+
+ setPhaseData(phaseData) {
+ this.setState(phaseData.state);
+ this.clearOrders();
+ for (let entry of Object.entries(phaseData.orders)) {
+ if (entry[1])
+ this.setOrders(entry[0], entry[1]);
+ }
+ this.messages = phaseData.messages instanceof SortedDict ? phaseData.messages : new SortedDict(phaseData.messages, parseInt);
+ }
+
+ setState(state) {
+ this.result = state.result || null;
+ this.note = state.note || null;
+ this.phase = state.name;
+ if (state.units) {
+ for (let power_name of Object.keys(state.units)) {
+ if (this.powers.hasOwnProperty(power_name)) {
+ const units = state.units[power_name];
+ const power = this.powers[power_name];
+ power.retreats = {};
+ power.units = [];
+ for (let unit of units) {
+ if (unit.charAt(0) === '*')
+ power.retreats[unit.substr(1)] = {};
+ else
+ power.units.push(unit);
+ }
+ }
+ }
+ }
+ if (state.centers)
+ for (let power_name of Object.keys(state.centers))
+ if (this.powers.hasOwnProperty(power_name))
+ this.powers[power_name].centers = state.centers[power_name];
+ if (state.homes)
+ for (let power_name of Object.keys(state.homes))
+ if (this.powers.hasOwnProperty(power_name))
+ this.powers[power_name].homes = state.homes[power_name];
+ if (state.influence)
+ for (let power_name of Object.keys(state.influence))
+ if (this.powers.hasOwnProperty(power_name))
+ this.powers[power_name].influence = state.influence[power_name];
+ if (state.civil_disorder)
+ for (let power_name of Object.keys(state.civil_disorder))
+ if (this.powers.hasOwnProperty(power_name))
+ this.powers[power_name].civil_disorder = state.civil_disorder[power_name];
+ if (state.builds)
+ this.builds = state.builds;
+ }
+
+ setStatus(status) {
+ this.status = status;
+ }
+
+ setOrders(powerName, orders) {
+ if (this.powers.hasOwnProperty(powerName) && (!this.isPlayerGame() || this.isPlayerGame(powerName)))
+ this.powers[powerName].setOrders(orders);
+ }
+
+ setWait(powerName, wait) {
+ if (this.powers.hasOwnProperty(powerName)) {
+ this.powers[powerName].wait = wait;
+ }
+ }
+
+ updateDummyPowers(dummyPowers) {
+ for (let dummyPowerName of dummyPowers) if (this.powers.hasOwnProperty(dummyPowerName))
+ this.powers[dummyPowerName].setDummy();
+ }
+
+ updatePowersControllers(controllers, timestamps) {
+ for (let entry of Object.entries(controllers)) {
+ this.getPower(entry[0]).updateController(entry[1], timestamps[entry[0]]);
+ }
+ }
+
+ cloneAt(pastPhase) {
+ if (pastPhase !== null && this.state_history.contains(pastPhase)) {
+ const messages = this.message_history.get(pastPhase);
+ const orders = this.order_history.get(pastPhase);
+ const state = this.state_history.get(pastPhase);
+ const game = new Game(this);
+ game.setPhaseData({
+ name: pastPhase,
+ state: state,
+ orders: orders,
+ messages: messages
+ });
+ return game;
+ }
+ return null;
+ }
+
+ getPhaseType() {
+ if (this.phase === null || this.phase === 'FORMING' || this.phase === 'COMPLETED')
+ return null;
+ return this.phase[this.phase.length - 1];
+ }
+
+ getControllablePowers() {
+ if (!this.isObserverGame()) {
+ if (this.isOmniscientGame())
+ return Object.keys(this.powers);
+ return [this.role];
+ }
+ return [];
+ }
+
+ getMessageChannels() {
+ const messageChannels = {};
+ let messages = this.messages;
+ if (!messages.size() && this.message_history.contains(this.phase))
+ messages = this.message_history.get(this.phase);
+ if (this.isPlayerGame()) {
+ for (let message of messages.values()) {
+ let protagonist = null;
+ if (message.sender === this.role || message.recipient === 'GLOBAL')
+ protagonist = message.recipient;
+ else if (message.recipient === this.role)
+ protagonist = message.sender;
+ if (!messageChannels.hasOwnProperty(protagonist))
+ messageChannels[protagonist] = [];
+ messageChannels[protagonist].push(message);
+ }
+ } else {
+ messageChannels['messages'] = messages.values();
+ }
+ return messageChannels;
+ }
+
+ markAllMessagesRead() {
+ for (let message of this.messages.values()) {
+ if (message.sender !== this.role)
+ message.read = true;
+ }
+ }
+
+ setPossibleOrders(possibleOrders) {
+ this.possibleOrders = possibleOrders.possible_orders;
+ this.orderableLocations = possibleOrders.orderable_locations;
+ this.ordersTree = {};
+ this.orderableLocToTypes = {};
+ Game.createOrdersTree(this.possibleOrders, this.ordersTree, this.orderableLocToTypes);
+ }
+
+ getOrderTypeToLocs(powerName) {
+ const typeToLocs = {};
+ for (let loc of this.orderableLocations[powerName]) {
+ // loc may be a coastal province. In such case, we must check province coasts too.
+ const associatedLocs = [];
+ for (let possibleLoc of Object.keys(this.orderableLocToTypes)) {
+ if (possibleLoc.substring(0, 3).toUpperCase() === loc.toUpperCase()) {
+ associatedLocs.push(possibleLoc);
+ }
+ }
+ for (let associatedLoc of associatedLocs) {
+ const orderTypes = this.orderableLocToTypes[associatedLoc];
+ for (let orderType of orderTypes) {
+ if (!typeToLocs.hasOwnProperty(orderType))
+ typeToLocs[orderType] = [associatedLoc];
+ else
+ typeToLocs[orderType].push(associatedLoc);
+ }
+ }
+ }
+ return typeToLocs;
+ }
+
+ _build_sites(power) {
+ const homes = this.rules.includes('BUILD_ANY') ? power.centers : power.homes;
+ const occupiedLocations = [];
+ for (let p of Object.values(this.powers)) {
+ for (let unit of p.units) {
+ occupiedLocations.push(unit.substring(2, 5));
+ }
+ }
+ const buildSites = [];
+ for (let h of homes) {
+ if (power.centers.includes(h) && !occupiedLocations.includes(h))
+ buildSites.push(h);
+ }
+ return buildSites;
+ }
+
+ getBuildsCount(powerName) {
+ if (this.getPhaseType() !== 'A')
+ return 0;
+ const power = this.powers[powerName];
+ let buildCount = power.centers.length - power.units.length;
+ if (buildCount > 0) {
+ const buildSites = this._build_sites(power);
+ buildCount = Math.min(buildSites.length, buildCount);
+ }
+ return buildCount;
+ }
+}
diff --git a/diplomacy/web/src/diplomacy/engine/message.js b/diplomacy/web/src/diplomacy/engine/message.js
new file mode 100644
index 0000000..c91ab6f
--- /dev/null
+++ b/diplomacy/web/src/diplomacy/engine/message.js
@@ -0,0 +1,34 @@
+// ==============================================================================
+// 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/>.
+// ==============================================================================
+const GLOBAL = 'GLOBAL';
+
+export class Message {
+
+ constructor(message) {
+ Object.assign(this, message);
+ this.time_sent = message.time_sent;
+ this.phase = message.phase;
+ this.sender = message.sender;
+ this.recipient = message.recipient;
+ this.message = message.message;
+ }
+
+ isGlobal() {
+ return this.recipient === GLOBAL;
+ }
+
+}
diff --git a/diplomacy/web/src/diplomacy/engine/power.js b/diplomacy/web/src/diplomacy/engine/power.js
new file mode 100644
index 0000000..d13dcd4
--- /dev/null
+++ b/diplomacy/web/src/diplomacy/engine/power.js
@@ -0,0 +1,129 @@
+// ==============================================================================
+// 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/>.
+// ==============================================================================
+/** Class Power. **/
+import {SortedDict} from "../utils/sorted_dict";
+import {STRINGS} from "../utils/strings";
+
+export class Power {
+ constructor(name, role, game) {
+ this.game = game;
+ this.role = role;
+
+ this.name = name;
+ this.controller = new SortedDict();
+ this.vote = null;
+ this.order_is_set = 0;
+ this.wait = !this.game.isRealTime();
+ this.centers = [];
+ this.homes = [];
+ this.units = [];
+ this.retreats = {};
+ this.orders = [];
+ this.influence = [];
+ }
+
+ isControlled() {
+ if (this.controller && this.controller.size()) {
+ return this.controller.lastValue() !== STRINGS.DUMMY;
+ }
+ return false;
+ }
+
+ getController() {
+ return (this.controller && this.controller.lastValue()) || STRINGS.DUMMY;
+ }
+
+ isEliminated() {
+ return !(this.units.length || this.centers.length || Object.keys(this.retreats).length);
+ }
+
+ setState(powerState) {
+ this.name = powerState.name;
+ this.controller = new SortedDict(powerState.controller);
+ this.vote = powerState.vote;
+ this.order_is_set = powerState.order_is_set;
+ this.wait = powerState.wait;
+ this.centers = powerState.centers;
+ this.homes = powerState.homes;
+ this.units = powerState.units;
+ this.retreats = powerState.retreats;
+ this.influence = powerState.influence || [];
+ // Get orders.
+ this.orders = [];
+ if (this.game.phase.charAt(this.game.phase.length - 1) === 'M') {
+ if (this.game.isNoCheck()) {
+ for (let value of Object.values(powerState.orders)) if (value)
+ this.orders.push(value);
+ } else {
+ for (let unit of Object.keys(powerState.orders))
+ this.orders.push(unit + ' ' + powerState.orders[unit]);
+ }
+ } else {
+ for (let order of powerState.adjust)
+ if (order && order !== 'WAIVE' && !order.startsWith('VOID '))
+ this.orders.push(order);
+ }
+ }
+
+ copy() {
+ const power = new Power(this.name, this.role, this.game);
+ for (let key of this.controller.keys())
+ power.controller.put(key, this.controller.get(key));
+ power.vote = this.vote;
+ power.order_is_set = this.order_is_set;
+ power.wait = this.wait;
+ power.centers = this.centers.slice();
+ power.homes = this.homes.slice();
+ power.units = this.units.slice();
+ power.retreats = Object.assign({}, this.retreats);
+ power.influence = this.influence.slice();
+ power.orders = this.orders.slice();
+ return power;
+ }
+
+ updateController(controller, timestamp) {
+ this.controller.put(timestamp, controller);
+ }
+
+ setOrders(orders) {
+ this.orders = orders.slice();
+ this.order_is_set = this.orders.length ? 2 : 1;
+ }
+
+ setDummy() {
+ this.controller.clear();
+ }
+
+ clearCenters() {
+ this.centers = [];
+ }
+
+ clearOrders() {
+ this.orders = [];
+ this.order_is_set = 0;
+ this.wait = !this.game.isRealTime();
+ }
+
+ clearUnits() {
+ this.units = [];
+ this.influence = [];
+ }
+
+ getOrders() {
+ return this.orders.slice();
+ }
+}
diff --git a/diplomacy/web/src/diplomacy/utils/diplog.js b/diplomacy/web/src/diplomacy/utils/diplog.js
new file mode 100644
index 0000000..1e4a753
--- /dev/null
+++ b/diplomacy/web/src/diplomacy/utils/diplog.js
@@ -0,0 +1,45 @@
+// ==============================================================================
+// 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-console: ["error", {allow: ["log", "info", "warn", "error"]}] */
+export class Diplog {
+ static error(msg) {
+ console.error(msg);
+ }
+
+ static warn(msg) {
+ console.warn(msg);
+ }
+
+ static info(msg) {
+ console.info(msg);
+ }
+
+ static success(msg) {
+ console.log(msg);
+ }
+
+ static printMessages(messages) {
+ if (messages) {
+ if (messages.error)
+ Diplog.error(messages.error);
+ if (messages.info)
+ Diplog.info(messages.info);
+ if (messages.success)
+ Diplog.success(messages.success);
+ }
+ }
+}
diff --git a/diplomacy/web/src/diplomacy/utils/future.js b/diplomacy/web/src/diplomacy/utils/future.js
new file mode 100644
index 0000000..c8d8add
--- /dev/null
+++ b/diplomacy/web/src/diplomacy/utils/future.js
@@ -0,0 +1,55 @@
+// ==============================================================================
+// 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/>.
+// ==============================================================================
+/** Class Future (like Python's Tornado future). **/
+export class Future {
+ constructor() {
+ this.__resolve_fn = null;
+ this.__reject_fn = null;
+ this.__promise = null;
+ this.__done = false;
+
+ const future = this;
+ this.__promise = new Promise((resolve, reject) => {
+ future.__resolve_fn = resolve;
+ future.__reject_fn = reject;
+ });
+ }
+
+ promise() {
+ return this.__promise;
+ }
+
+ setResult(result) {
+ if (!this.done()) {
+ this.__done = true;
+ const resolve_fn = this.__resolve_fn;
+ resolve_fn(result);
+ }
+ }
+
+ setException(exception) {
+ if (!this.done()) {
+ this.__done = true;
+ const reject_fn = this.__reject_fn;
+ reject_fn(exception);
+ }
+ }
+
+ done() {
+ return this.__done;
+ }
+}
diff --git a/diplomacy/web/src/diplomacy/utils/future_event.js b/diplomacy/web/src/diplomacy/utils/future_event.js
new file mode 100644
index 0000000..a9dfcd8
--- /dev/null
+++ b/diplomacy/web/src/diplomacy/utils/future_event.js
@@ -0,0 +1,41 @@
+// ==============================================================================
+// 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 "./future";
+
+/** Class FutureEvent (like Python's Tornado FutureEvent). **/
+export class FutureEvent {
+ constructor() {
+ this.__future = new Future();
+ }
+
+ set(error) {
+ if (!this.__future.done())
+ if (error)
+ this.__future.setException(error);
+ else
+ this.__future.setResult(null);
+ }
+
+ clear() {
+ if (this.__future.done())
+ this.__future = new Future();
+ }
+
+ wait() {
+ return this.__future.promise();
+ }
+}
diff --git a/diplomacy/web/src/diplomacy/utils/sorted_dict.js b/diplomacy/web/src/diplomacy/utils/sorted_dict.js
new file mode 100644
index 0000000..6a27f00
--- /dev/null
+++ b/diplomacy/web/src/diplomacy/utils/sorted_dict.js
@@ -0,0 +1,109 @@
+// ==============================================================================
+// 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 {UTILS} from "./utils";
+
+function defaultComparableKey(key) {
+ return key;
+}
+
+export class SortedDict {
+ constructor(dct, keyFn) {
+ this.__real_keys = [];
+ this.__keys = [];
+ this.__values = [];
+ this.__key_fn = keyFn || defaultComparableKey;
+ if (dct) for (let key of Object.keys(dct))
+ this.put(key, dct[key]);
+ }
+
+ clear() {
+ this.__real_keys = [];
+ this.__keys = [];
+ this.__values = [];
+ }
+
+ put(key, value) {
+ const realKey = key;
+ key = this.__key_fn(key);
+ const position = UTILS.binarySearch.insert(this.__keys, key);
+ if (position === this.__values.length) {
+ this.__values.push(value);
+ this.__real_keys.push(realKey);
+ } else if (this.__values[position] !== value) {
+ this.__values.splice(position, 0, value);
+ this.__real_keys.splice(position, 0, realKey);
+ }
+ return position;
+ }
+
+ remove(key) {
+ key = this.__key_fn(key);
+ const position = UTILS.binarySearch.find(this.__keys, key);
+ if (position < 0)
+ return null;
+ this.__keys.splice(position, 1);
+ this.__real_keys.splice(position, 1);
+ return this.__values.splice(position, 1)[0];
+ }
+
+ contains(key) {
+ return UTILS.binarySearch.find(this.__keys, this.__key_fn(key)) >= 0;
+ }
+
+ get(key) {
+ const position = UTILS.binarySearch.find(this.__keys, this.__key_fn(key));
+ if (position < 0)
+ return null;
+ return this.__values[position];
+ }
+
+ indexOf(key) {
+ return UTILS.binarySearch.find(this.__keys, this.__key_fn(key));
+ }
+
+ keyFromIndex(index) {
+ return this.__real_keys[index];
+ }
+
+ valueFromIndex(index) {
+ return this.__values[index];
+ }
+
+ size() {
+ return this.__keys.length;
+ }
+
+ lastKey() {
+ if (!this.__keys.length)
+ throw new Error('Sorted dict is empty.');
+ return this.__real_keys[this.__keys.length - 1];
+ }
+
+ lastValue() {
+ if (!this.__keys.length)
+ throw new Error('Sorted dict is empty.');
+ return this.__values[this.__values.length - 1];
+ }
+
+ keys() {
+ return this.__real_keys.slice();
+ }
+
+ values() {
+ return this.__values.slice();
+ }
+}
diff --git a/diplomacy/web/src/diplomacy/utils/strings.js b/diplomacy/web/src/diplomacy/utils/strings.js
new file mode 100644
index 0000000..651e878
--- /dev/null
+++ b/diplomacy/web/src/diplomacy/utils/strings.js
@@ -0,0 +1,86 @@
+// ==============================================================================
+// 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/>.
+// ==============================================================================
+/** Strings. **/
+export const STRINGS = {
+ ACTIVE: 'active',
+ ADMIN: 'admin',
+ CANCELED: 'canceled',
+ CHANNEL: 'channel',
+ COMPLETED: 'completed',
+ DEMOTE: 'demote',
+ DUMMY: 'dummy',
+ ERROR: 'error',
+ GAME: 'game',
+ MASTER_TYPE: 'master_type',
+ MODERATOR: 'moderator',
+ OBSERVER: 'observer',
+ OBSERVER_TYPE: 'observer_type',
+ OK: 'ok',
+ OMNISCIENT: 'omniscient',
+ OMNISCIENT_TYPE: 'omniscient_type',
+ PAUSED: 'paused',
+ PHASE: 'phase',
+ PROMOTE: 'promote',
+ STATE: 'state',
+ STATE_HISTORY: 'state_history',
+ SYNCHRONIZE: 'synchronize',
+ ALL_GAME_STATUSES: ['forming', 'active', 'paused', 'completed', 'canceled'],
+ ALL_POWER_NAMES: ['AUSTRIA', 'ENGLAND', 'FRANCE', 'GERMANY', 'ITALY', 'RUSSIA', 'TURKEY'],
+ RULES: [
+ 'ALWAYS_WAIT',
+ 'BUILD_ANY',
+ 'CD_DUMMIES',
+ 'CIVIL_DISORDER',
+ 'DIFFERENT_ADJUDICATION',
+ 'DONT_SKIP_PHASES',
+ 'HOLD_WIN',
+ 'IGNORE_ERRORS',
+ 'MULTIPLE_POWERS_PER_PLAYER',
+ 'NO_CHECK',
+ 'NO_DEADLINE',
+ 'NO_DIAS',
+ 'NO_OBSERVATIONS',
+ 'NO_PRESS',
+ 'POWER_CHOICE',
+ 'PROPOSE_DIAS',
+ 'PUBLIC_PRESS',
+ 'REAL_TIME',
+ 'SHARED_VICTORY',
+ 'SOLITAIRE',
+ 'START_MASTER',
+ ],
+ PUBLIC_RULES: [
+ 'ALWAYS_WAIT',
+ 'BUILD_ANY',
+ 'CD_DUMMIES',
+ 'CIVIL_DISORDER',
+ 'DONT_SKIP_PHASES',
+ 'HOLD_WIN',
+ 'IGNORE_ERRORS',
+ 'MULTIPLE_POWERS_PER_PLAYER',
+ 'NO_DEADLINE',
+ 'NO_DIAS',
+ 'NO_OBSERVATIONS',
+ 'NO_PRESS',
+ 'PROPOSE_DIAS',
+ 'PUBLIC_PRESS',
+ 'REAL_TIME',
+ 'SHARED_VICTORY',
+ 'SOLITAIRE',
+ 'START_MASTER',
+ ]
+};
diff --git a/diplomacy/web/src/diplomacy/utils/utils.js b/diplomacy/web/src/diplomacy/utils/utils.js
new file mode 100644
index 0000000..f398cd2
--- /dev/null
+++ b/diplomacy/web/src/diplomacy/utils/utils.js
@@ -0,0 +1,188 @@
+// ==============================================================================
+// 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/>.
+// ==============================================================================
+/** Utils. **/
+
+class Dict {
+}
+
+export const UTILS = {
+ NB_CONNECTION_ATTEMPTS: 12,
+ ATTEMPT_DELAY_SECONDS: 5,
+ REQUEST_TIMEOUT_SECONDS: 30,
+
+ /** Return a random integer in interval [from, to). **/
+ randomInteger: function (from, to) {
+ return Math.floor(Math.random() * (to - from) + from);
+ },
+
+ /** Create an ID string using current time + 5 random integers each with 10 digits. **/
+ createID: function () {
+ let id = new Date().getTime().toString(10);
+ for (let i = 0; i < 5; ++i)
+ id += UTILS.randomInteger(1e9, 1e10);
+ return id;
+ },
+
+ date: function () {
+ const d = new Date();
+ return d.toLocaleString() + '.' + d.getMilliseconds();
+ },
+
+ microsecondsToDate: function (time) {
+ return new Date(Math.floor(time / 1000));
+ },
+
+ binarySearch: {
+ find: function (array, element) {
+ let a = 0;
+ let b = array.length - 1;
+ while (a <= b) {
+ const c = Math.floor((a + b) / 2);
+ if (array[c] === element)
+ return c;
+ if (array[c] < element)
+ a = c + 1;
+ else
+ b = c - 1;
+ }
+ return -1;
+ },
+ insert: function (array, element) {
+ let a = 0;
+ let b = array.length - 1;
+ while (a <= b) {
+ const c = Math.floor((a + b) / 2);
+ if (array[c] === element)
+ return c;
+ if (array[c] < element)
+ a = c + 1;
+ else
+ b = c - 1;
+ }
+ // If we go out of loop, then array[b] < element, so we must insert element at position b + 1.
+ if (b < array.length - 1)
+ array.splice(b + 1, 0, element);
+ else
+ array.push(element);
+ return b + 1;
+ }
+ },
+
+ javascript: {
+
+ arrayIsEmpty: function (array) {
+ return !(array && array.length);
+ },
+
+ hasArray: function (array) {
+ return array && array.length;
+ },
+
+ clearObject: function (obj) {
+ const keys = Object.keys(obj);
+ for (let key of keys)
+ delete obj[key];
+ },
+
+ /** Create a dictionary from given array, using array elements as dictionary values
+ * and array elements's `field` values (element[field]) as dictionary keys. **/
+ arrayToDict: function (array, field) {
+ const dictionary = {};
+ for (let entry of array)
+ dictionary[entry[field]] = entry;
+ return dictionary;
+ },
+
+ count(obj) {
+ return Object.keys(obj).length;
+ },
+
+ extendArrayWithUniqueValues(obj, key, value) {
+ if (!obj.hasOwnProperty(key))
+ obj[key] = [value];
+ else if (!obj[key].includes(value))
+ obj[key].push(value);
+ },
+
+ extendTreeValue: function (obj, path, value, allowMultipleValues) {
+ let current = obj;
+ const pathLength = path.length;
+ const parentPathLength = pathLength - 1;
+ for (let i = 0; i < parentPathLength; ++i) {
+ const stepName = path[i];
+ if (!current.hasOwnProperty(stepName))
+ current[stepName] = new Dict();
+ current = current[stepName];
+ }
+ const stepName = path[pathLength - 1];
+ if (!current.hasOwnProperty(stepName))
+ current[stepName] = [];
+ if (allowMultipleValues || !current[stepName].includes(value))
+ current[stepName].push(value);
+ },
+
+ getTreeValue: function (obj, path) {
+ let current = obj;
+ for (let stepName of path) {
+ if (!current.hasOwnProperty(stepName))
+ return null;
+ current = current[stepName];
+ }
+ if (current instanceof Dict)
+ return Object.keys(current);
+ return current;
+ }
+ },
+
+ html: {
+
+ // Source: https://www.w3schools.com/charsets/ref_utf_geometric.asp
+ UNICODE_LEFT_ARROW: '\u25C0',
+ UNICODE_RIGHT_ARROW: '\u25B6',
+ UNICODE_TOP_ARROW: '\u25BC',
+ UNICODE_BOTTOM_ARROW: '\u25B2',
+ CROSS: '\u00D7',
+ UNICODE_SMALL_RIGHT_ARROW: '\u2192',
+ UNICODE_SMALL_LEFT_ARROW: '\u2190',
+
+ isSelect: function (element) {
+ return element.tagName.toLowerCase() === 'select';
+ },
+
+ isInput: function (element) {
+ return element.tagName.toLowerCase() === 'input';
+ },
+
+ isCheckBox: function (element) {
+ return UTILS.html.isInput(element) && element.type === 'checkbox';
+ },
+
+ isRadioButton: function (element) {
+ return UTILS.html.isInput(element) && element.type === 'radio';
+ },
+
+ isTextInput: function (element) {
+ return UTILS.html.isInput(element) && element.type === 'text';
+ },
+
+ isPasswordInput: function (element) {
+ return UTILS.html.isInput(element) && element.type === 'password';
+ }
+
+ }
+
+};
diff --git a/diplomacy/web/src/gui/core/content.jsx b/diplomacy/web/src/gui/core/content.jsx
new file mode 100644
index 0000000..416ba9e
--- /dev/null
+++ b/diplomacy/web/src/gui/core/content.jsx
@@ -0,0 +1,51 @@
+// ==============================================================================
+// 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 React from 'react';
+import PropTypes from 'prop-types';
+
+export class Content extends React.Component {
+ // PROPERTIES:
+ // page: pointer to parent Page object
+ // data: data for current content
+
+ // Each derived class must implement this static method.
+ static builder(page, data) {
+ return {
+ // page title (string)
+ title: `${data ? 'with data' : 'without data'}`,
+ // page navigation links: array of couples
+ // (navigation title, navigation callback ( onClick=() => callback() ))
+ navigation: [],
+ // page content: React component (e.g. <MyComponent/>, or <div class="content">...</div>, etc).
+ component: null
+ };
+ }
+
+ getPage() {
+ return this.props.page;
+ }
+
+ componentDidMount() {
+ window.scrollTo(0, 0);
+ }
+}
+
+
+Content.propTypes = {
+ page: PropTypes.object.isRequired,
+ data: PropTypes.object
+};
diff --git a/diplomacy/web/src/gui/core/fancybox.jsx b/diplomacy/web/src/gui/core/fancybox.jsx
new file mode 100644
index 0000000..4d1013d
--- /dev/null
+++ b/diplomacy/web/src/gui/core/fancybox.jsx
@@ -0,0 +1,59 @@
+// ==============================================================================
+// 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 React from 'react';
+import {Button} from "./widgets";
+import PropTypes from 'prop-types';
+
+const TIMES = '\u00D7';
+
+export class FancyBox extends React.Component {
+ // open-tag (<FancyBox></FancyBox>)
+ // PROPERTIES
+ // title
+ // onClose
+ render() {
+ return (
+ <div className={'fancy-wrapper'} onClick={this.props.onClose}>
+ <div className={'fancy-box container'} onClick={(event) => {
+ if (!event)
+ event = window.event;
+ if (event.hasOwnProperty('cancelBubble'))
+ event.cancelBubble = true;
+ if (event.stopPropagation)
+ event.stopPropagation();
+ }}>
+ <div className={'row fancy-bar'}>
+ <div className={'col-11 align-self-center fancy-title'}>{this.props.title}</div>
+ <div className={'col-1 fancy-button'}>
+ <Button title={TIMES} color={'danger'} onClick={this.props.onClose}/>
+ </div>
+ </div>
+ <div className={'row'}>
+ <div className={'col fancy-content'}>{this.props.children}</div>
+ </div>
+ </div>
+ </div>
+ );
+ }
+}
+
+
+FancyBox.propTypes = {
+ title: PropTypes.string.isRequired,
+ onClose: PropTypes.func.isRequired,
+ children: PropTypes.oneOfType([PropTypes.array, PropTypes.object])
+};
diff --git a/diplomacy/web/src/gui/core/forms.jsx b/diplomacy/web/src/gui/core/forms.jsx
new file mode 100644
index 0000000..76d188c
--- /dev/null
+++ b/diplomacy/web/src/gui/core/forms.jsx
@@ -0,0 +1,116 @@
+// ==============================================================================
+// 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 React from "react";
+import {Button} from "./widgets";
+import {UTILS} from "../../diplomacy/utils/utils";
+
+export class Forms {
+ static createOnChangeCallback(component, callback) {
+ return (event) => {
+ const value = UTILS.html.isCheckBox(event.target) ? event.target.checked : event.target.value;
+ const fieldName = UTILS.html.isRadioButton(event.target) ? event.target.name : event.target.id;
+ const update = {[fieldName]: value};
+ const state = Object.assign({}, component.state, update);
+ if (callback)
+ callback(state);
+ component.setState(state);
+ };
+ }
+
+ static createOnSubmitCallback(component, callback, resetState) {
+ return (event) => {
+ if (callback)
+ callback(Object.assign({}, component.state));
+ if (resetState)
+ component.setState(resetState);
+ event.preventDefault();
+ };
+ }
+
+ static createOnResetCallback(component, onChangeCallback, resetState) {
+ return (event) => {
+ if (onChangeCallback)
+ onChangeCallback(resetState);
+ component.setState(resetState);
+ if (event && event.preventDefault)
+ event.preventDefault();
+ };
+ }
+
+ static getValue(fieldValues, fieldName, defaultValue) {
+ return fieldValues.hasOwnProperty(fieldName) ? fieldValues[fieldName] : defaultValue;
+ }
+
+ static createReset(title, large, onReset) {
+ return <Button key={'reset'} title={title || 'reset'} onClick={onReset} pickEvent={true} large={large}/>;
+ }
+
+ static createSubmit(title, large, onSubmit) {
+ return <Button key={'submit'} title={title || 'submit'} onClick={onSubmit} pickEvent={true} large={large}/>;
+ }
+
+ static createButton(title, fn, color, large) {
+ const wrapFn = (event) => {
+ fn();
+ event.preventDefault();
+ };
+ return <Button large={large} key={title} color={color} title={title} onClick={wrapFn} pickEvent={true}/>;
+ }
+
+ static createCheckbox(id, title, value, onChange) {
+ const input = <input className={'form-check-input'} key={id} type={'checkbox'} id={id} checked={value}
+ onChange={onChange}/>;
+ const label = <label className={'form-check-label'} key={`label-${id}`} htmlFor={id}>{title}</label>;
+ return [input, label];
+ }
+
+ static createRadio(name, value, title, currentValue, onChange) {
+ const id = `[${name}][${value}]`;
+ const input = <input className={'form-check-input'} key={id} type={'radio'}
+ name={name} value={value} checked={currentValue === value}
+ id={id} onChange={onChange}/>;
+ const label = <label className={'form-check-label'} key={`label-${id}`} htmlFor={id}>{title || value}</label>;
+ return [input, label];
+ }
+
+ static createRow(label, input) {
+ return (
+ <div className={'form-group row'}>
+ {label}
+ <div className={'col'}>{input}</div>
+ </div>
+ );
+ }
+
+ static createLabel(htmFor, title, className) {
+ return <label className={className} htmlFor={htmFor}>{title}</label>;
+ }
+
+ static createColLabel(htmlFor, title) {
+ return Forms.createLabel(htmlFor, title, 'col');
+ }
+
+ static createSelectOptions(values, none) {
+ const options = values.slice();
+ const components = options.map((option, index) => <option key={index} value={option}>{option}</option>);
+ if (none) {
+ components.splice(0, 0, [<option key={-1} value={''}>{none === true ? '(none)' : `${none}`}</option>]);
+ }
+ return components;
+ }
+}
+
diff --git a/diplomacy/web/src/gui/core/layouts.jsx b/diplomacy/web/src/gui/core/layouts.jsx
new file mode 100644
index 0000000..78189e4
--- /dev/null
+++ b/diplomacy/web/src/gui/core/layouts.jsx
@@ -0,0 +1,55 @@
+// ==============================================================================
+// 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 React from 'react';
+import PropTypes from 'prop-types';
+
+class Div extends React.Component {
+ getClassName() {
+ return '';
+ }
+
+ render() {
+ return (
+ <div className={this.getClassName() + (this.props.className ? ' ' + this.props.className : '')}>
+ {this.props.children}
+ </div>
+ );
+ }
+}
+
+Div.propTypes = {
+ className: PropTypes.string,
+ children: PropTypes.oneOfType([PropTypes.array, PropTypes.object])
+};
+
+export class Bar extends Div {
+ getClassName() {
+ return 'bar';
+ }
+}
+
+export class Row extends Div {
+ getClassName() {
+ return 'row';
+ }
+}
+
+export class Col extends Div {
+ getClassName() {
+ return 'col';
+ }
+}
diff --git a/diplomacy/web/src/gui/core/page.jsx b/diplomacy/web/src/gui/core/page.jsx
new file mode 100644
index 0000000..5ca09fd
--- /dev/null
+++ b/diplomacy/web/src/gui/core/page.jsx
@@ -0,0 +1,434 @@
+// ==============================================================================
+// 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/>.
+// ==============================================================================
+/** Main class to use to create app GUI. **/
+
+import React from "react";
+import {ContentConnection} from "../diplomacy/contents/content_connection";
+import {ContentGames} from "../diplomacy/contents/content_games";
+import {ContentGame} from "../diplomacy/contents/content_game";
+import {UTILS} from "../../diplomacy/utils/utils";
+import {Diplog} from "../../diplomacy/utils/diplog";
+import {STRINGS} from "../../diplomacy/utils/strings";
+import {Game} from "../../diplomacy/engine/game";
+import Octicon, {Person} from '@githubprimer/octicons-react';
+import $ from "jquery";
+import {FancyBox} from "./fancybox";
+import {DipStorage} from "../diplomacy/utils/dipStorage";
+
+const CONTENTS = {
+ connection: ContentConnection,
+ games: ContentGames,
+ game: ContentGame
+};
+
+export class Page extends React.Component {
+
+ constructor(props) {
+ super(props);
+ this.connection = null;
+ this.channel = null;
+ this.availableMaps = null;
+ this.state = {
+ // fancybox,
+ fancyTitle: null,
+ onFancyBox: null,
+ // Page messages
+ error: null,
+ info: null,
+ success: null,
+ title: null,
+ // Page content parameters
+ contentName: 'connection',
+ contentData: null,
+ // Games.
+ games: {}, // Games found.
+ myGames: {} // Games locally stored.
+ };
+ this.loadPage = this.loadPage.bind(this);
+ this.loadConnection = this.loadConnection.bind(this);
+ this.loadGames = this.loadGames.bind(this);
+ this.loadGame = this.loadGame.bind(this);
+ this.loadGameFromDisk = this.loadGameFromDisk.bind(this);
+ this.logout = this.logout.bind(this);
+ this.error = this.error.bind(this);
+ this.info = this.info.bind(this);
+ this.success = this.success.bind(this);
+ this.unloadFancyBox = this.unloadFancyBox.bind(this);
+ }
+
+ static wrapMessage(message) {
+ return message ? `(${UTILS.date()}) ${message}` : '';
+ }
+
+ static __sort_games(games) {
+ // Sort games with not-joined games first, else compare game ID.
+ games.sort((a, b) => (((a.role ? 1 : 0) - (b.role ? 1 : 0)) || a.game_id.localeCompare(b.game_id)));
+ return games;
+ }
+
+ copyState(updatedFields) {
+ return Object.assign({}, this.state, updatedFields || {});
+ }
+
+ //// Methods to check page type.
+
+ __page_is(contentName, contentData) {
+ return this.state.contentName === contentName && (!contentData || this.state.contentData === contentData);
+ }
+
+ pageIsConnection(contentData) {
+ return this.__page_is('connection', contentData);
+ }
+
+ pageIsGames(contentData) {
+ return this.__page_is('games', contentData);
+ }
+
+ pageIsGame(contentData) {
+ return this.__page_is('game', contentData);
+ }
+
+ //// Methods to load a global fancybox.
+
+ loadFancyBox(title, callback) {
+ this.setState({fancyTitle: title, onFancyBox: callback});
+ }
+
+ unloadFancyBox() {
+ this.setState({fancyTitle: null, onFancyBox: null});
+ }
+
+ //// Methods to load a page.
+
+ loadPage(contentName, contentData, messages) {
+ messages = messages || {};
+ messages.error = Page.wrapMessage(messages.error);
+ messages.info = Page.wrapMessage(messages.info);
+ messages.success = Page.wrapMessage(messages.success);
+ Diplog.printMessages(messages);
+ this.setState(this.copyState({
+ error: messages.error,
+ info: messages.info,
+ success: messages.success,
+ contentName: contentName,
+ contentData: contentData,
+ title: null,
+ fancyTitle: null,
+ onFancyBox: null
+ }));
+ }
+
+ loadConnection(contentData, messages) {
+ this.loadPage('connection', contentData, messages);
+ }
+
+ loadGames(contentData, messages) {
+ this.loadPage('games', contentData, messages);
+ }
+
+ loadGame(gameInfo, messages) {
+ this.loadPage('game', gameInfo, messages);
+ }
+
+ loadGameFromDisk() {
+ const input = $(document.createElement('input'));
+ input.attr("type", "file");
+ input.trigger('click');
+ input.change(event => {
+ const file = event.target.files[0];
+ if (!file.name.match(/\.json$/i)) {
+ this.error(`Invalid JSON filename ${file.name}`);
+ } else {
+ const reader = new FileReader();
+ reader.onload = () => {
+ const savedData = JSON.parse(reader.result);
+ const gameObject = {};
+ gameObject.game_id = `(local) ${savedData.id}`;
+ gameObject.map_name = savedData.map;
+ gameObject.rules = savedData.rules;
+ const state_history = {};
+ const message_history = {};
+ const order_history = {};
+ const result_history = {};
+ for (let savedPhase of savedData.phases) {
+ const gameState = savedPhase.state;
+ const phaseOrders = savedPhase.orders || {};
+ const phaseResults = savedPhase.results || {};
+ const phaseMessages = {};
+ if (savedPhase.messages) {
+ for (let message of savedPhase.messages) {
+ phaseMessages[message.time_sent] = message;
+ }
+ }
+ if (!gameState.name)
+ gameState.name = savedPhase.name;
+ state_history[gameState.name] = gameState;
+ order_history[gameState.name] = phaseOrders;
+ message_history[gameState.name] = phaseMessages;
+ result_history[gameState.name] = phaseResults;
+ }
+ gameObject.state_history = state_history;
+ gameObject.message_history = message_history;
+ gameObject.order_history = order_history;
+ gameObject.state_history = state_history;
+ gameObject.result_history = result_history;
+ gameObject.messages = [];
+ gameObject.role = STRINGS.OBSERVER_TYPE;
+ gameObject.status = STRINGS.COMPLETED;
+ gameObject.timestamp_created = 0;
+ gameObject.deadline = 0;
+ gameObject.n_controls = 0;
+ gameObject.registration_password = '';
+ const game = new Game(gameObject);
+ this.loadGame(game);
+ };
+ reader.readAsText(file);
+ }
+ });
+ }
+
+ //// Methods to sign out channel and go back to connection page.
+
+ __disconnect() {
+ // Clear local data and go back to connection page.
+ this.connection.close();
+ this.connection = null;
+ this.channel = null;
+ this.availableMaps = null;
+ const message = Page.wrapMessage(`Disconnected from channel and server.`);
+ Diplog.success(message);
+ this.setState(this.copyState({
+ error: null,
+ info: null,
+ success: message,
+ contentName: 'connection',
+ contentData: null,
+ // When disconnected, remove all games previously loaded.
+ games: {},
+ myGames: {}
+ }));
+ }
+
+ logout() {
+ // Disconnect channel and go back to connection page.
+ if (this.channel) {
+ this.channel.logout()
+ .then(() => this.__disconnect())
+ .catch(error => this.error(`Error while disconnecting: ${error.toString()}.`));
+ } else {
+ this.__disconnect();
+ }
+ }
+
+ //// Methods to be used to set page title and messages.
+
+ setTitle(title) {
+ this.setState({title: title});
+ }
+
+ error(message) {
+ message = Page.wrapMessage(message);
+ Diplog.error(message);
+ this.setState({error: message});
+ }
+
+ info(message) {
+ message = Page.wrapMessage(message);
+ Diplog.info(message);
+ this.setState({info: message});
+ }
+
+ success(message) {
+ message = Page.wrapMessage(message);
+ Diplog.success(message);
+ this.setState({success: message});
+ }
+
+ warn(message) {
+ this.info(message);
+ }
+
+ //// Methods to manage games.
+
+ updateMyGames(gamesToAdd) {
+ // Update state myGames with given games. This method does not update local storage.
+ const myGames = Object.assign({}, this.state.myGames);
+ let gamesFound = null;
+ for (let gameToAdd of gamesToAdd) {
+ myGames[gameToAdd.game_id] = gameToAdd;
+ if (this.state.games.hasOwnProperty(gameToAdd.game_id)) {
+ if (!gamesFound)
+ gamesFound = Object.assign({}, this.state.games);
+ gamesFound[gameToAdd.game_id] = gameToAdd;
+ }
+ }
+ if (!gamesFound)
+ gamesFound = this.state.games;
+ this.setState({myGames: myGames, games: gamesFound});
+ }
+
+ getMyGames() {
+ return Page.__sort_games(Object.values(this.state.myGames));
+ }
+
+ getGamesFound() {
+ return Page.__sort_games(Object.values(this.state.games));
+ }
+
+ addGamesFound(gamesToAdd) {
+ const gamesFound = {};
+ for (let game of gamesToAdd) {
+ gamesFound[game.game_id] = (
+ this.state.myGames.hasOwnProperty(game.game_id) ?
+ this.state.myGames[game.game_id] : game
+ );
+ }
+ this.setState({games: gamesFound});
+ }
+
+ leaveGame(gameID) {
+ if (this.state.myGames.hasOwnProperty(gameID)) {
+ const game = this.state.myGames[gameID];
+ if (game.client) {
+ game.client.leave()
+ .then(() => {
+ this.disconnectGame(gameID);
+ this.loadGames(null, {info: `Game ${gameID} left.`});
+ })
+ .catch(error => this.error(`Error when leaving game ${gameID}: ${error.toString()}`));
+ }
+ }
+ }
+
+ disconnectGame(gameID) {
+ if (this.state.myGames.hasOwnProperty(gameID)) {
+ const game = this.state.myGames[gameID];
+ if (game.client)
+ game.client.clearAllCallbacks();
+ this.channel.getGamesInfo({games: [gameID]})
+ .then(gamesInfo => {
+ this.updateMyGames(gamesInfo);
+ })
+ .catch(error => this.error(`Error while leaving game ${gameID}: ${error.toString()}`));
+ }
+ }
+
+ addToMyGames(game) {
+ // Update state myGames with given game **and** update local storage.
+ const myGames = Object.assign({}, this.state.myGames);
+ const gamesFound = this.state.games.hasOwnProperty(game.game_id) ? Object.assign({}, this.state.games) : this.state.games;
+ myGames[game.game_id] = game;
+ if (gamesFound.hasOwnProperty(game.game_id))
+ gamesFound[game.game_id] = game;
+ DipStorage.addUserGame(this.channel.username, game.game_id);
+ this.setState({myGames: myGames, games: gamesFound});
+ }
+
+ removeFromMyGames(gameID) {
+ if (this.state.myGames.hasOwnProperty(gameID)) {
+ const games = Object.assign({}, this.state.myGames);
+ delete games[gameID];
+ DipStorage.removeUserGame(this.channel.username, gameID);
+ this.setState({myGames: games});
+ }
+ }
+
+ hasMyGame(gameID) {
+ return this.state.myGames.hasOwnProperty(gameID);
+ }
+
+ //// Render method.
+
+ render() {
+ const content = CONTENTS[this.state.contentName].builder(this, this.state.contentData);
+ const hasNavigation = UTILS.javascript.hasArray(content.navigation);
+
+ // NB: I currently don't find a better way to update document title from content details.
+ const successMessage = this.state.success || '-';
+ const infoMessage = this.state.info || '-';
+ const errorMessage = this.state.error || '-';
+ const title = this.state.title || content.title;
+ document.title = title + ' | Diplomacy';
+
+ return (
+ <div className="page container-fluid" id={this.state.contentName}>
+ <div className={'top-msg row'}>
+ <div title={successMessage !== '-' ? successMessage : ''}
+ className={'col-sm-4 msg success ' + (this.state.success ? 'with-msg' : 'no-msg')}
+ onClick={() => this.success()}>
+ {successMessage}
+ </div>
+ <div title={infoMessage !== '-' ? infoMessage : ''}
+ className={'col-sm-4 msg info ' + (this.state.info ? 'with-msg' : 'no-msg')}
+ onClick={() => this.info()}>
+ {infoMessage}
+ </div>
+ <div title={errorMessage !== '-' ? errorMessage : ''}
+ className={'col-sm-4 msg error ' + (this.state.error ? 'with-msg' : 'no-msg')}
+ onClick={() => this.error()}>
+ {errorMessage}
+ </div>
+ </div>
+ {((hasNavigation || this.channel) && (
+ <div className={'title row'}>
+ <div className={'col align-self-center'}><strong>{title}</strong></div>
+ <div className={'col-sm-1'}>
+ {(!hasNavigation && (
+ <div className={'float-right'}>
+ <strong>
+ <u className={'mr-2'}>{this.channel.username}</u>
+ <Octicon icon={Person}/>
+ </strong>
+ </div>
+ )) || (
+ <div className="dropdown float-right">
+ <button className="btn btn-secondary dropdown-toggle" type="button"
+ id="dropdownMenuButton" data-toggle="dropdown"
+ aria-haspopup="true" aria-expanded="false">
+ {(this.channel && this.channel.username && (
+ <span>
+ <u className={'mr-2'}>{this.channel.username}</u>
+ <Octicon icon={Person}/>
+ </span>
+ )) || 'Menu'}
+ </button>
+ <div className="dropdown-menu dropdown-menu-right"
+ aria-labelledby="dropdownMenuButton">
+ {content.navigation.map((nav, index) => {
+ const navTitle = nav[0];
+ const navAction = nav[1];
+ return <a key={index} className="dropdown-item"
+ onClick={navAction}>{navTitle}</a>;
+ })}
+ </div>
+ </div>
+ )}
+ </div>
+ </div>
+ )) || (
+ <div className={'title'}><strong>{title}</strong></div>
+ )}
+ {content.component}
+ {this.state.onFancyBox && (
+ <FancyBox title={this.state.fancyTitle} onClose={this.unloadFancyBox}>
+ {this.state.onFancyBox()}
+ </FancyBox>
+ )}
+ </div>
+ );
+ }
+}
diff --git a/diplomacy/web/src/gui/core/table.jsx b/diplomacy/web/src/gui/core/table.jsx
new file mode 100644
index 0000000..cb729e7
--- /dev/null
+++ b/diplomacy/web/src/gui/core/table.jsx
@@ -0,0 +1,112 @@
+// ==============================================================================
+// 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/>.
+// ==============================================================================
+//// Tables.
+
+import React from "react";
+import PropTypes from 'prop-types';
+
+class DefaultWrapper {
+ constructor(data) {
+ this.data = data;
+ this.get = this.get.bind(this);
+ }
+
+ get(fieldName) {
+ return this.data[fieldName];
+ }
+}
+
+function defaultWrapper(data) {
+ return new DefaultWrapper(data);
+}
+
+export class Table extends React.Component {
+ // className
+ // caption
+ // columns : {name: [title, order]}
+ // data: [objects with expected column names]
+ // wrapper: (optional) function to use to wrap one data entry into an object before accessing fields.
+ // Must return an instance with a method get(name).
+ // If provided: wrapper(data_entry).get(field_name)
+ // else: data_entry[field_name]
+
+ constructor(props) {
+ super(props);
+ if (!this.props.wrapper)
+ this.props.wrapper = defaultWrapper;
+ }
+
+ static getHeader(columns) {
+ const header = [];
+ for (let entry of Object.entries(columns)) {
+ const name = entry[0];
+ const title = entry[1][0];
+ const order = entry[1][1];
+ header.push([order, name, title]);
+ }
+ header.sort((a, b) => {
+ let t = a[0] - b[0];
+ if (t === 0)
+ t = a[1].localeCompare(b[1]);
+ if (t === 0)
+ t = a[2].localeCompare(b[2]);
+ return t;
+ });
+ return header;
+ }
+
+ static getHeaderLine(header) {
+ return (
+ <thead className={'thead-light'}>
+ <tr>{header.map((column, colIndex) => <th key={colIndex}>{column[2]}</th>)}</tr>
+ </thead>
+ );
+ }
+
+ static getBodyRow(header, row, rowIndex, wrapper) {
+ const wrapped = wrapper(row);
+ return (<tr key={rowIndex}>
+ {header.map((headerColumn, colIndex) => <td className={'align-middle'}
+ key={colIndex}>{wrapped.get(headerColumn[1])}</td>)}
+ </tr>);
+ }
+
+ static getBodyLines(header, data, wrapper) {
+ return (<tbody>{data.map((row, rowIndex) => Table.getBodyRow(header, row, rowIndex, wrapper))}</tbody>);
+ }
+
+ render() {
+ const header = Table.getHeader(this.props.columns);
+ return (
+ <div className={'table-responsive'}>
+ <table className={this.props.className}>
+ <caption>{this.props.caption} ({this.props.data.length})</caption>
+ {Table.getHeaderLine(header)}
+ {Table.getBodyLines(header, this.props.data, this.props.wrapper)}
+ </table>
+ </div>
+ );
+ }
+}
+
+Table.propTypes = {
+ wrapper: PropTypes.func,
+ columns: PropTypes.object,
+ className: PropTypes.string,
+ caption: PropTypes.string,
+ data: PropTypes.array
+};
diff --git a/diplomacy/web/src/gui/core/tabs.jsx b/diplomacy/web/src/gui/core/tabs.jsx
new file mode 100644
index 0000000..6123219
--- /dev/null
+++ b/diplomacy/web/src/gui/core/tabs.jsx
@@ -0,0 +1,96 @@
+// ==============================================================================
+// 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 React from "react";
+import {Action} from "./widgets";
+import PropTypes from 'prop-types';
+
+export class Tab extends React.Component {
+ render() {
+ const style = {
+ display: this.props.display ? 'block' : 'none'
+ };
+ const id = this.props.id ? {id: this.props.id} : {};
+ return (
+ <div className={'tab mb-4 ' + this.props.className} style={style} {...id}>
+ {this.props.children}
+ </div>
+ );
+ }
+}
+
+Tab.propTypes = {
+ display: PropTypes.bool,
+ className: PropTypes.string,
+ id: PropTypes.string,
+ children: PropTypes.oneOfType([PropTypes.array, PropTypes.object])
+};
+
+Tab.defaultProps = {
+ display: false,
+ className: '',
+ id: ''
+};
+
+export class Tabs extends React.Component {
+ /** PROPERTIES
+ * active: index of active menu (must be > menu.length).
+ * highlights: dictionary mapping a menu indice to a highlight message
+ * onChange: callback(index): receive index of menu to display.
+ * **/
+
+ generateTabAction(tabTitle, tabId, isActive, onChange, highlight) {
+ return <Action isActive={isActive}
+ title={tabTitle}
+ onClick={() => onChange(tabId)}
+ highlight={highlight}
+ key={tabId}/>;
+ }
+
+ render() {
+ if (!this.props.menu.length)
+ throw new Error(`No tab menu given.`);
+ if (this.props.menu.length !== this.props.titles.length)
+ throw new Error(`Menu length (${this.props.menu.length}) != titles length (${this.props.titles.length})`);
+ if (this.props.active && !this.props.menu.includes(this.props.active))
+ throw new Error(`Invalid active tab name, got ${this.props.active}, expected one of: ${this.props.menu.join(', ')}`);
+ const active = this.props.active || this.props.menu[0];
+ return (
+ <div className={'tabs mb-3'}>
+ <nav className={'tabs-bar nav nav-tabs justify-content-center mb-3'}>
+ {this.props.menu.map((tabName, index) => this.generateTabAction(
+ this.props.titles[index], tabName, active === tabName, this.props.onChange,
+ (this.props.highlights.hasOwnProperty(tabName) && this.props.highlights[tabName]) || null
+ ))}
+ </nav>
+ {this.props.children}
+ </div>
+ );
+ }
+}
+
+Tabs.propTypes = {
+ menu: PropTypes.arrayOf(PropTypes.string).isRequired, // tab names
+ titles: PropTypes.arrayOf(PropTypes.string).isRequired, // tab titles
+ onChange: PropTypes.func.isRequired, // callback(tab name)
+ children: PropTypes.array.isRequired,
+ active: PropTypes.string, // current active tab name
+ highlights: PropTypes.object, // {tab name => highligh message (optional)}
+};
+
+Tabs.defaultProps = {
+ highlights: {}
+};
diff --git a/diplomacy/web/src/gui/core/widgets.jsx b/diplomacy/web/src/gui/core/widgets.jsx
new file mode 100644
index 0000000..62a5eb4
--- /dev/null
+++ b/diplomacy/web/src/gui/core/widgets.jsx
@@ -0,0 +1,102 @@
+// ==============================================================================
+// 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 React from "react";
+import PropTypes from 'prop-types';
+
+export class Button extends React.Component {
+ /** Bootstrap button.
+ * Bootstrap classes:
+ * - btn
+ * - btn-primary
+ * - mx-1 (margin-left 1px, margin-right 1px)
+ * Props: title (str), onClick (function).
+ * **/
+ // title
+ // onClick
+ // pickEvent = false
+ // large = false
+ // small = false
+
+ constructor(props) {
+ super(props);
+ this.onClick = this.onClick.bind(this);
+ }
+
+ onClick(event) {
+ if (this.props.onClick)
+ this.props.onClick(this.props.pickEvent ? event : null);
+ }
+
+ render() {
+ return (
+ <button
+ className={`btn btn-${this.props.color || 'secondary'}` + (this.props.large ? ' btn-block' : '') + (this.props.small ? ' btn-sm' : '')}
+ disabled={this.props.disabled}
+ onClick={this.onClick}>
+ <strong>{this.props.title}</strong>
+ </button>
+ );
+ }
+}
+
+Button.propTypes = {
+ title: PropTypes.string.isRequired,
+ onClick: PropTypes.func.isRequired,
+ color: PropTypes.string,
+ large: PropTypes.bool,
+ small: PropTypes.bool,
+ pickEvent: PropTypes.bool,
+ disabled: PropTypes.bool
+};
+
+Button.defaultPropTypes = {
+ disabled: false
+};
+
+
+export class Action extends React.Component {
+ // title
+ // isActive
+ // onClick
+ // See Button parameters.
+
+ render() {
+ return (
+ <div className="action nav-item" onClick={this.props.onClick}>
+ <div
+ className={'nav-link' + (this.props.isActive ? ' active' : '') + (this.props.highlight !== null ? ' updated' : '')}>
+ {this.props.title}
+ {this.props.highlight !== null
+ && this.props.highlight !== undefined
+ && <span className={'update'}>{this.props.highlight}</span>}
+ </div>
+ </div>
+ );
+ }
+}
+
+Action.propTypes = {
+ title: PropTypes.string.isRequired,
+ onClick: PropTypes.func.isRequired,
+ highlight: PropTypes.any,
+ isActive: PropTypes.bool
+};
+
+Action.defaultProps = {
+ highlight: null,
+ isActive: false
+};
diff --git a/diplomacy/web/src/gui/diplomacy/contents/content_connection.jsx b/diplomacy/web/src/gui/diplomacy/contents/content_connection.jsx
new file mode 100644
index 0000000..8aa7fb1
--- /dev/null
+++ b/diplomacy/web/src/gui/diplomacy/contents/content_connection.jsx
@@ -0,0 +1,91 @@
+// ==============================================================================
+// 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 React from 'react';
+import {Content} from "../../core/content";
+import {Connection} from "../../../diplomacy/client/connection";
+import {ConnectionForm} from "../forms/connection_form";
+import {DipStorage} from "../utils/dipStorage";
+
+export class ContentConnection extends Content {
+ constructor(props) {
+ super(props);
+ this.connection = null;
+ this.onSubmit = this.onSubmit.bind(this);
+ }
+
+ static builder(page, data) {
+ return {
+ title: 'Connection',
+ navigation: [],
+ component: <ContentConnection page={page} data={data}/>
+ };
+ }
+
+ onSubmit(data) {
+ const page = this.getPage();
+ for (let fieldName of ['hostname', 'port', 'username', 'password', 'showServerFields'])
+ if (!data.hasOwnProperty(fieldName))
+ return page.error(`Missing ${fieldName}, got ${JSON.stringify(data)}`);
+ page.info('Connecting ...');
+ if (this.connection) {
+ this.connection.currentConnectionProcessing.stop();
+ }
+ this.connection = new Connection(data.hostname, data.port, window.location.protocol.toLowerCase() === 'https:');
+ // Page is passed as logger object (with methods info(), error(), success()) when connecting.
+ this.connection.connect(this.getPage())
+ .then(() => {
+ page.connection = this.connection;
+ this.connection = null;
+ page.success(`Successfully connected to server ${data.username}:${data.port}`);
+ page.connection.authenticate(data.username, data.password, false)
+ .catch((error) => {
+ page.error(`Unable to sign in, trying to create an account, error: ${error}`);
+ return page.connection.authenticate(data.username, data.password, true);
+ })
+ .then((channel) => {
+ page.channel = channel;
+ return channel.getAvailableMaps();
+ })
+ .then(availableMaps => {
+ page.availableMaps = availableMaps;
+ const userGameIndices = DipStorage.getUserGames(page.channel.username);
+ if (userGameIndices && userGameIndices.length) {
+ return page.channel.getGamesInfo({games: userGameIndices});
+ } else {
+ return null;
+ }
+ })
+ .then((gamesInfo) => {
+ if (gamesInfo) {
+ this.getPage().success('Found ' + gamesInfo.length + ' user games.');
+ this.getPage().updateMyGames(gamesInfo);
+ }
+ page.loadGames(null, {success: `Account ${data.username} connected.`});
+ })
+ .catch((error) => {
+ page.error('Error while authenticating: ' + error + ' Please re-try.');
+ });
+ })
+ .catch((error) => {
+ page.error('Error while connecting: ' + error + ' Please re-try.');
+ });
+ }
+
+ render() {
+ return <main><ConnectionForm onSubmit={this.onSubmit}/></main>;
+ }
+}
diff --git a/diplomacy/web/src/gui/diplomacy/contents/content_game.jsx b/diplomacy/web/src/gui/diplomacy/contents/content_game.jsx
new file mode 100644
index 0000000..81a689d
--- /dev/null
+++ b/diplomacy/web/src/gui/diplomacy/contents/content_game.jsx
@@ -0,0 +1,1235 @@
+// ==============================================================================
+// 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 React from "react";
+import Scrollchor from 'react-scrollchor';
+import {SelectLocationForm} from "../forms/select_location_form";
+import {SelectViaForm} from "../forms/select_via_form";
+import {Order} from "../utils/order";
+import {Button} from "../../core/widgets";
+import {Bar, Row} from "../../core/layouts";
+import {Content} from "../../core/content";
+import {Tab, Tabs} from "../../core/tabs";
+import {Map} from "../map/map";
+import {extendOrderBuilding, ORDER_BUILDER, POSSIBLE_ORDERS} from "../utils/order_building";
+import {PowerActionsForm} from "../forms/power_actions_form";
+import {MessageForm} from "../forms/message_form";
+import {UTILS} from "../../../diplomacy/utils/utils";
+import {Message} from "../../../diplomacy/engine/message";
+import {PowerOrder} from "../widgets/power_order";
+import {MessageView} from "../widgets/message_view";
+import {STRINGS} from "../../../diplomacy/utils/strings";
+import {Diplog} from "../../../diplomacy/utils/diplog";
+import {Table} from "../../core/table";
+import {PowerView} from "../utils/power_view";
+import {FancyBox} from "../../core/fancybox";
+import {DipStorage} from "../utils/dipStorage";
+
+const HotKey = require('react-shortcut');
+
+/* Order management in game page.
+ * When editing orders locally, we have to compare it to server orders
+ * to determine when we need to update orders on server side. There are
+ * 9 comparison cases, depending on orders:
+ * SERVER LOCAL DECISION
+ * null null 0 (same)
+ * null {} 1 (different, user wants to send "no orders" on server)
+ * null {orders} 1 (different, user defines new orders locally)
+ * {} null 0 (assumed same: user is not allowed to "delete" a "no orders": he can only add new orders)
+ * {} {} 0 (same)
+ * {} {orders} 1 (different, user defines new orders locally and wants to overwrite the "no-orders" on server)
+ * {orders} null 1 (different, user wants to delete all server orders, will result to "no-orders")
+ * {orders} {} 1 (different, user wants to delete all server orders, will result to "no-orders")
+ * {orders} {orders} same if we have exactly same orders on both server and local
+ * */
+
+const TABLE_POWER_VIEW = {
+ name: ['Power', 0],
+ controller: ['Controller', 1],
+ order_is_set: ['With orders', 2],
+ wait: ['Waiting', 3]
+};
+
+function Help() {
+ return (
+ <div>
+ <p>When building an order, press <strong>ESC</strong> to reset build.</p>
+ <p>Press letter associated to an order type to start building an order of this type.
+ <br/> Order type letter is indicated in order type name after order type radio button.
+ </p>
+ <p>In Phase History tab, use keyboard left and right arrows to navigate in past phases.</p>
+ </div>
+ );
+}
+
+export class ContentGame extends Content {
+
+ constructor(props) {
+ super(props);
+ // Load local orders from local storage (if available).
+ const savedOrders = this.props.data.client ? DipStorage.getUserGameOrders(
+ this.props.data.client.channel.username,
+ this.props.data.game_id,
+ this.props.data.phase
+ ) : null;
+ let orders = null;
+ if (savedOrders) {
+ orders = {};
+ for (let entry of Object.entries(savedOrders)) {
+ let powerOrders = null;
+ const powerName = entry[0];
+ if (entry[1]) {
+ powerOrders = {};
+ for (let orderString of entry[1]) {
+ const order = new Order(orderString, true);
+ powerOrders[order.loc] = order;
+ }
+ }
+ orders[powerName] = powerOrders;
+ }
+ }
+ this.schedule_timeout_id = null;
+ this.state = {
+ tabMain: null,
+ tabPastMessages: null,
+ tabCurrentMessages: null,
+ messageHighlights: {},
+ historyPhaseIndex: null,
+ historyShowOrders: true,
+ historySubView: 0,
+ historyCurrentLoc: null,
+ historyCurrentOrders: null,
+ wait: null, // {power name => bool}
+ orders: orders, // {power name => {loc => {local: bool, order: str}}}
+ power: null,
+ orderBuildingType: null,
+ orderBuildingPath: [],
+ fancy_title: null,
+ fancy_function: null,
+ on_fancy_close: null,
+ };
+
+ // Bind some class methods to this instance.
+ this.closeFancyBox = this.closeFancyBox.bind(this);
+ this.displayFirstPastPhase = this.displayFirstPastPhase.bind(this);
+ this.displayLastPastPhase = this.displayLastPastPhase.bind(this);
+ this.displayLocationOrders = this.displayLocationOrders.bind(this);
+ this.getMapInfo = this.getMapInfo.bind(this);
+ this.notifiedGamePhaseUpdated = this.notifiedGamePhaseUpdated.bind(this);
+ this.notifiedLocalStateChange = this.notifiedLocalStateChange.bind(this);
+ this.notifiedNetworkGame = this.notifiedNetworkGame.bind(this);
+ this.notifiedNewGameMessage = this.notifiedNewGameMessage.bind(this);
+ this.notifiedPowersControllers = this.notifiedPowersControllers.bind(this);
+ this.onChangeCurrentPower = this.onChangeCurrentPower.bind(this);
+ this.onChangeMainTab = this.onChangeMainTab.bind(this);
+ this.onChangeOrderType = this.onChangeOrderType.bind(this);
+ this.onChangePastPhase = this.onChangePastPhase.bind(this);
+ this.onChangePastPhaseIndex = this.onChangePastPhaseIndex.bind(this);
+ this.onChangeShowPastOrders = this.onChangeShowPastOrders.bind(this);
+ this.onChangeTabCurrentMessages = this.onChangeTabCurrentMessages.bind(this);
+ this.onChangeTabPastMessages = this.onChangeTabPastMessages.bind(this);
+ this.onClickMessage = this.onClickMessage.bind(this);
+ this.onDecrementPastPhase = this.onDecrementPastPhase.bind(this);
+ this.onIncrementPastPhase = this.onIncrementPastPhase.bind(this);
+ this.onOrderBuilding = this.onOrderBuilding.bind(this);
+ this.onOrderBuilt = this.onOrderBuilt.bind(this);
+ this.onProcessGame = this.onProcessGame.bind(this);
+ this.onRemoveAllOrders = this.onRemoveAllOrders.bind(this);
+ this.onRemoveOrder = this.onRemoveOrder.bind(this);
+ this.onSelectLocation = this.onSelectLocation.bind(this);
+ this.onSelectVia = this.onSelectVia.bind(this);
+ this.onSetNoOrders = this.onSetNoOrders.bind(this);
+ this.reloadServerOrders = this.reloadServerOrders.bind(this);
+ this.renderOrders = this.renderOrders.bind(this);
+ this.sendMessage = this.sendMessage.bind(this);
+ this.setOrders = this.setOrders.bind(this);
+ this.setSelectedLocation = this.setSelectedLocation.bind(this);
+ this.setSelectedVia = this.setSelectedVia.bind(this);
+ this.setWaitFlag = this.setWaitFlag.bind(this);
+ this.vote = this.vote.bind(this);
+ }
+
+ static gameTitle(game) {
+ let title = `${game.game_id} | ${game.phase} | ${game.status} | ${game.role} | ${game.map_name}`;
+ const remainingTime = game.deadline_timer;
+ if (remainingTime === undefined)
+ title += ` (deadline: ${game.deadline} sec)`;
+ else if (remainingTime)
+ title += ` (remaining ${remainingTime} sec)`;
+ return title;
+ }
+
+ static saveGameToDisk(game, page) {
+ if (game.client) {
+ game.client.save()
+ .then((savedData) => {
+ const domLink = document.createElement('a');
+ domLink.setAttribute(
+ 'href', 'data:text/plain;charset=utf-8,' + encodeURIComponent(JSON.stringify(savedData)));
+ domLink.setAttribute('download', `${game.game_id}.json`);
+ domLink.style.display = 'none';
+ document.body.appendChild(domLink);
+ domLink.click();
+ document.body.removeChild(domLink);
+ })
+ .catch(exc => page.error(`Error while saving game: ${exc.toString()}`));
+ } else {
+ page.error(`Cannot save this game.`);
+ }
+ }
+
+ static builder(page, data) {
+ return {
+ title: ContentGame.gameTitle(data),
+ navigation: [
+ ['Help', () => page.loadFancyBox('Help', () => <Help/>)],
+ ['Load a game from disk', page.loadGameFromDisk],
+ ['Save game to disk', () => ContentGame.saveGameToDisk(data)],
+ [`${UTILS.html.UNICODE_SMALL_LEFT_ARROW} Games`, page.loadGames],
+ [`${UTILS.html.UNICODE_SMALL_LEFT_ARROW} Leave game`, () => page.leaveGame(data.game_id)],
+ [`${UTILS.html.UNICODE_SMALL_LEFT_ARROW} Logout`, page.logout]
+ ],
+ component: <ContentGame page={page} data={data}/>
+ };
+ }
+
+ static getServerWaitFlags(engine) {
+ const wait = {};
+ const controllablePowers = engine.getControllablePowers();
+ for (let powerName of controllablePowers) {
+ wait[powerName] = engine.powers[powerName].wait;
+ }
+ return wait;
+ }
+
+ static getServerOrders(engine) {
+ const orders = {};
+ const controllablePowers = engine.getControllablePowers();
+ for (let powerName of controllablePowers) {
+ const powerOrders = {};
+ let countOrders = 0;
+ const power = engine.powers[powerName];
+ for (let orderString of power.orders) {
+ const serverOrder = new Order(orderString, false);
+ powerOrders[serverOrder.loc] = serverOrder;
+ ++countOrders;
+ }
+ orders[powerName] = (countOrders || power.order_is_set) ? powerOrders : null;
+ }
+ return orders;
+ }
+
+ static getOrderBuilding(powerName, orderType, orderPath) {
+ return {
+ type: orderType,
+ path: orderPath,
+ power: powerName,
+ builder: orderType && ORDER_BUILDER[orderType]
+ };
+ }
+
+ closeFancyBox() {
+ this.setState({
+ fancy_title: null,
+ fancy_function: null,
+ on_fancy_close: null,
+ orderBuildingPath: []
+ });
+ }
+
+ setSelectedLocation(location, powerName, orderType, orderPath) {
+ if (!location)
+ return;
+ extendOrderBuilding(
+ powerName, orderType, orderPath, location,
+ this.onOrderBuilding, this.onOrderBuilt, this.getPage().error
+ );
+ this.setState({
+ fancy_title: null,
+ fancy_function: null,
+ on_fancy_close: null
+ });
+ }
+
+ setSelectedVia(moveType, powerName, orderPath, location) {
+ if (!moveType || !['M', 'V'].includes(moveType))
+ return;
+ extendOrderBuilding(
+ powerName, moveType, orderPath, location,
+ this.onOrderBuilding, this.onOrderBuilt, this.getPage().error
+ );
+ this.setState({
+ fancy_title: null,
+ fancy_function: null,
+ on_fancy_close: null
+ });
+ }
+
+ onSelectLocation(possibleLocations, powerName, orderType, orderPath) {
+ const title = `Select location to continue building order: ${orderPath.join(' ')} ... (press ESC or close button to cancel building)`;
+ const func = () => (<SelectLocationForm locations={possibleLocations}
+ onSelect={(location) => this.setSelectedLocation(location, powerName, orderType, orderPath)}/>);
+ this.setState({
+ fancy_title: title,
+ fancy_function: func,
+ on_fancy_close: this.closeFancyBox
+ });
+ }
+
+ onSelectVia(location, powerName, orderPath) {
+ const title = `Select move type for move order: ${orderPath.join(' ')}`;
+ const func = () => (
+ <SelectViaForm onSelect={(moveType) => this.setSelectedVia(moveType, powerName, orderPath, location)}/>);
+ this.setState({
+ fancy_title: title,
+ fancy_function: func,
+ on_fancy_close: this.closeFancyBox
+ });
+ }
+
+ __get_orders(engine) {
+ const orders = ContentGame.getServerOrders(engine);
+ if (this.state.orders) {
+ for (let powerName of Object.keys(orders)) {
+ const serverPowerOrders = orders[powerName];
+ const localPowerOrders = this.state.orders[powerName];
+ if (localPowerOrders) {
+ for (let localOrder of Object.values(localPowerOrders)) {
+ localOrder.local = (
+ !serverPowerOrders
+ || !serverPowerOrders.hasOwnProperty(localOrder.loc)
+ || serverPowerOrders[localOrder.loc].order !== localOrder.order
+ );
+ }
+ }
+ orders[powerName] = localPowerOrders;
+ }
+ }
+ return orders;
+ }
+
+ __get_wait(engine) {
+ return this.state.wait ? this.state.wait : ContentGame.getServerWaitFlags(engine);
+ }
+
+ getMapInfo() {
+ return this.props.page.availableMaps[this.props.data.map_name];
+ }
+
+ clearScheduleTimeout() {
+ if (this.schedule_timeout_id) {
+ clearInterval(this.schedule_timeout_id);
+ this.schedule_timeout_id = null;
+ }
+ }
+
+ updateDeadlineTimer() {
+ const engine = this.props.data;
+ --engine.deadline_timer;
+ if (engine.deadline_timer <= 0) {
+ engine.deadline_timer = 0;
+ this.clearScheduleTimeout();
+ }
+ this.getPage().setTitle(ContentGame.gameTitle(engine));
+ }
+
+ reloadDeadlineTimer(networkGame) {
+ networkGame.querySchedule()
+ .then(dataSchedule => {
+ const schedule = dataSchedule.schedule;
+ const server_current = schedule.current_time;
+ const server_end = schedule.time_added + schedule.delay;
+ const server_remaining = server_end - server_current;
+ this.props.data.deadline_timer = server_remaining * schedule.time_unit;
+ if (!this.schedule_timeout_id)
+ this.schedule_timeout_id = setInterval(() => this.updateDeadlineTimer(), schedule.time_unit * 1000);
+ })
+ .catch(() => {
+ if (this.props.data.hasOwnProperty('deadline_timer'))
+ delete this.props.data.deadline_timer;
+ this.clearScheduleTimeout();
+ // this.getPage().error(`Error while updating deadline timer: ${error.toString()}`);
+ });
+ }
+
+ networkGameIsDisplayed(networkGame) {
+ return this.getPage().pageIsGame(networkGame.local);
+ }
+
+ notifiedNetworkGame(networkGame, notification) {
+ if (this.networkGameIsDisplayed(networkGame)) {
+ const msg = `Game (${networkGame.local.game_id}) received notification ${notification.name}.`;
+ this.props.page.loadGame(networkGame.local, {info: msg});
+ this.reloadDeadlineTimer(networkGame);
+ }
+ }
+
+ notifiedPowersControllers(networkGame, notification) {
+ if (networkGame.local.isPlayerGame() && (
+ !networkGame.channel.game_id_to_instances.hasOwnProperty(networkGame.local.game_id)
+ || !networkGame.channel.game_id_to_instances[networkGame.local.game_id].has(networkGame.local.role)
+ )) {
+ // This power game is now invalid.
+ this.props.page.disconnectGame(networkGame.local.game_id);
+ if (this.networkGameIsDisplayed(networkGame)) {
+ this.props.page.loadGames(null,
+ {error: `Player game ${networkGame.local.game_id}/${networkGame.local.role} was kicked. Deadline over?`});
+ }
+ } else {
+ this.notifiedNetworkGame(networkGame, notification);
+ }
+ }
+
+ notifiedGamePhaseUpdated(networkGame, notification) {
+ networkGame.getAllPossibleOrders()
+ .then(allPossibleOrders => {
+ networkGame.local.setPossibleOrders(allPossibleOrders);
+ if (this.networkGameIsDisplayed(networkGame)) {
+ this.getPage().loadGame(
+ networkGame.local, {info: `Game update (${notification.name}) to ${networkGame.local.phase}.`}
+ );
+ this.__store_orders(null);
+ this.setState({orders: null, wait: null, messageHighlights: {}});
+ this.reloadDeadlineTimer(networkGame);
+ }
+ })
+ .catch(error => this.getPage().error('Error when updating possible orders: ' + error.toString()));
+ }
+
+ notifiedLocalStateChange(networkGame) {
+ networkGame.getAllPossibleOrders()
+ .then(allPossibleOrders => {
+ networkGame.local.setPossibleOrders(allPossibleOrders);
+ if (this.networkGameIsDisplayed(networkGame)) {
+ this.getPage().loadGame(
+ networkGame.local, {info: `Possible orders re-loaded.`}
+ );
+ this.reloadDeadlineTimer(networkGame);
+ }
+ })
+ .catch(error => this.getPage().error('Error when updating possible orders: ' + error.toString()));
+ }
+
+ notifiedNewGameMessage(networkGame, notification) {
+ let protagonist = notification.message.sender;
+ if (notification.message.recipient === 'GLOBAL')
+ protagonist = notification.message.recipient;
+ const messageHighlights = Object.assign({}, this.state.messageHighlights);
+ if (!messageHighlights.hasOwnProperty(protagonist))
+ messageHighlights[protagonist] = 1;
+ else
+ ++messageHighlights[protagonist];
+ this.setState({messageHighlights: messageHighlights});
+ this.notifiedNetworkGame(networkGame, notification);
+ }
+
+ bindCallbacks(networkGame) {
+ if (!networkGame.callbacksBound) {
+ networkGame.addOnClearedCenters(this.notifiedLocalStateChange);
+ networkGame.addOnClearedOrders(this.notifiedLocalStateChange);
+ networkGame.addOnClearedUnits(this.notifiedLocalStateChange);
+ networkGame.addOnPowersControllers(this.notifiedPowersControllers);
+ networkGame.addOnGameMessageReceived(this.notifiedNewGameMessage);
+ networkGame.addOnGameProcessed(this.notifiedGamePhaseUpdated);
+ networkGame.addOnGamePhaseUpdate(this.notifiedGamePhaseUpdated);
+ networkGame.addOnGameStatusUpdate(this.notifiedNetworkGame);
+ networkGame.addOnOmniscientUpdated(this.notifiedNetworkGame);
+ networkGame.addOnPowerOrdersUpdate(this.notifiedNetworkGame);
+ networkGame.addOnPowerOrdersFlag(this.notifiedNetworkGame);
+ networkGame.addOnPowerVoteUpdated(this.notifiedNetworkGame);
+ networkGame.addOnPowerWaitFlag(this.notifiedNetworkGame);
+ networkGame.addOnVoteCountUpdated(this.notifiedNetworkGame);
+ networkGame.addOnVoteUpdated(this.notifiedNetworkGame);
+ networkGame.callbacksBound = true;
+ networkGame.local.markAllMessagesRead();
+ }
+ }
+
+ onChangeCurrentPower(event) {
+ this.setState({power: event.target.value});
+ }
+
+ onChangeMainTab(tab) {
+ this.setState({tabMain: tab});
+ }
+
+ onChangeTabCurrentMessages(tab) {
+ this.setState({tabCurrentMessages: tab});
+ }
+
+ onChangeTabPastMessages(tab) {
+ this.setState({tabPastMessages: tab});
+ }
+
+ sendMessage(networkGame, recipient, body) {
+ const engine = networkGame.local;
+ const message = new Message({
+ phase: engine.phase,
+ sender: engine.role,
+ recipient: recipient,
+ message: body
+ });
+ const page = this.props.page;
+ networkGame.sendGameMessage({message: message})
+ .then(() => {
+ page.loadGame(engine, {success: `Message sent: ${JSON.stringify(message)}`});
+ })
+ .catch(error => page.error(error.toString()));
+ }
+
+ __store_orders(orders) {
+ // Save local orders into local storage.
+ const username = this.props.data.client.channel.username;
+ const gameID = this.props.data.game_id;
+ const gamePhase = this.props.data.phase;
+ if (!orders) {
+ return DipStorage.clearUserGameOrders(username, gameID);
+ }
+ for (let entry of Object.entries(orders)) {
+ const powerName = entry[0];
+ let powerOrdersList = null;
+ if (entry[1]) {
+ powerOrdersList = Object.values(entry[1]).map(order => order.order);
+ }
+ DipStorage.clearUserGameOrders(username, gameID, powerName);
+ DipStorage.addUserGameOrders(username, gameID, gamePhase, powerName, powerOrdersList);
+ }
+ }
+
+ reloadServerOrders() {
+ const serverOrders = ContentGame.getServerOrders(this.props.data);
+ this.__store_orders(serverOrders);
+ this.setState({orders: serverOrders});
+ }
+
+ setOrders() {
+ const serverOrders = ContentGame.getServerOrders(this.props.data);
+ const orders = this.__get_orders(this.props.data);
+
+ for (let entry of Object.entries(orders)) {
+ const powerName = entry[0];
+ const localPowerOrders = entry[1] ? Object.values(entry[1]).map(orderEntry => orderEntry.order) : null;
+ const serverPowerOrders = serverOrders[powerName] ? Object.values(serverOrders[powerName]).map(orderEntry => orderEntry.order) : null;
+ let same = false;
+
+ if (serverPowerOrders === null) {
+ // No orders set on server.
+ if (localPowerOrders === null)
+ same = true;
+ // Otherwise, we have local orders set (even empty local orders).
+ } else if (serverPowerOrders.length === 0) {
+ // Empty orders set on server.
+ // If local orders are null or empty, then we assume
+ // it's the same thing as empty order set on server.
+ if (localPowerOrders === null || !localPowerOrders.length)
+ same = true;
+ // Otherwise, we have local non-empty orders set.
+ } else {
+ // Orders set on server. Identical to local orders only if we have exactly same orders on server and locally.
+ if (localPowerOrders && localPowerOrders.length === serverPowerOrders.length) {
+ localPowerOrders.sort();
+ serverPowerOrders.sort();
+ const length = localPowerOrders.length;
+ same = true;
+ for (let i = 0; i < length; ++i) {
+ if (localPowerOrders[i] !== serverPowerOrders[i]) {
+ same = false;
+ break;
+ }
+ }
+ }
+ }
+
+ if (same) {
+ Diplog.warn(`Orders not changed for ${powerName}.`);
+ continue;
+ }
+ Diplog.info('Sending orders for ' + powerName + ': ' + JSON.stringify(localPowerOrders));
+ this.props.data.client.setOrders({power_name: powerName, orders: localPowerOrders || []})
+ .then(() => {
+ this.props.page.success('Orders sent.');
+ })
+ .catch(err => {
+ this.props.page.error(err.toString());
+ })
+ .then(() => {
+ this.reloadServerOrders();
+ });
+ }
+ }
+
+ onProcessGame() {
+ this.props.data.client.process()
+ .then(() => this.props.page.success('Game processed.'))
+ .catch(err => {
+ this.props.page.error(err.toString());
+ });
+ }
+
+ onRemoveOrder(powerName, order) {
+ const orders = this.__get_orders(this.props.data);
+ if (orders.hasOwnProperty(powerName)
+ && orders[powerName].hasOwnProperty(order.loc)
+ && orders[powerName][order.loc].order === order.order) {
+ delete orders[powerName][order.loc];
+ if (!UTILS.javascript.count(orders[powerName]))
+ orders[powerName] = null;
+ this.__store_orders(orders);
+ this.setState({orders: orders});
+ }
+ }
+
+ onRemoveAllOrders() {
+ const orders = {};
+ const controllablePowers = this.props.data.getControllablePowers();
+ for (let powerName of controllablePowers) {
+ orders[powerName] = null;
+ }
+ this.__store_orders(orders);
+ this.setState({orders: orders});
+ }
+
+ onOrderBuilding(powerName, path) {
+ const pathToSave = path.slice(1);
+ this.props.page.success(`Building order ${pathToSave.join(' ')} ...`);
+ this.setState({orderBuildingPath: pathToSave});
+ }
+
+ onOrderBuilt(powerName, orderString) {
+ const state = Object.assign({}, this.state);
+ state.orderBuildingPath = [];
+ state.fancy_title = null;
+ state.fancy_function = null;
+ state.on_fancy_close = null;
+ if (!orderString) {
+ Diplog.warn('No order built.');
+ this.setState(state);
+ return;
+ }
+ const engine = this.props.data;
+ const localOrder = new Order(orderString, true);
+ const allOrders = this.__get_orders(engine);
+ if (!allOrders.hasOwnProperty(powerName)) {
+ Diplog.warn(`Unknown power ${powerName}.`);
+ this.setState(state);
+ return;
+ }
+
+ if (!allOrders[powerName])
+ allOrders[powerName] = {};
+ allOrders[powerName][localOrder.loc] = localOrder;
+ state.orders = allOrders;
+ this.props.page.success(`Built order: ${orderString}`);
+ this.__store_orders(allOrders);
+ this.setState(state);
+ }
+
+ onSetNoOrders(powerName) {
+ const orders = this.__get_orders(this.props.data);
+ orders[powerName] = {};
+ this.__store_orders(orders);
+ this.setState({orders: orders});
+ }
+
+ onChangeOrderType(form) {
+ this.setState({
+ orderBuildingType: form.order_type,
+ orderBuildingPath: [],
+ fancy_title: null,
+ fancy_function: null,
+ on_fancy_close: null
+ });
+ }
+
+ vote(decision) {
+ const engine = this.props.data;
+ const networkGame = engine.client;
+ const controllablePowers = engine.getControllablePowers();
+ const currentPowerName = this.state.power || (controllablePowers.length ? controllablePowers[0] : null);
+ if (!currentPowerName)
+ throw new Error(`Internal error: unable to detect current selected power name.`);
+ networkGame.vote({power_name: currentPowerName, vote: decision})
+ .then(() => this.getPage().success(`Vote set to ${decision} for ${currentPowerName}`))
+ .catch(error => {
+ Diplog.error(error.stack);
+ this.getPage().error(`Error while setting vote for ${currentPowerName}: ${error.toString()}`);
+ });
+ }
+
+ setWaitFlag(waitFlag) {
+ const engine = this.props.data;
+ const networkGame = engine.client;
+ const controllablePowers = engine.getControllablePowers();
+ const currentPowerName = this.state.power || (controllablePowers.length ? controllablePowers[0] : null);
+ if (!currentPowerName)
+ throw new Error(`Internal error: unable to detect current selected power name.`);
+ networkGame.setWait(waitFlag, {power_name: currentPowerName})
+ .then(() => this.getPage().success(`Wait flag set to ${waitFlag} for ${currentPowerName}`))
+ .catch(error => {
+ Diplog.error(error.stack);
+ this.getPage().error(`Error while setting wait flag for ${currentPowerName}: ${error.toString()}`);
+ });
+ }
+
+ __change_past_phase(newPhaseIndex, subView) {
+ this.setState({
+ historyPhaseIndex: newPhaseIndex,
+ historySubView: (subView ? subView : 0),
+ historyCurrentLoc: null,
+ historyCurrentOrders: null
+ });
+ }
+
+ onChangePastPhase(event) {
+ this.__change_past_phase(event.target.value);
+ }
+
+ onChangePastPhaseIndex(increment) {
+ const selectObject = document.getElementById('select-past-phase');
+ if (selectObject) {
+ if (!this.state.historyShowOrders) {
+ // We must change map sub-view before showed phase index.
+ const currentSubView = this.state.historySubView;
+ const newSubView = currentSubView + (increment ? 1 : -1);
+ if (newSubView === 0 || newSubView === 1) {
+ // Sub-view correctly updated. We don't yet change showed phase.
+ return this.setState({historySubView: newSubView});
+ }
+ // Sub-view badly updated (either from 0 to -1, or from 1 to 2). We must change phase.
+ }
+ // Let's simply increase or decrease index of showed past phase.
+ const index = selectObject.selectedIndex;
+ const newIndex = index + (increment ? 1 : -1);
+ if (newIndex >= 0 && newIndex < selectObject.length) {
+ selectObject.selectedIndex = newIndex;
+ this.__change_past_phase(parseInt(selectObject.options[newIndex].value, 10), (increment ? 0 : 1));
+ }
+ }
+ }
+
+ onIncrementPastPhase(event) {
+ this.onChangePastPhaseIndex(true);
+ if (event && event.preventDefault)
+ event.preventDefault();
+ }
+
+ onDecrementPastPhase(event) {
+ this.onChangePastPhaseIndex(false);
+ if (event && event.preventDefault)
+ event.preventDefault();
+ }
+
+ displayFirstPastPhase() {
+ this.__change_past_phase(0, 0);
+ }
+
+ displayLastPastPhase() {
+ this.__change_past_phase(-1, 1);
+ }
+
+ onChangeShowPastOrders(event) {
+ this.setState({historyShowOrders: event.target.checked, historySubView: 0});
+ }
+
+ renderOrders(engine, currentPowerName) {
+ const serverOrders = ContentGame.getServerOrders(this.props.data);
+ const orders = this.__get_orders(engine);
+ const wait = this.__get_wait(engine);
+
+ const render = [];
+ render.push(<PowerOrder key={currentPowerName} name={currentPowerName} wait={wait[currentPowerName]}
+ orders={orders[currentPowerName]}
+ serverCount={serverOrders[currentPowerName] ? UTILS.javascript.count(serverOrders[currentPowerName]) : -1}
+ onRemove={this.onRemoveOrder}/>);
+ return render;
+ }
+
+ onClickMessage(message) {
+ if (!message.read) {
+ message.read = true;
+ let protagonist = message.sender;
+ if (message.recipient === 'GLOBAL')
+ protagonist = message.recipient;
+ this.getPage().loadGame(this.props.data);
+ if (this.state.messageHighlights.hasOwnProperty(protagonist) && this.state.messageHighlights[protagonist] > 0) {
+ const messageHighlights = Object.assign({}, this.state.messageHighlights);
+ --messageHighlights[protagonist];
+ this.setState({messageHighlights: messageHighlights});
+ }
+ }
+ }
+
+ displayLocationOrders(loc, orders) {
+ this.setState({
+ historyCurrentLoc: loc || null,
+ historyCurrentOrders: orders && orders.length ? orders : null
+ });
+ }
+
+ renderPastMessages(engine) {
+ const messageChannels = engine.getMessageChannels();
+ let tabNames = null;
+ if (engine.isPlayerGame()) {
+ tabNames = [];
+ for (let powerName of Object.keys(engine.powers)) if (powerName !== engine.role)
+ tabNames.push(powerName);
+ tabNames.sort();
+ tabNames.push('GLOBAL');
+ } else {
+ tabNames = Object.keys(messageChannels);
+ }
+ const currentTabId = this.state.tabPastMessages || tabNames[0];
+
+ return (
+ <div className={'panel-messages'} key={'panel-messages'}>
+ {/* Messages. */}
+ <Tabs menu={tabNames} titles={tabNames} onChange={this.onChangeTabPastMessages} active={currentTabId}>
+ {tabNames.map(protagonist => (
+ <Tab key={protagonist} className={'game-messages'} display={currentTabId === protagonist}>
+ {(!messageChannels.hasOwnProperty(protagonist) || !messageChannels[protagonist].length ?
+ (<div className={'no-game-message'}>No
+ messages{engine.isPlayerGame() ? ` with ${protagonist}` : ''}.</div>) :
+ messageChannels[protagonist].map((message, index) => (
+ <MessageView key={index} owner={engine.role} message={message} read={true}/>
+ ))
+ )}
+ </Tab>
+ ))}
+ </Tabs>
+ </div>
+ );
+ }
+
+ renderCurrentMessages(engine) {
+ const messageChannels = engine.getMessageChannels();
+ let tabNames = null;
+ let highlights = null;
+ if (engine.isPlayerGame()) {
+ tabNames = [];
+ for (let powerName of Object.keys(engine.powers)) if (powerName !== engine.role)
+ tabNames.push(powerName);
+ tabNames.sort();
+ tabNames.push('GLOBAL');
+ highlights = this.state.messageHighlights;
+ } else {
+ tabNames = Object.keys(messageChannels);
+ let totalHighlights = 0;
+ for (let count of Object.values(this.state.messageHighlights))
+ totalHighlights += count;
+ highlights = {messages: totalHighlights};
+ }
+ const unreadMarked = new Set();
+ const currentTabId = this.state.tabCurrentMessages || tabNames[0];
+
+ return (
+ <div className={'panel-messages'} key={'panel-messages'}>
+ {/* Messages. */}
+ <Tabs menu={tabNames} titles={tabNames} onChange={this.onChangeTabCurrentMessages} active={currentTabId}
+ highlights={highlights}>
+ {tabNames.map(protagonist => (
+ <Tab id={`panel-current-messages-${protagonist}`} key={protagonist} className={'game-messages'}
+ display={currentTabId === protagonist}>
+ {(!messageChannels.hasOwnProperty(protagonist) || !messageChannels[protagonist].length ?
+ (<div className={'no-game-message'}>No
+ messages{engine.isPlayerGame() ? ` with ${protagonist}` : ''}.</div>) :
+ (messageChannels[protagonist].map((message, index) => {
+ let id = null;
+ if (!message.read && !unreadMarked.has(protagonist)) {
+ if (engine.isOmniscientGame() || message.sender !== engine.role) {
+ unreadMarked.add(protagonist);
+ id = `${protagonist}-unread`;
+ }
+ }
+ return <MessageView key={index}
+ owner={engine.role}
+ message={message}
+ id={id}
+ onClick={this.onClickMessage}/>;
+ }))
+ )}
+ </Tab>
+ ))}
+ </Tabs>
+ {/* Link to go to first unread received message. */}
+ {unreadMarked.has(currentTabId) && (
+ <Scrollchor className={'link-unread-message'}
+ to={`${currentTabId}-unread`}
+ target={`panel-current-messages-${currentTabId}`}>
+ Go to 1st unread message
+ </Scrollchor>
+ )}
+ {/* Send form. */}
+ {engine.isPlayerGame() && (
+ <MessageForm sender={engine.role} recipient={currentTabId} onSubmit={form =>
+ this.sendMessage(engine.client, currentTabId, form.message)}/>)}
+ </div>
+ );
+ }
+
+ renderPastMap(gameEngine, showOrders) {
+ return <Map key={'past-map'}
+ id={'past-map'}
+ game={gameEngine}
+ mapInfo={this.getMapInfo(gameEngine.map_name)}
+ onError={this.getPage().error}
+ onHover={showOrders ? this.displayLocationOrders : null}
+ showOrders={Boolean(showOrders)}
+ orders={(gameEngine.order_history.contains(gameEngine.phase) && gameEngine.order_history.get(gameEngine.phase)) || null}
+ />;
+ }
+
+ renderCurrentMap(gameEngine, powerName, orderType, orderPath) {
+ const rawOrders = this.__get_orders(gameEngine);
+ const orders = {};
+ for (let entry of Object.entries(rawOrders)) {
+ orders[entry[0]] = [];
+ if (entry[1]) {
+ for (let orderObject of Object.values(entry[1]))
+ orders[entry[0]].push(orderObject.order);
+ }
+ }
+ return <Map key={'current-map'}
+ id={'current-map'}
+ game={gameEngine}
+ mapInfo={this.getMapInfo(gameEngine.map_name)}
+ onError={this.getPage().error}
+ orderBuilding={ContentGame.getOrderBuilding(powerName, orderType, orderPath)}
+ onOrderBuilding={this.onOrderBuilding}
+ onOrderBuilt={this.onOrderBuilt}
+ showOrders={true}
+ orders={orders}
+ onSelectLocation={this.onSelectLocation}
+ onSelectVia={this.onSelectVia}/>;
+ }
+
+ renderTabPhaseHistory(toDisplay, initialEngine) {
+ const pastPhases = initialEngine.state_history.values().map(state => state.name);
+ if (initialEngine.phase === 'COMPLETED') {
+ pastPhases.push('COMPLETED');
+ }
+ let phaseIndex = 0;
+ if (initialEngine.displayed) {
+ if (this.state.historyPhaseIndex === null || this.state.historyPhaseIndex >= pastPhases.length) {
+ phaseIndex = pastPhases.length - 1;
+ } else {
+ if (this.state.historyPhaseIndex < 0) {
+ phaseIndex = pastPhases.length + this.state.historyPhaseIndex;
+ } else {
+ phaseIndex = this.state.historyPhaseIndex;
+ }
+ }
+ }
+ const engine = (
+ phaseIndex === initialEngine.state_history.size() ?
+ initialEngine : initialEngine.cloneAt(initialEngine.state_history.keyFromIndex(phaseIndex))
+ );
+ let orders = {};
+ let orderResult = null;
+ if (engine.order_history.contains(engine.phase))
+ orders = engine.order_history.get(engine.phase);
+ if (engine.result_history.contains(engine.phase))
+ orderResult = engine.result_history.get(engine.phase);
+ let countOrders = 0;
+ for (let powerOrders of Object.values(orders)) {
+ if (powerOrders)
+ countOrders += powerOrders.length;
+ }
+ const powerNames = Object.keys(orders);
+ powerNames.sort();
+
+ const getOrderResult = (order) => {
+ if (orderResult) {
+ const pieces = order.split(/ +/);
+ const unit = `${pieces[0]} ${pieces[1]}`;
+ if (orderResult.hasOwnProperty(unit)) {
+ const resultsToParse = orderResult[unit];
+ if (!resultsToParse.length)
+ resultsToParse.push('');
+ const results = [];
+ for (let r of resultsToParse) {
+ if (results.length)
+ results.push(', ');
+ results.push(<span key={results.length} className={r || 'success'}>{r || 'OK'}</span>);
+ }
+ return <span className={'order-result'}> ({results})</span>;
+ }
+ }
+ return '';
+ };
+
+ const orderView = [
+ (<form key={1} className={'form-inline mb-4'}>
+ <Button title={UTILS.html.UNICODE_LEFT_ARROW} onClick={this.onDecrementPastPhase} pickEvent={true}
+ disabled={phaseIndex === 0}/>
+ <div className={'form-group'}>
+ <select className={'form-control custom-select'}
+ id={'select-past-phase'}
+ value={phaseIndex}
+ onChange={this.onChangePastPhase}>
+ {pastPhases.map((phaseName, index) => <option key={index} value={index}>{phaseName}</option>)}
+ </select>
+ </div>
+ <Button title={UTILS.html.UNICODE_RIGHT_ARROW} onClick={this.onIncrementPastPhase} pickEvent={true}
+ disabled={phaseIndex === pastPhases.length - 1}/>
+ <div className={'form-group'}>
+ <input className={'form-check-input'} id={'show-orders'} type={'checkbox'}
+ checked={this.state.historyShowOrders} onChange={this.onChangeShowPastOrders}/>
+ <label className={'form-check-label'} htmlFor={'show-orders'}>Show orders</label>
+ </div>
+ </form>),
+ ((this.state.historyShowOrders && (
+ (countOrders && (
+ <div key={2} className={'past-orders container'}>
+ {powerNames.map(powerName => !orders[powerName] || !orders[powerName].length ? '' : (
+ <div key={powerName} className={'row'}>
+ <div className={'past-power-name col-sm-2'}>{powerName}</div>
+ <div className={'past-power-orders col-sm-10'}>
+ {orders[powerName].map((order, index) => (
+ <div key={index}>{order}{getOrderResult(order)}</div>
+ ))}
+ </div>
+ </div>
+ ))}
+ </div>
+ )) || <div key={2} className={'no-orders'}>No orders for this phase!</div>
+ )) || '')
+ ];
+ const messageView = this.renderPastMessages(engine);
+
+ let detailsView = null;
+ if (this.state.historyShowOrders && countOrders) {
+ detailsView = (
+ <Row>
+ <div className={'col-sm-6'}>{orderView}</div>
+ <div className={'col-sm-6'}>{messageView}</div>
+ </Row>
+ );
+ } else {
+ detailsView = orderView.slice();
+ detailsView.push(messageView);
+ }
+
+ return (
+ <Tab id={'tab-phase-history'} display={toDisplay}>
+ <Row>
+ <div className={'col-xl'}>
+ {this.state.historyCurrentOrders && (
+ <div className={'history-current-orders'}>{this.state.historyCurrentOrders.join(', ')}</div>
+ )}
+ {this.renderPastMap(engine, this.state.historyShowOrders || this.state.historySubView)}
+ </div>
+ <div className={'col-xl'}>{detailsView}</div>
+ </Row>
+ {toDisplay && <HotKey keys={['arrowleft']} onKeysCoincide={this.onDecrementPastPhase}/>}
+ {toDisplay && <HotKey keys={['arrowright']} onKeysCoincide={this.onIncrementPastPhase}/>}
+ {toDisplay && <HotKey keys={['home']} onKeysCoincide={this.displayFirstPastPhase}/>}
+ {toDisplay && <HotKey keys={['end']} onKeysCoincide={this.displayLastPastPhase}/>}
+ </Tab>
+ );
+ }
+
+ renderTabCurrentPhase(toDisplay, engine, powerName, orderType, orderPath) {
+ const powerNames = Object.keys(engine.powers);
+ powerNames.sort();
+ const orderedPowers = powerNames.map(pn => engine.powers[pn]);
+ return (
+ <Tab id={'tab-current-phase'} display={toDisplay}>
+ <Row>
+ <div className={'col-xl'}>
+ {this.renderCurrentMap(engine, powerName, orderType, orderPath)}
+ </div>
+ <div className={'col-xl'}>
+ {/* Orders. */}
+ <div className={'panel-orders mb-4'}>
+ <Bar className={'p-2'}>
+ <strong className={'mr-4'}>Orders:</strong>
+ <Button title={'reset'} onClick={this.reloadServerOrders}/>
+ <Button title={'delete all'} onClick={this.onRemoveAllOrders}/>
+ <Button color={'primary'} title={'update'} onClick={this.setOrders}/>
+ {(!this.props.data.isPlayerGame() && this.props.data.observer_level === STRINGS.MASTER_TYPE &&
+ <Button color={'danger'} title={'process game'}
+ onClick={this.onProcessGame}/>) || ''}
+ </Bar>
+ <div className={'orders'}>{this.renderOrders(this.props.data, powerName)}</div>
+ <div className={'table-responsive'}>
+ <Table className={'table table-striped table-sm'}
+ caption={'Powers info'}
+ columns={TABLE_POWER_VIEW}
+ data={orderedPowers}
+ wrapper={PowerView.wrap}/>
+ </div>
+ </div>
+ {/* Messages. */}
+ {this.renderCurrentMessages(engine)}
+ </div>
+ </Row>
+ </Tab>
+ );
+ }
+
+ render() {
+ const engine = this.props.data;
+ const phaseType = engine.getPhaseType();
+ const controllablePowers = engine.getControllablePowers();
+ if (this.props.data.client)
+ this.bindCallbacks(this.props.data.client);
+
+ if (engine.phase === 'FORMING')
+ return <main>
+ <div className={'forming'}>Game not yet started!</div>
+ </main>;
+
+ const tabNames = [];
+ const tabTitles = [];
+ let hasTabPhaseHistory = false;
+ let hasTabCurrentPhase = false;
+ if (engine.state_history.size()) {
+ hasTabPhaseHistory = true;
+ tabNames.push('phase_history');
+ tabTitles.push('Phase history');
+ }
+ if (controllablePowers.length && phaseType) {
+ hasTabCurrentPhase = true;
+ tabNames.push('current_phase');
+ tabTitles.push('Current phase');
+ }
+ if (!tabNames.length) {
+ // This should never happen, but let's display this message.
+ return <main>
+ <div className={'no-data'}>No data in this game!</div>
+ </main>;
+ }
+ const mainTab = this.state.tabMain && tabNames.includes(this.state.tabMain) ? this.state.tabMain : tabNames[tabNames.length - 1];
+
+ const currentPowerName = this.state.power || (controllablePowers.length && controllablePowers[0]);
+ let currentPower = null;
+ let orderTypeToLocs = null;
+ let allowedPowerOrderTypes = null;
+ let orderBuildingType = null;
+ let buildCount = null;
+ if (hasTabCurrentPhase) {
+ currentPower = engine.getPower(currentPowerName);
+ orderTypeToLocs = engine.getOrderTypeToLocs(currentPowerName);
+ allowedPowerOrderTypes = Object.keys(orderTypeToLocs);
+ // canOrder = allowedPowerOrderTypes.length
+ if (allowedPowerOrderTypes.length) {
+ POSSIBLE_ORDERS.sortOrderTypes(allowedPowerOrderTypes, phaseType);
+ if (this.state.orderBuildingType && allowedPowerOrderTypes.includes(this.state.orderBuildingType))
+ orderBuildingType = this.state.orderBuildingType;
+ else
+ orderBuildingType = allowedPowerOrderTypes[0];
+ }
+ buildCount = engine.getBuildsCount(currentPowerName);
+ }
+
+ return (
+ <main>
+ {(hasTabCurrentPhase && (
+ <div className={'row align-items-center mb-3'}>
+ <div className={'col-sm-2'}>
+ {(controllablePowers.length === 1 &&
+ <div className={'power-name'}>{controllablePowers[0]}</div>) || (
+ <select className={'form-control custom-select'} id={'current-power'}
+ value={currentPowerName} onChange={this.onChangeCurrentPower}>
+ {controllablePowers.map(
+ powerName => <option key={powerName} value={powerName}>{powerName}</option>)}
+ </select>
+ )}
+ </div>
+ <div className={'col-sm-10'}>
+ <PowerActionsForm orderType={orderBuildingType}
+ orderTypes={allowedPowerOrderTypes}
+ onChange={this.onChangeOrderType}
+ onNoOrders={() => this.onSetNoOrders(currentPowerName)}
+ onSetWaitFlag={() => this.setWaitFlag(!currentPower.wait)}
+ onVote={this.vote}
+ role={engine.role}
+ power={currentPower}/>
+ </div>
+ </div>
+ )) || ''}
+ {(hasTabCurrentPhase && (
+ <div>
+ {(allowedPowerOrderTypes.length && (
+ <span>
+ <strong>Orderable locations</strong>: {orderTypeToLocs[orderBuildingType].join(', ')}
+ </span>
+ ))
+ || (<strong>&nbsp;No orderable location.</strong>)}
+ {phaseType === 'A' && (
+ (buildCount === null && (
+ <strong>&nbsp;(unknown build count)</strong>
+ ))
+ || (buildCount === 0 ? (
+ <strong>&nbsp;(nothing to build or disband)</strong>
+ ) : (buildCount > 0 ? (
+ <strong>&nbsp;({buildCount} unit{buildCount > 1 && 's'} may be built)</strong>
+ ) : (
+ <strong>&nbsp;({-buildCount} unit{buildCount < -1 && 's'} to disband)</strong>
+ )))
+ )}
+ </div>
+ )) || ''}
+ <Tabs menu={tabNames} titles={tabTitles} onChange={this.onChangeMainTab} active={mainTab}>
+ {/* Tab Phase history. */}
+ {(hasTabPhaseHistory && this.renderTabPhaseHistory(mainTab === 'phase_history', engine)) || ''}
+ {/* Tab Current phase. */}
+ {(hasTabCurrentPhase && this.renderTabCurrentPhase(
+ mainTab === 'current_phase',
+ engine,
+ currentPowerName,
+ orderBuildingType,
+ this.state.orderBuildingPath
+ )) || ''}
+ </Tabs>
+ {this.state.fancy_title && (
+ <FancyBox title={this.state.fancy_title} onClose={this.state.on_fancy_close}>
+ {this.state.fancy_function()}
+ </FancyBox>)}
+ </main>
+ );
+ }
+
+ componentDidMount() {
+ super.componentDidMount();
+ if (this.props.data.client)
+ this.reloadDeadlineTimer(this.props.data.client);
+ this.props.data.displayed = true;
+ // Try to prevent scrolling when pressing keys Home and End.
+ document.onkeydown = (event) => {
+ if (['home', 'end'].includes(event.key.toLowerCase())) {
+ // Try to prevent scrolling.
+ if (event.hasOwnProperty('cancelBubble'))
+ event.cancelBubble = true;
+ if (event.stopPropagation)
+ event.stopPropagation();
+ if (event.preventDefault)
+ event.preventDefault();
+ }
+ };
+ }
+
+ componentDidUpdate() {
+ this.props.data.displayed = true;
+ }
+
+ componentWillUnmount() {
+ this.clearScheduleTimeout();
+ this.props.data.displayed = false;
+ document.onkeydown = null;
+ }
+
+}
diff --git a/diplomacy/web/src/gui/diplomacy/contents/content_games.jsx b/diplomacy/web/src/gui/diplomacy/contents/content_games.jsx
new file mode 100644
index 0000000..6a62d71
--- /dev/null
+++ b/diplomacy/web/src/gui/diplomacy/contents/content_games.jsx
@@ -0,0 +1,140 @@
+// ==============================================================================
+// 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 React from "react";
+import {Content} from "../../core/content";
+import {Tab, Tabs} from "../../core/tabs";
+import {Table} from "../../core/table";
+import {FindForm} from "../forms/find_form";
+import {CreateForm} from "../forms/create_form";
+import {InlineGameView} from "../utils/inline_game_view";
+import {STRINGS} from "../../../diplomacy/utils/strings";
+
+const TABLE_LOCAL_GAMES = {
+ game_id: ['Game ID', 0],
+ deadline: ['Deadline', 1],
+ rights: ['Rights', 2],
+ rules: ['Rules', 3],
+ players: ['Players/Expected', 4],
+ status: ['Status', 5],
+ phase: ['Phase', 6],
+ join: ['Join', 7],
+ my_games: ['My Games', 8],
+};
+
+export class ContentGames extends Content {
+
+ constructor(props) {
+ super(props);
+ this.state = {tab: null};
+ this.changeTab = this.changeTab.bind(this);
+ this.onFind = this.onFind.bind(this);
+ this.onCreate = this.onCreate.bind(this);
+ this.wrapGameData = this.wrapGameData.bind(this);
+ }
+
+ static builder(page, data) {
+ return {
+ title: 'Games',
+ navigation: [
+ ['load a game from disk', page.loadGameFromDisk],
+ ['logout', page.logout]
+ ],
+ component: <ContentGames page={page} data={data}/>
+ };
+ }
+
+ onFind(form) {
+ for (let field of ['game_id', 'status', 'include_protected', 'for_omniscience'])
+ if (!form[field])
+ form[field] = null;
+ this.getPage().channel.listGames(form)
+ .then((data) => {
+ this.getPage().success('Found ' + data.length + ' data.');
+ this.getPage().addGamesFound(data);
+ })
+ .catch((error) => {
+ this.getPage().error('Error when looking for distant games: ' + error);
+ });
+ }
+
+ onCreate(form) {
+ for (let key of Object.keys(form)) {
+ if (form[key] === '')
+ form[key] = null;
+ }
+ if (form.n_controls !== null)
+ form.n_controls = parseInt(form.n_controls, 10);
+ if (form.deadline !== null)
+ form.deadline = parseInt(form.deadline, 10);
+ form.rules = ['POWER_CHOICE'];
+ for (let rule of STRINGS.PUBLIC_RULES) {
+ const rule_id = `rule_${rule.toLowerCase()}`;
+ if (form.hasOwnProperty(rule_id)) {
+ if (form[rule_id])
+ form.rules.push(rule);
+ delete form[rule_id];
+ }
+ }
+ let networkGame = null;
+ this.getPage().channel.createGame(form)
+ .then((game) => {
+ this.getPage().addToMyGames(game.local);
+ networkGame = game;
+ return networkGame.getAllPossibleOrders();
+ })
+ .then(allPossibleOrders => {
+ networkGame.local.setPossibleOrders(allPossibleOrders);
+ this.getPage().loadGame(networkGame.local, {success: 'Game created.'});
+ })
+ .catch((error) => {
+ this.getPage().error('Error when creating a game: ' + error);
+ });
+ }
+
+ changeTab(tabIndex) {
+ this.setState({tab: tabIndex});
+ }
+
+ wrapGameData(gameData) {
+ return new InlineGameView(this.getPage(), gameData);
+ }
+
+ render() {
+ const myGames = this.getPage().getMyGames();
+ const tab = this.state.tab ? this.state.tab : (myGames.length ? 'my-games' : 'find');
+ return (
+ <main>
+ <Tabs menu={['create', 'find', 'my-games']} titles={['Create', 'Find', 'My Games']}
+ onChange={this.changeTab} active={tab}>
+ <Tab id="tab-games-create" display={tab === 'create'}>
+ <CreateForm onSubmit={this.onCreate}/>
+ </Tab>
+ <Tab id="tab-games-find" display={tab === 'find'}>
+ <FindForm onSubmit={this.onFind}/>
+ <Table className={"table table-striped"} caption={"Games"} columns={TABLE_LOCAL_GAMES}
+ data={this.getPage().getGamesFound()} wrapper={this.wrapGameData}/>
+ </Tab>
+ <Tab id={'tab-my-games'} display={tab === 'my-games'}>
+ <Table className={"table table-striped"} caption={"My games"} columns={TABLE_LOCAL_GAMES}
+ data={myGames} wrapper={this.wrapGameData}/>
+ </Tab>
+ </Tabs>
+ </main>
+ );
+ }
+
+}
diff --git a/diplomacy/web/src/gui/diplomacy/forms/connection_form.jsx b/diplomacy/web/src/gui/diplomacy/forms/connection_form.jsx
new file mode 100644
index 0000000..49ba381
--- /dev/null
+++ b/diplomacy/web/src/gui/diplomacy/forms/connection_form.jsx
@@ -0,0 +1,123 @@
+// ==============================================================================
+// 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 React from 'react';
+import {Forms} from "../../core/forms";
+import {UTILS} from "../../../diplomacy/utils/utils";
+import PropTypes from "prop-types";
+import {DipStorage} from "../utils/dipStorage";
+
+export class ConnectionForm extends React.Component {
+ constructor(props) {
+ super(props);
+ // Load fields values from local storage.
+ const initialState = this.initState();
+ const savedState = DipStorage.getConnectionForm();
+ if (savedState) {
+ if (savedState.hostname)
+ initialState.hostname = savedState.hostname;
+ if (savedState.port)
+ initialState.port = savedState.port;
+ if (savedState.username)
+ initialState.username = savedState.username;
+ if (savedState.showServerFields)
+ initialState.showServerFields = savedState.showServerFields;
+ }
+ this.state = initialState;
+ this.updateServerFieldsView = this.updateServerFieldsView.bind(this);
+ this.onChange = this.onChange.bind(this);
+ }
+
+ initState() {
+ return {
+ hostname: window.location.hostname,
+ port: (window.location.protocol.toLowerCase() === 'https:') ? 8433 : 8432,
+ username: '',
+ password: '',
+ showServerFields: false
+ };
+ }
+
+ updateServerFieldsView() {
+ DipStorage.setConnectionshowServerFields(!this.state.showServerFields);
+ this.setState({showServerFields: !this.state.showServerFields});
+ }
+
+ onChange(newState) {
+ const initialState = this.initState();
+ if (newState.hostname !== initialState.hostname)
+ DipStorage.setConnectionHostname(newState.hostname);
+ else
+ DipStorage.setConnectionHostname(null);
+ if (newState.port !== initialState.port)
+ DipStorage.setConnectionPort(newState.port);
+ else
+ DipStorage.setConnectionPort(null);
+ if (newState.username !== initialState.username)
+ DipStorage.setConnectionUsername(newState.username);
+ else
+ DipStorage.setConnectionUsername(null);
+ if (this.props.onChange)
+ this.props.onChange(newState);
+ }
+
+ render() {
+ const onChange = Forms.createOnChangeCallback(this, this.onChange);
+ const onSubmit = Forms.createOnSubmitCallback(this, this.props.onSubmit);
+ return (
+ <form>
+ {Forms.createRow(
+ Forms.createColLabel('username', 'username:'),
+ <input className={'form-control'} type={'text'} id={'username'}
+ value={Forms.getValue(this.state, 'username')} onChange={onChange}/>
+ )}
+ {Forms.createRow(
+ Forms.createColLabel('password', 'password:'),
+ <input className={'form-control'} type={'password'} id={'password'}
+ value={Forms.getValue(this.state, 'password')} onChange={onChange}/>
+ )}
+ <div>
+ <div className={this.state.showServerFields ? 'mb-2' : 'mb-4'}>
+ <span className={'button-server'} onClick={this.updateServerFieldsView}>
+ server settings {this.state.showServerFields ? UTILS.html.UNICODE_BOTTOM_ARROW : UTILS.html.UNICODE_TOP_ARROW}
+ </span>
+ </div>
+ {this.state.showServerFields && (
+ <div className={'mb-4'}>
+ {Forms.createRow(
+ <label className={'col'} htmlFor={'hostname'}>hostname:</label>,
+ <input className={'form-control'} type={'text'} id={'hostname'}
+ value={Forms.getValue(this.state, 'hostname')} onChange={onChange}/>
+ )}
+ {Forms.createRow(
+ <label className={'col'} htmlFor={'port'}>port:</label>,
+ <input className={'form-control'} type={'number'} id={'port'}
+ value={Forms.getValue(this.state, 'port')}
+ onChange={onChange}/>
+ )}
+ </div>
+ )}
+ </div>
+ {Forms.createRow('', Forms.createSubmit('connect', true, onSubmit))}
+ </form>
+ );
+ }
+}
+
+ConnectionForm.propTypes = {
+ onChange: PropTypes.func,
+ onSubmit: PropTypes.func
+};
diff --git a/diplomacy/web/src/gui/diplomacy/forms/create_form.jsx b/diplomacy/web/src/gui/diplomacy/forms/create_form.jsx
new file mode 100644
index 0000000..48c733e
--- /dev/null
+++ b/diplomacy/web/src/gui/diplomacy/forms/create_form.jsx
@@ -0,0 +1,95 @@
+// ==============================================================================
+// 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 React from 'react';
+import {Forms} from "../../core/forms";
+import {STRINGS} from "../../../diplomacy/utils/strings";
+import PropTypes from "prop-types";
+
+export class CreateForm extends React.Component {
+ constructor(props) {
+ super(props);
+ this.state = this.initState();
+ }
+
+ initState() {
+ const state = {
+ game_id: '',
+ power_name: '',
+ n_controls: 7,
+ deadline: 300,
+ registration_password: ''
+ };
+ for (let rule of STRINGS.PUBLIC_RULES)
+ state[`rule_${rule.toLowerCase()}`] = false;
+ return state;
+ }
+
+ render() {
+ const onChange = Forms.createOnChangeCallback(this, this.props.onChange);
+ const onSubmit = Forms.createOnSubmitCallback(this, this.props.onSubmit);
+ return (
+ <form>
+ {Forms.createRow(
+ Forms.createColLabel('game_id', 'Game ID (optional)'),
+ <input id={'game_id'} className={'form-control'} type={'text'}
+ value={Forms.getValue(this.state, 'game_id')} onChange={onChange}/>
+ )}
+ {Forms.createRow(
+ Forms.createColLabel('power_name', 'power:'),
+ <select id={'power_name'} className={'form-control custom-select'}
+ value={Forms.getValue(this.state, 'power_name')} onChange={onChange}>
+ {Forms.createSelectOptions(STRINGS.ALL_POWER_NAMES, true)}
+ </select>
+ )}
+ {Forms.createRow(
+ Forms.createColLabel('n_controls', 'number of required players:'),
+ <input id={'n_controls'} className={'form-control'} type={'number'}
+ value={Forms.getValue(this.state, 'n_controls')} onChange={onChange}/>
+ )}
+ {Forms.createRow(
+ Forms.createColLabel('deadline', 'deadline (in seconds)'),
+ <input id={'deadline'} className={'form-control'} type={'number'}
+ value={Forms.getValue(this.state, 'deadline')}
+ onChange={onChange}/>
+ )}
+ {Forms.createRow(
+ Forms.createColLabel('registration_password', 'registration password'),
+ <input id={'registration_password'} className={'form-control'} type={'password'}
+ value={Forms.getValue(this.state, 'registration_password')} onChange={onChange}/>
+ )}
+ <div><strong>RULES:</strong></div>
+ <div className={'mb-4'}>
+ {STRINGS.PUBLIC_RULES.map((rule, index) => (
+ <div key={index} className={'form-check-inline'}>
+ {Forms.createCheckbox(
+ `rule_${rule.toLowerCase()}`,
+ rule,
+ Forms.getValue(this.state, `rule_${rule.toLowerCase()}`),
+ onChange)}
+ </div>
+ ))}
+ </div>
+ {Forms.createRow('', Forms.createSubmit('create a game', true, onSubmit))}
+ </form>
+ );
+ }
+}
+
+CreateForm.propTypes = {
+ onChange: PropTypes.func,
+ onSubmit: PropTypes.func
+};
diff --git a/diplomacy/web/src/gui/diplomacy/forms/find_form.jsx b/diplomacy/web/src/gui/diplomacy/forms/find_form.jsx
new file mode 100644
index 0000000..c73d2b1
--- /dev/null
+++ b/diplomacy/web/src/gui/diplomacy/forms/find_form.jsx
@@ -0,0 +1,70 @@
+// ==============================================================================
+// 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 React from 'react';
+import {Forms} from "../../core/forms";
+import {STRINGS} from "../../../diplomacy/utils/strings";
+import PropTypes from "prop-types";
+
+export class FindForm extends React.Component {
+ constructor(props) {
+ super(props);
+ this.state = this.initState();
+ }
+
+ initState() {
+ return {
+ game_id: '',
+ status: '',
+ include_protected: false,
+ for_omniscience: false
+ };
+ }
+
+ render() {
+ const onChange = Forms.createOnChangeCallback(this, this.props.onChange);
+ const onSubmit = Forms.createOnSubmitCallback(this, this.props.onSubmit);
+ return (
+ <form>
+ {Forms.createRow(
+ Forms.createColLabel('game_id', 'game id (should contain):'),
+ <input className={'form-control'} id={'game_id'} type={'text'}
+ value={Forms.getValue(this.state, 'game_id')}
+ onChange={onChange}/>
+ )}
+ {Forms.createRow(
+ Forms.createColLabel('status', 'status:'),
+ (<select className={'form-control custom-select'}
+ id={'status'} value={Forms.getValue(this.state, 'status')} onChange={onChange}>
+ {Forms.createSelectOptions(STRINGS.ALL_GAME_STATUSES, true)}
+ </select>)
+ )}
+ <div className={'form-check'}>
+ {Forms.createCheckbox('include_protected', 'include protected games.', Forms.getValue(this.state, 'include_protected'), onChange)}
+ </div>
+ <div className={'form-check mb-4'}>
+ {Forms.createCheckbox('for_omniscience', 'for omniscience.', Forms.getValue(this.state, 'for_omniscience'), onChange)}
+ </div>
+ {Forms.createRow('', Forms.createSubmit('find games', true, onSubmit))}
+ </form>
+ );
+ }
+}
+
+FindForm.propTypes = {
+ onChange: PropTypes.func,
+ onSubmit: PropTypes.func
+};
diff --git a/diplomacy/web/src/gui/diplomacy/forms/join_form.jsx b/diplomacy/web/src/gui/diplomacy/forms/join_form.jsx
new file mode 100644
index 0000000..0447280
--- /dev/null
+++ b/diplomacy/web/src/gui/diplomacy/forms/join_form.jsx
@@ -0,0 +1,77 @@
+// ==============================================================================
+// 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 React from 'react';
+import {Forms} from "../../core/forms";
+import {STRINGS} from "../../../diplomacy/utils/strings";
+import PropTypes from "prop-types";
+
+export class JoinForm extends React.Component {
+ constructor(props) {
+ super(props);
+ this.state = this.initState();
+ }
+
+ initState() {
+ return {
+ [this.getPowerNameID()]: this.getDefaultPowerName(),
+ [this.getPasswordID()]: ''
+ };
+ }
+
+ getPowerNameID() {
+ return `power_name_${this.props.game_id}`;
+ }
+
+ getPasswordID() {
+ return `registration_password_${this.props.game_id}`;
+ }
+
+ getDefaultPowerName() {
+ return (this.props.powers && this.props.powers.length && this.props.powers[0]) || '';
+ }
+
+ render() {
+ const onChange = Forms.createOnChangeCallback(this, this.props.onChange);
+ const onSubmit = Forms.createOnSubmitCallback(this, this.props.onSubmit);
+ return (
+ <form className={'form-inline'}>
+ <div className={'form-group'}>
+ {Forms.createLabel(this.getPowerNameID(), 'Power:')}
+ <select id={this.getPowerNameID()} className={'from-control custom-select ml-2'}
+ value={Forms.getValue(this.state, this.getPowerNameID())} onChange={onChange}>
+ {Forms.createSelectOptions(STRINGS.ALL_POWER_NAMES, true)}
+ </select>
+ </div>
+ <div className={'form-group mx-2'}>
+ {Forms.createLabel(this.getPasswordID(), '', 'sr-only')}
+ <input id={this.getPasswordID()} type={'password'} className={'form-control'}
+ placeholder={'registration password'}
+ value={Forms.getValue(this.state, this.getPasswordID())}
+ onChange={onChange}/>
+ </div>
+ {Forms.createSubmit('join', false, onSubmit)}
+ </form>
+ );
+ }
+}
+
+JoinForm.propTypes = {
+ game_id: PropTypes.string.isRequired,
+ powers: PropTypes.arrayOf(PropTypes.string),
+ onChange: PropTypes.func,
+ onSubmit: PropTypes.func
+};
diff --git a/diplomacy/web/src/gui/diplomacy/forms/message_form.jsx b/diplomacy/web/src/gui/diplomacy/forms/message_form.jsx
new file mode 100644
index 0000000..a7c377a
--- /dev/null
+++ b/diplomacy/web/src/gui/diplomacy/forms/message_form.jsx
@@ -0,0 +1,53 @@
+// ==============================================================================
+// 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 React from 'react';
+import {Forms} from "../../core/forms";
+import {UTILS} from "../../../diplomacy/utils/utils";
+import PropTypes from "prop-types";
+
+export class MessageForm extends React.Component {
+ constructor(props) {
+ super(props);
+ this.state = this.initState();
+ }
+
+ initState() {
+ return {message: ''};
+ }
+
+ render() {
+ const onChange = Forms.createOnChangeCallback(this, this.props.onChange);
+ const onSubmit = Forms.createOnSubmitCallback(this, this.props.onSubmit, this.initState());
+ return (
+ <form>
+ <div className={'form-group'}>
+ {Forms.createLabel('message', '', 'sr-only')}
+ <textarea id={'message'} className={'form-control'}
+ value={Forms.getValue(this.state, 'message')} onChange={onChange}/>
+ </div>
+ {Forms.createSubmit(`send (${this.props.sender} ${UTILS.html.UNICODE_SMALL_RIGHT_ARROW} ${this.props.recipient})`, true, onSubmit)}
+ </form>
+ );
+ }
+}
+
+MessageForm.propTypes = {
+ sender: PropTypes.string,
+ recipient: PropTypes.string,
+ onChange: PropTypes.func,
+ onSubmit: PropTypes.func
+};
diff --git a/diplomacy/web/src/gui/diplomacy/forms/power_actions_form.jsx b/diplomacy/web/src/gui/diplomacy/forms/power_actions_form.jsx
new file mode 100644
index 0000000..33bd763
--- /dev/null
+++ b/diplomacy/web/src/gui/diplomacy/forms/power_actions_form.jsx
@@ -0,0 +1,120 @@
+// ==============================================================================
+// 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 React from 'react';
+import {Forms} from "../../core/forms";
+import {ORDER_BUILDER} from "../utils/order_building";
+import {STRINGS} from "../../../diplomacy/utils/strings";
+import PropTypes from "prop-types";
+import {Power} from "../../../diplomacy/engine/power";
+
+const HotKey = require('react-shortcut');
+
+export class PowerActionsForm extends React.Component {
+ constructor(props) {
+ super(props);
+ this.state = this.initState();
+ }
+
+ initState() {
+ return {order_type: this.props.orderType};
+ }
+
+ render() {
+ const onChange = Forms.createOnChangeCallback(this, this.props.onChange);
+ const onReset = Forms.createOnResetCallback(this, this.props.onChange, this.initState());
+ const onSetOrderType = (letter) => {
+ this.setState({order_type: letter}, () => {
+ if (this.props.onChange)
+ this.props.onChange(this.state);
+ });
+ };
+ let title = '';
+ let titleClass = 'mr-4';
+ const header = [];
+ const votes = [];
+ if (this.props.orderTypes.length) {
+ title = 'Create order:';
+ header.push(<strong key={'title'} className={titleClass}>{title}</strong>);
+ header.push(...this.props.orderTypes.map((orderLetter, index) => (
+ <div key={index} className={'form-check-inline'}>
+ {Forms.createRadio('order_type', orderLetter, ORDER_BUILDER[orderLetter].name, this.props.orderType, onChange)}
+ </div>
+ )));
+ header.push(Forms.createReset('reset', false, onReset));
+ } else if (this.props.power.order_is_set) {
+ title = 'Unorderable power (already locked on server).';
+ titleClass += ' neutral';
+ header.push(<strong key={'title'} className={titleClass}>{title}</strong>);
+ } else {
+ title = 'No orders available for this power.';
+ header.push(<strong key={'title'} className={titleClass}>{title}</strong>);
+ }
+ if (!this.props.power.order_is_set) {
+ header.push(Forms.createButton('pass', this.props.onNoOrders));
+ }
+
+ if (this.props.role !== STRINGS.OMNISCIENT_TYPE) {
+ votes.push(<strong key={0} className={'ml-4 mr-2'}>Vote for draw:</strong>);
+ switch (this.props.power.vote) {
+ case 'yes':
+ votes.push(Forms.createButton('no', () => this.props.onVote('no'), 'danger'));
+ votes.push(Forms.createButton('neutral', () => this.props.onVote('neutral'), 'info'));
+ break;
+ case 'no':
+ votes.push(Forms.createButton('yes', () => this.props.onVote('yes'), 'success'));
+ votes.push(Forms.createButton('neutral', () => this.props.onVote('neutral'), 'info'));
+ break;
+ case 'neutral':
+ votes.push(Forms.createButton('yes', () => this.props.onVote('yes'), 'success'));
+ votes.push(Forms.createButton('no', () => this.props.onVote('no'), 'danger'));
+ break;
+ default:
+ votes.push(Forms.createButton('yes', () => this.props.onVote('yes'), 'success'));
+ votes.push(Forms.createButton('no', () => this.props.onVote('no'), 'danger'));
+ votes.push(Forms.createButton('neutral', () => this.props.onVote('neutral'), 'info'));
+ break;
+ }
+ }
+ return (
+ <form className={'form-inline power-actions-form'}>
+ {header}
+ {Forms.createButton(
+ (this.props.power.wait ? 'no wait' : 'wait'),
+ this.props.onSetWaitFlag,
+ (this.props.power.wait ? 'success' : 'danger')
+ )}
+ {votes}
+ <HotKey keys={['escape']} onKeysCoincide={onReset}/>
+ {this.props.orderTypes.map((letter, index) => (
+ <HotKey key={index} keys={[letter.toLowerCase()]} onKeysCoincide={() => onSetOrderType(letter)}/>
+ ))}
+ </form>
+ );
+ }
+}
+
+PowerActionsForm.propTypes = {
+ orderType: PropTypes.oneOf(Object.keys(ORDER_BUILDER)),
+ orderTypes: PropTypes.arrayOf(PropTypes.oneOf(Object.keys(ORDER_BUILDER))),
+ power: PropTypes.instanceOf(Power),
+ role: PropTypes.string,
+ onChange: PropTypes.func,
+ onSubmit: PropTypes.func,
+ onNoOrders: PropTypes.func, // onNoOrders()
+ onVote: PropTypes.func, // onVote(voteString)
+ onSetWaitFlag: PropTypes.func, // onSetWaitFlag(),
+};
diff --git a/diplomacy/web/src/gui/diplomacy/forms/select_location_form.jsx b/diplomacy/web/src/gui/diplomacy/forms/select_location_form.jsx
new file mode 100644
index 0000000..3c55e49
--- /dev/null
+++ b/diplomacy/web/src/gui/diplomacy/forms/select_location_form.jsx
@@ -0,0 +1,36 @@
+// ==============================================================================
+// 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 React from "react";
+import PropTypes from "prop-types";
+import {Button} from "../../core/widgets";
+
+export class SelectLocationForm extends React.Component {
+ render() {
+ return (
+ <div>
+ {this.props.locations.map((location, index) => (
+ <Button key={index} title={location} large={true} onClick={() => this.props.onSelect(location)}/>
+ ))}
+ </div>
+ );
+ }
+}
+
+SelectLocationForm.propTypes = {
+ locations: PropTypes.arrayOf(PropTypes.string).isRequired,
+ onSelect: PropTypes.func.isRequired // onSelect(location)
+};
diff --git a/diplomacy/web/src/gui/diplomacy/forms/select_via_form.jsx b/diplomacy/web/src/gui/diplomacy/forms/select_via_form.jsx
new file mode 100644
index 0000000..cc62fe2
--- /dev/null
+++ b/diplomacy/web/src/gui/diplomacy/forms/select_via_form.jsx
@@ -0,0 +1,35 @@
+// ==============================================================================
+// 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 React from "react";
+import PropTypes from "prop-types";
+import {Button} from "../../core/widgets";
+
+export class SelectViaForm extends React.Component {
+ render() {
+ return (
+ <div>
+ <Button title={'regular move (M)'} large={true} onClick={() => this.props.onSelect('M')}/>
+ <Button title={'move via (V)'} large={true} onClick={() => this.props.onSelect('V')}/>
+ </div>
+ );
+ }
+}
+
+SelectViaForm.propTypes = {
+ onSelect: PropTypes.func.isRequired
+};
+
diff --git a/diplomacy/web/src/gui/diplomacy/map/dom_order_builder.js b/diplomacy/web/src/gui/diplomacy/map/dom_order_builder.js
new file mode 100644
index 0000000..8b7072e
--- /dev/null
+++ b/diplomacy/web/src/gui/diplomacy/map/dom_order_builder.js
@@ -0,0 +1,278 @@
+// ==============================================================================
+// 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 {UTILS} from "../../../diplomacy/utils/utils";
+import $ from "jquery";
+import {extendOrderBuilding} from "../utils/order_building";
+import {Diplog} from "../../../diplomacy/utils/diplog";
+
+function parseLocation(txt) {
+ if (txt.length > 2 && txt[1] === ' ' && ['A', 'F'].includes(txt[0]))
+ return txt.substr(2);
+ return txt;
+}
+
+export class DOMOrderBuilder {
+
+ constructor(svgElement, onOrderBuilding, onOrderBuilt, onSelectLocation, onSelectVia, onError) {
+ this.svg = svgElement;
+ this.cbOrderBuilding = onOrderBuilding;
+ this.cbOrderBuilt = onOrderBuilt;
+ this.cbSelectLocation = onSelectLocation;
+ this.cbSelectVia = onSelectVia;
+ this.cbError = onError;
+
+ this.game = null;
+ this.mapData = null;
+ this.orderBuilding = null;
+
+ this.provinceColors = {};
+ this.clickedID = null;
+ this.clickedNeighbors = [];
+
+ this.onProvinceClick = this.onProvinceClick.bind(this);
+ this.onLabelClick = this.onLabelClick.bind(this);
+ this.onUnitClick = this.onUnitClick.bind(this);
+ }
+
+ saveProvinceColors() {
+ // Get province colors.
+ const elements = this.svg.getElementsByTagName('path');
+ for (let element of elements) {
+ this.provinceColors[element.id] = element.getAttribute('class');
+ }
+ }
+
+ provinceNameToMapID(name) {
+ return `_${name.toLowerCase()}___${this.svg.parentNode.id}`;
+ }
+
+ mapID(id) {
+ return `${id}___${this.svg.parentNode.id}`;
+ }
+
+ onOrderBuilding(svgPath, powerName, orderPath) {
+ this.cbOrderBuilding(powerName, orderPath);
+ }
+
+ onOrderBuilt(svgPath, powerName, orderString) {
+ this.cbOrderBuilt(powerName, orderString);
+ }
+
+ onError(svgPath, error) {
+ this.cbError(error.toString());
+ }
+
+ handleSvgPath(svgPath) {
+ const orderBuilding = this.orderBuilding;
+ if (!orderBuilding.builder)
+ return this.onError(svgPath, 'No orderable locations.');
+
+ const province = this.mapData.getProvince(svgPath.id);
+ if (!province)
+ return;
+
+ const stepLength = orderBuilding.builder.steps.length;
+ if (orderBuilding.path.length >= stepLength)
+ throw new Error(`Order building: current steps count (${orderBuilding.path.length}) should be less than` +
+ ` expected steps count (${stepLength}) (${orderBuilding.path.join(', ')}).`);
+
+ const lengthAfterClick = orderBuilding.path.length + 1;
+ let validLocations = [];
+ const testedPath = [orderBuilding.type].concat(orderBuilding.path);
+ const value = UTILS.javascript.getTreeValue(this.game.ordersTree, testedPath);
+ if (value !== null) {
+ const checker = orderBuilding.builder.steps[lengthAfterClick - 1];
+ try {
+ const possibleLocations = checker(province, orderBuilding.power);
+ for (let possibleLocation of possibleLocations) {
+ possibleLocation = possibleLocation.toUpperCase();
+ if (value.includes(possibleLocation))
+ validLocations.push(possibleLocation);
+ }
+ } catch (error) {
+ return this.onError(svgPath, error);
+ }
+ }
+ if (!validLocations.length)
+ return this.onError(svgPath, 'Disallowed.');
+
+ if (validLocations.length > 1 && orderBuilding.type === 'S' && orderBuilding.path.length >= 2) {
+ // We are building a support order and we have a multiple choice for a location.
+ // Let's check if next location to choose is a coast. To have a coast:
+ // - all possible locations must start with same 3 characters.
+ // - we expect at least province name in possible locations (e.g. 'SPA' for 'SPA/NC').
+ // If we have a coast, we will remove province name from possible locations.
+ let isACoast = true;
+ let validLocationsNoProvinceName = [];
+ for (let i = 0; i < validLocations.length; ++i) {
+ let location = validLocations[i];
+ if (i > 0) {
+ // Compare 3 first letters with previous location.
+ if (validLocations[i - 1].substring(0, 3).toUpperCase() !== validLocations[i].substring(0, 3).toUpperCase()) {
+ // No same prefix with previous location. We does not have a coast.
+ isACoast = false;
+ break;
+ }
+ }
+ if (location.length !== 3)
+ validLocationsNoProvinceName.push(location);
+ }
+ if (validLocations.length === validLocationsNoProvinceName.length) {
+ // We have not found province name.
+ isACoast = false;
+ }
+ if (isACoast) {
+ // We want to choose location in a coastal province. Let's remove province name.
+ validLocations = validLocationsNoProvinceName;
+ }
+ }
+
+ if (validLocations.length > 1) {
+ if (this.cbSelectLocation) {
+ return this.cbSelectLocation(validLocations, orderBuilding.power, orderBuilding.type, orderBuilding.path);
+ } else {
+ Diplog.warn(`Forced to select first valid location.`);
+ validLocations = [validLocations[0]];
+ }
+ }
+ let orderBuildingType = orderBuilding.type;
+ if (lengthAfterClick === stepLength && orderBuildingType === 'M') {
+ const moveOrderPath = ['M'].concat(orderBuilding.path, validLocations[0]);
+ const moveTypes = UTILS.javascript.getTreeValue(this.game.ordersTree, moveOrderPath);
+ if (moveTypes !== null) {
+ if (moveTypes.length === 2) {
+ // This move can be done either regularly or VIA a fleet. Let user choose.
+ return this.cbSelectVia(validLocations[0], orderBuilding.power, orderBuilding.path);
+ } else {
+ orderBuildingType = moveTypes[0];
+ }
+ }
+ }
+ this.clickedID = svgPath.id;
+
+ this.cleanBuildingView();
+ if (lengthAfterClick < stepLength)
+ this.renderBuildingView(validLocations[0]);
+ extendOrderBuilding(
+ orderBuilding.power, orderBuildingType, orderBuilding.path, validLocations[0],
+ this.cbOrderBuilding, this.cbOrderBuilt, this.cbError
+ );
+
+ }
+
+ getPathFromProvince(province) {
+ let path = this.svg.getElementById(this.provinceNameToMapID(province.name));
+ if (!path) {
+ for (let alias of province.aliases) {
+ path = this.svg.getElementById(this.provinceNameToMapID(alias));
+ if (path)
+ break;
+ }
+ }
+ return path;
+ }
+
+ onProvinceClick(event) {
+ this.handleSvgPath(event.target);
+ }
+
+ onLabelClick(event) {
+ const province = this.mapData.getProvince(event.target.textContent);
+ if (province) {
+ const path = this.getPathFromProvince(province);
+ if (path)
+ this.handleSvgPath(path);
+ }
+ }
+
+ onUnitClick(event) {
+ const province = this.mapData.getProvince(event.target.getAttribute('diplomacyUnit'));
+ if (province) {
+ let path = this.getPathFromProvince(province);
+ if (!path && province.isCoast())
+ path = this.svg.getElementById(this.provinceNameToMapID(province.parent.name));
+ if (path) {
+ this.handleSvgPath(path);
+ }
+ }
+ }
+
+ cleanBuildingView() {
+ if (this.clickedID) {
+ const path = this.svg.getElementById(this.clickedID);
+ if (path)
+ path.setAttribute('class', this.provinceColors[this.clickedID]);
+ }
+ for (let neighborName of this.clickedNeighbors) {
+ const province = this.mapData.getProvince(neighborName);
+ if (!province)
+ continue;
+ const path = this.getPathFromProvince(province);
+ if (path)
+ path.setAttribute('class', this.provinceColors[path.id]);
+ }
+ this.clickedNeighbors = [];
+ }
+
+ renderBuildingView(extraLocation) {
+ if (this.clickedID) {
+ const path = this.svg.getElementById(this.clickedID);
+ if (path)
+ path.setAttribute('class', 'provinceRed');
+ }
+ const selectedPath = [this.orderBuilding.type].concat(this.orderBuilding.path);
+ if (extraLocation)
+ selectedPath.push(extraLocation);
+ const possibleNeighbors = UTILS.javascript.getTreeValue(this.game.ordersTree, selectedPath);
+ if (!possibleNeighbors)
+ return;
+ this.clickedNeighbors = possibleNeighbors.map(neighbor => parseLocation(neighbor));
+ if (this.clickedNeighbors.length) {
+ for (let neighbor of this.clickedNeighbors) {
+ let neighborProvince = this.mapData.getProvince(neighbor);
+ if (!neighborProvince)
+ throw new Error('Unknown neighbor province ' + neighbor);
+ let path = this.getPathFromProvince(neighborProvince);
+ if (!path && neighborProvince.isCoast())
+ path = this.getPathFromProvince(neighborProvince.parent);
+ if (!path)
+ throw new Error(`Unable to find SVG path related to province ${neighborProvince.name}.`);
+ path.setAttribute('class', neighborProvince.isWater() ? 'provinceBlue' : 'provinceGreen');
+ }
+ }
+ }
+
+ update(game, mapData, orderBuilding) {
+ this.game = game;
+ this.mapData = mapData;
+ this.orderBuilding = orderBuilding;
+ this.saveProvinceColors();
+ // If there is a building path, then we are building, so we don't clean anything.
+ this.cleanBuildingView();
+ if (this.orderBuilding.path.length)
+ this.renderBuildingView();
+ // I don't yet know why I should place this here. Maybe because unit are re-rendered manually at every reloading ?
+ $(`#${this.svg.parentNode.id} svg use[diplomacyUnit]`).click(this.onUnitClick);
+ }
+
+ init(game, mapData, orderBuilding) {
+ $(`#${this.svg.parentNode.id} svg path`).click(this.onProvinceClick);
+ $(`#${this.mapID('BriefLabelLayer')} text`).click(this.onLabelClick);
+ this.update(game, mapData, orderBuilding);
+ }
+
+}
diff --git a/diplomacy/web/src/gui/diplomacy/map/dom_past_map.js b/diplomacy/web/src/gui/diplomacy/map/dom_past_map.js
new file mode 100644
index 0000000..58fd6c8
--- /dev/null
+++ b/diplomacy/web/src/gui/diplomacy/map/dom_past_map.js
@@ -0,0 +1,112 @@
+// ==============================================================================
+// 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 $ from "jquery";
+
+export class DOMPastMap {
+
+ constructor(svgElement, onHover) {
+ this.svg = svgElement;
+ this.cbHover = onHover;
+ this.game = null;
+ this.orders = null;
+ this.mapData = null;
+ this.onProvinceHover = this.onProvinceHover.bind(this);
+ this.onLabelHover = this.onLabelHover.bind(this);
+ this.onUnitHover = this.onUnitHover.bind(this);
+ }
+
+ provinceNameToMapID(name) {
+ return `_${name.toLowerCase()}___${this.svg.parentNode.id}`;
+ }
+
+ mapID(id) {
+ return `${id}___${this.svg.parentNode.id}`;
+ }
+
+ onHover(name) {
+ const orders = [];
+ if (this.orders) {
+ for (let powerOrders of Object.values(this.orders)) {
+ for (let order of powerOrders) {
+ const pieces = order.split(/ +/);
+ if (pieces[1].slice(0, 3) === name.toUpperCase().slice(0, 3))
+ orders.push(order);
+ }
+ }
+ }
+ return orders;
+ }
+
+ handleSvgPath(svgPath) {
+ const province = this.mapData.getProvince(svgPath.id);
+ if (province) {
+ this.cbHover(province.name, this.onHover(province.name));
+ }
+ }
+
+ getPathFromProvince(province) {
+ let path = this.svg.getElementById(this.provinceNameToMapID(province.name));
+ if (!path) {
+ for (let alias of province.aliases) {
+ path = this.svg.getElementById(this.provinceNameToMapID(alias));
+ if (path)
+ break;
+ }
+ }
+ return path;
+ }
+
+ onProvinceHover(event) {
+ this.handleSvgPath(event.target);
+ }
+
+ onLabelHover(event) {
+ const province = this.mapData.getProvince(event.target.textContent);
+ if (province) {
+ const path = this.getPathFromProvince(province);
+ if (path)
+ this.handleSvgPath(path);
+ }
+ }
+
+ onUnitHover(event) {
+ const province = this.mapData.getProvince(event.target.getAttribute('diplomacyUnit'));
+ if (province) {
+ let path = this.getPathFromProvince(province);
+ if (!path && province.isCoast())
+ path = this.svg.getElementById(this.provinceNameToMapID(province.parent.name));
+ if (path) {
+ this.handleSvgPath(path);
+ }
+ }
+ }
+
+ update(game, mapData, orders) {
+ this.game = game;
+ this.mapData = mapData;
+ this.orders = orders;
+ // I don't yet know why I should place this here. Maybe because unit are re-rendered manually at every reloading ?
+ $(`#${this.svg.parentNode.id} svg use[diplomacyUnit]`).hover(this.onUnitHover);
+ }
+
+ init(game, mapData, orders) {
+ $(`#${this.svg.parentNode.id} svg path`).hover(this.onProvinceHover).mouseleave(() => this.cbHover(null, null));
+ $(`#${this.mapID('BriefLabelLayer')} text`).hover(this.onLabelHover);
+ this.update(game, mapData, orders);
+ }
+
+}
diff --git a/diplomacy/web/src/gui/diplomacy/map/map.jsx b/diplomacy/web/src/gui/diplomacy/map/map.jsx
new file mode 100644
index 0000000..2a2949f
--- /dev/null
+++ b/diplomacy/web/src/gui/diplomacy/map/map.jsx
@@ -0,0 +1,94 @@
+// ==============================================================================
+// 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 React from "react";
+import SVG from 'react-inlinesvg';
+import mapSVG from '../../../standard.svg';
+import {Renderer} from "./renderer";
+import {MapData} from "../utils/map_data";
+import {DOMOrderBuilder} from "./dom_order_builder";
+import PropTypes from 'prop-types';
+import {DOMPastMap} from "./dom_past_map";
+
+export class Map extends React.Component {
+ // id: ID of div wrapping SVG map.
+ // mapInfo: dict
+ // game: game engine
+ // onError: callback(error)
+ // showOrders: bool
+
+ // orderBuilding: dict
+ // onOrderBuilding: callback(powerName, orderBuildingPath)
+ // onOrderBuilt: callback(powerName, orderString)
+
+ constructor(props) {
+ super(props);
+ this.renderer = null;
+ this.domOrderBuilder = null;
+ this.domPastMap = null;
+ this.initSVG = this.initSVG.bind(this);
+ }
+
+ initSVG() {
+ const svg = document.getElementById(this.props.id).getElementsByTagName('svg')[0];
+
+ const game = this.props.game;
+ const mapData = new MapData(this.props.mapInfo, game);
+ this.renderer = new Renderer(svg, game, mapData);
+ this.renderer.render(this.props.showOrders, this.props.orders);
+ if (this.props.orderBuilding) {
+ this.domOrderBuilder = new DOMOrderBuilder(
+ svg,
+ this.props.onOrderBuilding, this.props.onOrderBuilt, this.props.onSelectLocation, this.props.onSelectVia,
+ this.props.onError
+ );
+ this.domOrderBuilder.init(game, mapData, this.props.orderBuilding);
+ } else if (this.props.onHover) {
+ this.domPastMap = new DOMPastMap(svg, this.props.onHover);
+ this.domPastMap.init(game, mapData, this.props.orders);
+ }
+ }
+
+ render() {
+ if (this.renderer) {
+ const game = this.props.game;
+ const mapData = new MapData(this.props.mapInfo, game);
+ this.renderer.update(game, mapData, this.props.showOrders, this.props.orders);
+ if (this.domOrderBuilder)
+ this.domOrderBuilder.update(game, mapData, this.props.orderBuilding);
+ else if (this.domPastMap)
+ this.domPastMap.update(game, mapData, this.props.orders);
+ }
+ const divFactory = ((props, children) => <div id={this.props.id} {...props}>{children}</div>);
+ return <SVG wrapper={divFactory} uniquifyIDs={true} uniqueHash={this.props.id} src={mapSVG}
+ onLoad={this.initSVG} onError={err => this.props.onError(err.message)}>Game map</SVG>;
+ }
+}
+
+Map.propTypes = {
+ id: PropTypes.string,
+ showOrders: PropTypes.bool,
+ orders: PropTypes.objectOf(PropTypes.arrayOf(PropTypes.string)),
+ onSelectLocation: PropTypes.func,
+ onSelectVia: PropTypes.func,
+ game: PropTypes.object,
+ mapInfo: PropTypes.object,
+ orderBuilding: PropTypes.object,
+ onOrderBuilding: PropTypes.func,
+ onOrderBuilt: PropTypes.func,
+ onError: PropTypes.func,
+ onHover: PropTypes.func,
+};
diff --git a/diplomacy/web/src/gui/diplomacy/map/renderer.js b/diplomacy/web/src/gui/diplomacy/map/renderer.js
new file mode 100644
index 0000000..e2586af
--- /dev/null
+++ b/diplomacy/web/src/gui/diplomacy/map/renderer.js
@@ -0,0 +1,615 @@
+// ==============================================================================
+// 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 $ from "jquery";
+
+const ARMY = 'Army';
+const FLEET = 'Fleet';
+// SVG tag names.
+const PREFIX_TAG = 'jdipNS'.toLowerCase();
+const TAG_ORDERDRAWING = 'jdipNS:ORDERDRAWING'.toLowerCase();
+const TAG_POWERCOLORS = 'jdipNS:POWERCOLORS'.toLowerCase();
+const TAG_POWERCOLOR = 'jdipNS:POWERCOLOR'.toLowerCase();
+const TAG_SYMBOLSIZE = 'jdipNS:SYMBOLSIZE'.toLowerCase();
+const TAG_PROVINCE_DATA = 'jdipNS:PROVINCE_DATA'.toLowerCase();
+const TAG_PROVINCE = 'jdipNS:PROVINCE'.toLowerCase();
+const TAG_UNIT = 'jdipNS:UNIT'.toLowerCase();
+const TAG_DISLODGED_UNIT = 'jdipNS:DISLODGED_UNIT'.toLowerCase();
+const TAG_SUPPLY_CENTER = 'jdipNS:SUPPLY_CENTER'.toLowerCase();
+const TAG_DISPLAY = 'jdipNS:DISPLAY'.toLowerCase();
+
+function attr(node, name) {
+ return node.attributes[name].value;
+}
+
+function offset(floatString, offset) {
+ return "" + (parseFloat(floatString) + offset);
+}
+
+export class Renderer {
+ constructor(svgDomElement, game, mapData) {
+ this.svg = svgDomElement;
+ this.game = game;
+ this.mapData = mapData;
+ this.metadata = {
+ color: {},
+ symbol_size: {},
+ orders: {},
+ coord: {}
+ };
+ this.initialInfluences = {};
+ this.__load_metadata();
+ this.__save_initial_influences();
+ }
+
+ __hashed_id(id) {
+ return `${id}___${this.svg.parentNode.id}`;
+ }
+
+ __svg_element_from_id(id) {
+ const hashedID = this.__hashed_id(id);
+ const element = this.svg.getElementById(hashedID);
+ if (!element)
+ throw new Error(`Unable to find ID ${id} (looked for hashed ID ${hashedID})`);
+ return element;
+ }
+
+ __load_metadata() {
+ // Order drawings.
+ const order_drawings = this.svg.getElementsByTagName(TAG_ORDERDRAWING);
+ if (!order_drawings.length)
+ throw new Error('Unable to find order drawings (tag ' + TAG_ORDERDRAWING + ') in SVG map.');
+ for (let order_drawing of order_drawings) {
+ for (let child_node of order_drawing.childNodes) {
+ if (child_node.nodeName === TAG_POWERCOLORS) {
+ // Power colors.
+ for (let power_color of child_node.childNodes) {
+ if (power_color.nodeName === TAG_POWERCOLOR) {
+ this.metadata.color[attr(power_color, 'power').toUpperCase()] = attr(power_color, 'color');
+ }
+ }
+ } else if (child_node.nodeName === TAG_SYMBOLSIZE) {
+ // Symbol size.
+ this.metadata.symbol_size[attr(child_node, 'name')] = [attr(child_node, 'height'), attr(child_node, 'width')];
+ } else if (child_node.nodeName.startsWith(PREFIX_TAG)) {
+ // Order type.
+ const order_type = child_node.nodeName.replace(PREFIX_TAG + ':', '');
+ this.metadata.orders[order_type] = {};
+ for (let attribute of child_node.attributes) {
+ if (!attribute.name.includes(':')) {
+ this.metadata.orders[order_type][attribute.name] = attribute.value;
+ }
+ }
+ }
+ }
+ }
+ // Object coordinates.
+ const all_province_data = this.svg.getElementsByTagName(TAG_PROVINCE_DATA);
+ if (!all_province_data.length)
+ throw new Error('Unable to find province data in SVG map (tag ' + TAG_PROVINCE_DATA + ').');
+ for (let province_data of all_province_data) {
+ for (let child_node of province_data.childNodes) {
+ // Province.
+ if (child_node.nodeName === TAG_PROVINCE) {
+ const province = attr(child_node, 'name').toUpperCase().replace('-', '/');
+ this.metadata.coord[province] = {};
+ for (let coord_node of child_node.childNodes) {
+ if (coord_node.nodeName === TAG_UNIT) {
+ this.metadata.coord[province].unit = [attr(coord_node, 'x'), attr(coord_node, 'y')];
+ } else if (coord_node.nodeName === TAG_DISLODGED_UNIT) {
+ this.metadata.coord[province].disl = [attr(coord_node, 'x'), attr(coord_node, 'y')];
+ } else if (coord_node.nodeName === TAG_SUPPLY_CENTER) {
+ this.metadata.coord[province].sc = [attr(coord_node, 'x'), attr(coord_node, 'y')];
+ }
+ }
+ }
+ }
+ }
+ // Deleting.
+ this.svg.removeChild(this.svg.getElementsByTagName(TAG_DISPLAY)[0]);
+ this.svg.removeChild(this.svg.getElementsByTagName(TAG_ORDERDRAWING)[0]);
+ this.svg.removeChild(this.svg.getElementsByTagName(TAG_PROVINCE_DATA)[0]);
+
+ // (this code was previously in render())
+ // Removing mouse layer.
+ this.svg.removeChild(this.__svg_element_from_id('MouseLayer'));
+ }
+
+ __save_initial_influences() {
+ const mapLayer = this.__svg_element_from_id('MapLayer');
+ if (!mapLayer)
+ throw new Error('Unable to find map layer.');
+ for (let element of mapLayer.childNodes) {
+ if (element.tagName === 'path') {
+ this.initialInfluences[element.id] = element.getAttribute('class');
+ }
+ }
+ }
+
+ __restore_initial_influences() {
+ for (let id of Object.keys(this.initialInfluences)) {
+ const className = this.initialInfluences[id];
+ this.svg.getElementById(id).setAttribute('class', className);
+ }
+ }
+
+ __set_current_phase() {
+ const current_phase = (this.game.phase[0] === '?' || this.game.phase === 'COMPLETED') ? 'FINAL' : this.game.phase;
+ const phase_display = this.__svg_element_from_id('CurrentPhase');
+ if (phase_display) {
+ phase_display.childNodes[0].nodeValue = current_phase;
+ }
+ }
+
+ __set_note(note1, note2) {
+ note1 = note1 || '';
+ note2 = note2 || '';
+ const display_note1 = this.__svg_element_from_id('CurrentNote');
+ const display_note2 = this.__svg_element_from_id('CurrentNote2');
+ if (display_note1)
+ display_note1.childNodes[0].nodeValue = note1;
+ if (display_note2)
+ display_note2.childNodes[0].nodeValue = note2;
+ }
+
+ __add_unit(unit, power_name, is_dislogged) {
+ const split_unit = unit.split(/ +/);
+ const unit_type = split_unit[0];
+ const loc = split_unit[1];
+ const dislogged_type = is_dislogged ? 'disl' : 'unit';
+ const symbol = unit_type === 'F' ? FLEET : ARMY;
+ const loc_x = offset(this.metadata.coord[loc][dislogged_type][0], -11.5);
+ const loc_y = offset(this.metadata.coord[loc][dislogged_type][1], -10.0);
+ // Helpful link about creating SVG elements: https://stackoverflow.com/a/25949237
+ const node = document.createElementNS("http://www.w3.org/2000/svg", 'use');
+ node.setAttributeNS('http://www.w3.org/1999/xlink', 'href', '#' + this.__hashed_id((is_dislogged ? 'Dislodged' : '') + symbol));
+ node.setAttribute('x', loc_x);
+ node.setAttribute('y', loc_y);
+ node.setAttribute('height', this.metadata.symbol_size[symbol][0]);
+ node.setAttribute('width', this.metadata.symbol_size[symbol][1]);
+ node.setAttribute('class', 'unit' + power_name.toLowerCase());
+ node.setAttribute('diplomacyUnit', loc);
+ const parent_node = this.__svg_element_from_id(is_dislogged ? 'DislodgedUnitLayer' : 'UnitLayer');
+ if (parent_node)
+ parent_node.appendChild(node);
+ }
+
+ __add_supply_center(loc, power_name) {
+ const symbol = 'SupplyCenter';
+ const loc_x = offset(this.metadata.coord[loc]['sc'][0], -8.5);
+ const loc_y = offset(this.metadata.coord[loc]['sc'][1], -11.0);
+ const node = document.createElementNS("http://www.w3.org/2000/svg", 'use');
+ node.setAttributeNS('http://www.w3.org/1999/xlink', 'href', '#' + this.__hashed_id(symbol));
+ node.setAttribute('x', loc_x);
+ node.setAttribute('y', loc_y);
+ node.setAttribute('height', this.metadata.symbol_size[symbol][0]);
+ node.setAttribute('width', this.metadata.symbol_size[symbol][1]);
+ node.setAttribute('class', power_name ? ('sc' + power_name.toLowerCase()) : 'scnopower');
+ const parent_node = this.__svg_element_from_id('SupplyCenterLayer');
+ if (parent_node)
+ parent_node.appendChild(node);
+ }
+
+ __set_influence(loc, power_name) {
+ loc = loc.toUpperCase().substr(0, 3);
+ if (!['LAND', 'COAST'].includes(this.mapData.getProvince(loc).type))
+ return;
+ const path = this.__svg_element_from_id('_' + loc.toLowerCase());
+ if (!path || path.nodeName !== 'path') {
+ throw new Error(`Unable to find SVG path for loc ${loc}, got ${path ? path.nodeName : '(nothing)'}`);
+ }
+ path.setAttribute('class', power_name ? power_name.toLowerCase() : 'nopower');
+ }
+
+ issueHoldOrder(loc, power_name) {
+ const polygon_coord = [];
+ const loc_x = offset(this.metadata['coord'][loc]['unit'][0], 8.5);
+ const loc_y = offset(this.metadata['coord'][loc]['unit'][1], 9.5);
+ for (let ofs of [
+ [13.8, -33.3], [33.3, -13.8], [33.3, 13.8], [13.8, 33.3], [-13.8, 33.3],
+ [-33.3, 13.8], [-33.3, -13.8], [-13.8, -33.3]]
+ ) {
+ polygon_coord.push(offset(loc_x, ofs[0]) + ',' + offset(loc_y, ofs[1]));
+ }
+ const g_node = document.createElementNS("http://www.w3.org/2000/svg", 'g');
+ const poly_1 = document.createElementNS("http://www.w3.org/2000/svg", 'polygon');
+ const poly_2 = document.createElementNS("http://www.w3.org/2000/svg", 'polygon');
+ poly_1.setAttribute('stroke-width', '10');
+ poly_1.setAttribute('class', 'varwidthshadow');
+ poly_1.setAttribute('points', polygon_coord.join(' '));
+ poly_2.setAttribute('stroke-width', '6');
+ poly_2.setAttribute('class', 'varwidthorder');
+ poly_2.setAttribute('points', polygon_coord.join(' '));
+ poly_2.setAttribute('stroke', this.metadata['color'][power_name]);
+ g_node.appendChild(poly_1);
+ g_node.appendChild(poly_2);
+ const orderLayer = this.__svg_element_from_id('Layer1');
+ if (!orderLayer)
+ throw new Error(`Unable to find svg order layer.`);
+ orderLayer.appendChild(g_node);
+ }
+
+ issueMoveOrder(src_loc, dest_loc, power_name) {
+ let src_loc_x = 0;
+ let src_loc_y = 0;
+ const phaseType = this.game.getPhaseType();
+ if (phaseType === 'R') {
+ src_loc_x = offset(this.metadata.coord[src_loc]['unit'][0], -2.5);
+ src_loc_y = offset(this.metadata.coord[src_loc]['unit'][1], -2.5);
+ } else {
+ src_loc_x = offset(this.metadata.coord[src_loc]['unit'][0], 10);
+ src_loc_y = offset(this.metadata.coord[src_loc]['unit'][1], 10);
+ }
+ let dest_loc_x = offset(this.metadata.coord[dest_loc]['unit'][0], 10);
+ let dest_loc_y = offset(this.metadata.coord[dest_loc]['unit'][1], 10);
+
+ // Adjusting destination
+ const delta_x = parseFloat(dest_loc_x) - parseFloat(src_loc_x);
+ const delta_y = parseFloat(dest_loc_y) - parseFloat(src_loc_y);
+ const vector_length = Math.sqrt(delta_x * delta_x + delta_y * delta_y);
+ dest_loc_x = '' + Math.round((parseFloat(src_loc_x) + (vector_length - 30.) / vector_length * delta_x) * 100.) / 100.;
+ dest_loc_y = '' + Math.round((parseFloat(src_loc_y) + (vector_length - 30.) / vector_length * delta_y) * 100.) / 100.;
+
+ // Creating nodes.
+ const g_node = document.createElementNS("http://www.w3.org/2000/svg", 'g');
+ const line_with_shadow = document.createElementNS("http://www.w3.org/2000/svg", 'line');
+ const line_with_arrow = document.createElementNS("http://www.w3.org/2000/svg", 'line');
+ line_with_shadow.setAttribute('x1', src_loc_x);
+ line_with_shadow.setAttribute('y1', src_loc_y);
+ line_with_shadow.setAttribute('x2', dest_loc_x);
+ line_with_shadow.setAttribute('y2', dest_loc_y);
+ line_with_shadow.setAttribute('class', 'varwidthshadow');
+ line_with_shadow.setAttribute('stroke-width', '10');
+ line_with_arrow.setAttribute('x1', src_loc_x);
+ line_with_arrow.setAttribute('y1', src_loc_y);
+ line_with_arrow.setAttribute('x2', dest_loc_x);
+ line_with_arrow.setAttribute('y2', dest_loc_y);
+ line_with_arrow.setAttribute('class', 'varwidthorder');
+ line_with_arrow.setAttribute('marker-end', 'url(#' + this.__hashed_id('arrow') + ')');
+ line_with_arrow.setAttribute('stroke', this.metadata.color[power_name]);
+ line_with_arrow.setAttribute('stroke-width', '6');
+ g_node.appendChild(line_with_shadow);
+ g_node.appendChild(line_with_arrow);
+ const orderLayer = this.__svg_element_from_id('Layer1');
+ if (!orderLayer)
+ throw new Error(`Unable to find svg order layer.`);
+ orderLayer.appendChild(g_node);
+ }
+
+ issueSupportMoveOrder(loc, src_loc, dest_loc, power_name) {
+ const loc_x = offset(this.metadata['coord'][loc]['unit'][0], 10);
+ const loc_y = offset(this.metadata['coord'][loc]['unit'][1], 10);
+ const src_loc_x = offset(this.metadata['coord'][src_loc]['unit'][0], 10);
+ const src_loc_y = offset(this.metadata['coord'][src_loc]['unit'][1], 10);
+ let dest_loc_x = offset(this.metadata['coord'][dest_loc]['unit'][0], 10);
+ let dest_loc_y = offset(this.metadata['coord'][dest_loc]['unit'][1], 10);
+
+ // Adjusting destination
+ const delta_x = parseFloat(dest_loc_x) - parseFloat(src_loc_x);
+ const delta_y = parseFloat(dest_loc_y) - parseFloat(src_loc_y);
+ const vector_length = Math.sqrt(delta_x * delta_x + delta_y * delta_y);
+ dest_loc_x = '' + Math.round((parseFloat(src_loc_x) + (vector_length - 30.) / vector_length * delta_x) * 100.) / 100.;
+ dest_loc_y = '' + Math.round((parseFloat(src_loc_y) + (vector_length - 30.) / vector_length * delta_y) * 100.) / 100.;
+
+ const g_node = document.createElementNS("http://www.w3.org/2000/svg", 'g');
+ const path_with_shadow = document.createElementNS("http://www.w3.org/2000/svg", 'path');
+ const path_with_arrow = document.createElementNS("http://www.w3.org/2000/svg", 'path');
+ path_with_shadow.setAttribute('class', 'shadowdash');
+ path_with_shadow.setAttribute('d', `M ${loc_x},${loc_y} C ${src_loc_x},${src_loc_y} ${src_loc_x},${src_loc_y} ${dest_loc_x},${dest_loc_y}`);
+ path_with_arrow.setAttribute('class', 'supportorder');
+ path_with_arrow.setAttribute('marker-end', 'url(#' + this.__hashed_id('arrow') + ')');
+ path_with_arrow.setAttribute('stroke', this.metadata['color'][power_name]);
+ path_with_arrow.setAttribute('d', `M ${loc_x},${loc_y} C ${src_loc_x},${src_loc_y} ${src_loc_x},${src_loc_y} ${dest_loc_x},${dest_loc_y}`);
+ g_node.appendChild(path_with_shadow);
+ g_node.appendChild(path_with_arrow);
+ const orderLayer = this.__svg_element_from_id('Layer2');
+ if (!orderLayer)
+ throw new Error(`Unable to find svg order layer.`);
+ orderLayer.appendChild(g_node);
+ }
+
+ issueSupportHoldOrder(loc, dest_loc, power_name) {
+ const loc_x = offset(this.metadata['coord'][loc]['unit'][0], 10);
+ const loc_y = offset(this.metadata['coord'][loc]['unit'][1], 10);
+ let dest_loc_x = offset(this.metadata['coord'][dest_loc]['unit'][0], 10);
+ let dest_loc_y = offset(this.metadata['coord'][dest_loc]['unit'][1], 10);
+
+ const delta_x = parseFloat(dest_loc_x) - parseFloat(loc_x);
+ const delta_y = parseFloat(dest_loc_y) - parseFloat(loc_y);
+ const vector_length = Math.sqrt(delta_x * delta_x + delta_y * delta_y);
+ dest_loc_x = '' + Math.round((parseFloat(loc_x) + (vector_length - 35.) / vector_length * delta_x) * 100.) / 100.;
+ dest_loc_y = '' + Math.round((parseFloat(loc_y) + (vector_length - 35.) / vector_length * delta_y) * 100.) / 100.;
+
+ const polygon_coord = [];
+ const poly_loc_x = offset(this.metadata['coord'][dest_loc]['unit'][0], 8.5);
+ const poly_loc_y = offset(this.metadata['coord'][dest_loc]['unit'][1], 9.5);
+ for (let ofs of [
+ [15.9, -38.3], [38.3, -15.9], [38.3, 15.9], [15.9, 38.3], [-15.9, 38.3], [-38.3, 15.9],
+ [-38.3, -15.9], [-15.9, -38.3]
+ ]) {
+ polygon_coord.push(offset(poly_loc_x, ofs[0]) + ',' + offset(poly_loc_y, ofs[1]));
+ }
+ const g_node = document.createElementNS("http://www.w3.org/2000/svg", 'g');
+ const shadow_line = document.createElementNS("http://www.w3.org/2000/svg", 'line');
+ const support_line = document.createElementNS("http://www.w3.org/2000/svg", 'line');
+ const shadow_poly = document.createElementNS("http://www.w3.org/2000/svg", 'polygon');
+ const support_poly = document.createElementNS("http://www.w3.org/2000/svg", 'polygon');
+ shadow_line.setAttribute('x1', loc_x);
+ shadow_line.setAttribute('y1', loc_y);
+ shadow_line.setAttribute('x2', dest_loc_x);
+ shadow_line.setAttribute('y2', dest_loc_y);
+ shadow_line.setAttribute('class', 'shadowdash');
+ support_line.setAttribute('x1', loc_x);
+ support_line.setAttribute('y1', loc_y);
+ support_line.setAttribute('x2', dest_loc_x);
+ support_line.setAttribute('y2', dest_loc_y);
+ support_line.setAttribute('class', 'supportorder');
+ support_line.setAttribute('stroke', this.metadata['color'][power_name]);
+ shadow_poly.setAttribute('class', 'shadowdash');
+ shadow_poly.setAttribute('points', polygon_coord.join(' '));
+ support_poly.setAttribute('class', 'supportorder');
+ support_poly.setAttribute('points', polygon_coord.join(' '));
+ support_poly.setAttribute('stroke', this.metadata['color'][power_name]);
+ g_node.appendChild(shadow_line);
+ g_node.appendChild(support_line);
+ g_node.appendChild(shadow_poly);
+ g_node.appendChild(support_poly);
+ const orderLayer = this.__svg_element_from_id('Layer2');
+ if (!orderLayer)
+ throw new Error(`Unable to find svg order layer.`);
+ orderLayer.appendChild(g_node);
+ }
+
+ issueConvoyOrder(loc, src_loc, dest_loc, power_name) {
+ const loc_x = offset(this.metadata['coord'][loc]['unit'][0], 10);
+ const loc_y = offset(this.metadata['coord'][loc]['unit'][1], 10);
+ const src_loc_x = offset(this.metadata['coord'][src_loc]['unit'][0], 10);
+ const src_loc_y = offset(this.metadata['coord'][src_loc]['unit'][1], 10);
+ let dest_loc_x = offset(this.metadata['coord'][dest_loc]['unit'][0], 10);
+ let dest_loc_y = offset(this.metadata['coord'][dest_loc]['unit'][1], 10);
+
+ const src_delta_x = parseFloat(src_loc_x) - parseFloat(loc_x);
+ const src_delta_y = parseFloat(src_loc_y) - parseFloat(loc_y);
+ const src_vector_length = Math.sqrt(src_delta_x * src_delta_x + src_delta_y * src_delta_y);
+ const src_loc_x_1 = '' + Math.round((parseFloat(loc_x) + (src_vector_length - 30.) / src_vector_length * src_delta_x) * 100.) / 100.;
+ const src_loc_y_1 = '' + Math.round((parseFloat(loc_y) + (src_vector_length - 30.) / src_vector_length * src_delta_y) * 100.) / 100.;
+
+ let dest_delta_x = parseFloat(src_loc_x) - parseFloat(dest_loc_x);
+ let dest_delta_y = parseFloat(src_loc_y) - parseFloat(dest_loc_y);
+ let dest_vector_length = Math.sqrt(dest_delta_x * dest_delta_x + dest_delta_y * dest_delta_y);
+ const src_loc_x_2 = '' + Math.round((parseFloat(dest_loc_x) + (dest_vector_length - 30.) / dest_vector_length * dest_delta_x) * 100.) / 100.;
+ const src_loc_y_2 = '' + Math.round((parseFloat(dest_loc_y) + (dest_vector_length - 30.) / dest_vector_length * dest_delta_y) * 100.) / 100.;
+
+ dest_delta_x = parseFloat(dest_loc_x) - parseFloat(src_loc_x);
+ dest_delta_y = parseFloat(dest_loc_y) - parseFloat(src_loc_y);
+ dest_vector_length = Math.sqrt(dest_delta_x * dest_delta_x + dest_delta_y * dest_delta_y);
+ dest_loc_x = '' + Math.round((parseFloat(src_loc_x) + (dest_vector_length - 30.) / dest_vector_length * dest_delta_x) * 100.) / 100.;
+ dest_loc_y = '' + Math.round((parseFloat(src_loc_y) + (dest_vector_length - 30.) / dest_vector_length * dest_delta_y) * 100.) / 100.;
+
+ const triangle_coord = [];
+ const triangle_loc_x = offset(this.metadata['coord'][src_loc]['unit'][0], 10);
+ const triangle_loc_y = offset(this.metadata['coord'][src_loc]['unit'][1], 10);
+ for (let ofs of [[0, -38.3], [33.2, 19.1], [-33.2, 19.1]]) {
+ triangle_coord.push(offset(triangle_loc_x, ofs[0]) + ',' + offset(triangle_loc_y, ofs[1]));
+ }
+
+ const g_node = document.createElementNS("http://www.w3.org/2000/svg", 'g');
+ const src_shadow_line = document.createElementNS("http://www.w3.org/2000/svg", 'line');
+ const dest_shadow_line = document.createElementNS("http://www.w3.org/2000/svg", 'line');
+ const src_convoy_line = document.createElementNS("http://www.w3.org/2000/svg", 'line');
+ const dest_convoy_line = document.createElementNS("http://www.w3.org/2000/svg", 'line');
+ const shadow_poly = document.createElementNS("http://www.w3.org/2000/svg", 'polygon');
+ const convoy_poly = document.createElementNS("http://www.w3.org/2000/svg", 'polygon');
+ src_shadow_line.setAttribute('x1', loc_x);
+ src_shadow_line.setAttribute('y1', loc_y);
+ src_shadow_line.setAttribute('x2', src_loc_x_1);
+ src_shadow_line.setAttribute('y2', src_loc_y_1);
+ src_shadow_line.setAttribute('class', 'shadowdash');
+
+ dest_shadow_line.setAttribute('x1', src_loc_x_2);
+ dest_shadow_line.setAttribute('y1', src_loc_y_2);
+ dest_shadow_line.setAttribute('x2', dest_loc_x);
+ dest_shadow_line.setAttribute('y2', dest_loc_y);
+ dest_shadow_line.setAttribute('class', 'shadowdash');
+
+ src_convoy_line.setAttribute('x1', loc_x);
+ src_convoy_line.setAttribute('y1', loc_y);
+ src_convoy_line.setAttribute('x2', src_loc_x_1);
+ src_convoy_line.setAttribute('y2', src_loc_y_1);
+ src_convoy_line.setAttribute('class', 'convoyorder');
+ src_convoy_line.setAttribute('stroke', this.metadata['color'][power_name]);
+
+ dest_convoy_line.setAttribute('x1', src_loc_x_2);
+ dest_convoy_line.setAttribute('y1', src_loc_y_2);
+ dest_convoy_line.setAttribute('x2', dest_loc_x);
+ dest_convoy_line.setAttribute('y2', dest_loc_y);
+ dest_convoy_line.setAttribute('class', 'convoyorder');
+ dest_convoy_line.setAttribute('marker-end', 'url(#' + this.__hashed_id('arrow') + ')');
+
+ dest_convoy_line.setAttribute('stroke', this.metadata['color'][power_name]);
+
+ shadow_poly.setAttribute('class', 'shadowdash');
+ shadow_poly.setAttribute('points', triangle_coord.join(' '));
+
+ convoy_poly.setAttribute('class', 'convoyorder');
+ convoy_poly.setAttribute('points', triangle_coord.join(' '));
+ convoy_poly.setAttribute('stroke', this.metadata['color'][power_name]);
+
+ g_node.appendChild(src_shadow_line);
+ g_node.appendChild(dest_shadow_line);
+ g_node.appendChild(src_convoy_line);
+ g_node.appendChild(dest_convoy_line);
+ g_node.appendChild(shadow_poly);
+ g_node.appendChild(convoy_poly);
+
+ const orderLayer = this.__svg_element_from_id('Layer2');
+ if (!orderLayer)
+ throw new Error(`Unable to find svg order layer.`);
+ orderLayer.appendChild(g_node);
+ }
+
+ issueBuildOrder(unit_type, loc, power_name) {
+ const loc_x = offset(this.metadata['coord'][loc]['unit'][0], -11.5);
+ const loc_y = offset(this.metadata['coord'][loc]['unit'][1], -10.);
+ const build_loc_x = offset(this.metadata['coord'][loc]['unit'][0], -20.5);
+ const build_loc_y = offset(this.metadata['coord'][loc]['unit'][1], -20.5);
+ const symbol = unit_type === 'A' ? ARMY : FLEET;
+ const build_symbol = 'BuildUnit';
+ const g_node = document.createElementNS("http://www.w3.org/2000/svg", 'g');
+ const symbol_node = document.createElementNS("http://www.w3.org/2000/svg", 'use');
+ const build_node = document.createElementNS("http://www.w3.org/2000/svg", 'use');
+ symbol_node.setAttribute('x', loc_x);
+ symbol_node.setAttribute('y', loc_y);
+ symbol_node.setAttribute('height', this.metadata['symbol_size'][symbol][0]);
+ symbol_node.setAttribute('width', this.metadata['symbol_size'][symbol][1]);
+ symbol_node.setAttributeNS('http://www.w3.org/1999/xlink', 'href', '#' + this.__hashed_id(symbol));
+ symbol_node.setAttribute('class', `unit${power_name.toLowerCase()}`);
+ build_node.setAttribute('x', build_loc_x);
+ build_node.setAttribute('y', build_loc_y);
+ build_node.setAttribute('height', this.metadata['symbol_size'][build_symbol][0]);
+ build_node.setAttribute('width', this.metadata['symbol_size'][build_symbol][1]);
+ build_node.setAttributeNS('http://www.w3.org/1999/xlink', 'href', '#' + this.__hashed_id(build_symbol));
+ g_node.appendChild(build_node);
+ g_node.appendChild(symbol_node);
+ const orderLayer = this.__svg_element_from_id('HighestOrderLayer');
+ if (!orderLayer)
+ throw new Error(`Unable to find svg order layer.`);
+ orderLayer.appendChild(g_node);
+ }
+
+ issueDisbandOrder(loc) {
+ const phaseType = this.game.getPhaseType();
+ let loc_x = 0;
+ let loc_y = 0;
+ if (phaseType === 'R') {
+ loc_x = offset(this.metadata['coord'][loc]['unit'][0], -29.);
+ loc_y = offset(this.metadata['coord'][loc]['unit'][1], -27.5);
+ } else {
+ loc_x = offset(this.metadata['coord'][loc]['unit'][0], -16.5);
+ loc_y = offset(this.metadata['coord'][loc]['unit'][1], -15.);
+ }
+ const symbol = 'RemoveUnit';
+ const g_node = document.createElementNS("http://www.w3.org/2000/svg", 'g');
+ const symbol_node = document.createElementNS("http://www.w3.org/2000/svg", 'use');
+ symbol_node.setAttribute('x', loc_x);
+ symbol_node.setAttribute('y', loc_y);
+ symbol_node.setAttribute('height', this.metadata['symbol_size'][symbol][0]);
+ symbol_node.setAttribute('width', this.metadata['symbol_size'][symbol][1]);
+ symbol_node.setAttributeNS('http://www.w3.org/1999/xlink', 'href', '#' + this.__hashed_id(symbol));
+ g_node.appendChild(symbol_node);
+ const orderLayer = this.__svg_element_from_id('HighestOrderLayer');
+ if (!orderLayer)
+ throw new Error(`Unable to find svg order layer.`);
+ orderLayer.appendChild(g_node);
+ }
+
+ clear() {
+ this.__set_note('', '');
+ $(`#${this.__hashed_id('DislodgedUnitLayer')} use`).remove();
+ $(`#${this.__hashed_id('UnitLayer')} use`).remove();
+ $(`#${this.__hashed_id('SupplyCenterLayer')} use`).remove();
+ $(`#${this.__hashed_id('Layer1')} g`).remove();
+ $(`#${this.__hashed_id('Layer2')} g`).remove();
+ $(`#${this.__hashed_id('HighestOrderLayer')} g`).remove();
+ this.__restore_initial_influences();
+ }
+
+ render(includeOrders, orders) {
+ // Setting phase and note.
+ const nb_centers = [];
+ for (let power of Object.values(this.game.powers)) {
+ if (!power.isEliminated())
+ nb_centers.push([power.name.substr(0, 3), power.centers.length]);
+ }
+ // Sort nb_centers by descending number of centers.
+ nb_centers.sort((a, b) => {
+ return -(a[1] - b[1]) || a[0].localeCompare(b[0]);
+ });
+ const nb_centers_per_power = nb_centers.map((couple) => (couple[0] + ': ' + couple[1])).join(' ');
+ this.__set_current_phase();
+ this.__set_note(nb_centers_per_power, this.game.note);
+
+ // Adding units, supply centers, influence and orders.
+ const scs = new Set(this.mapData.supplyCenters);
+ for (let power of Object.values(this.game.powers)) {
+ for (let unit of power.units)
+ this.__add_unit(unit, power.name, false);
+ for (let unit of Object.keys(power.retreats))
+ this.__add_unit(unit, power.name, true);
+ for (let center of power.centers) {
+ this.__add_supply_center(center, power.name);
+ this.__set_influence(center, power.name);
+ scs.delete(center);
+ }
+ if (!power.isEliminated()) {
+ for (let loc of power.influence) {
+ if (!this.mapData.supplyCenters.has(loc))
+ this.__set_influence(loc, power.name);
+ }
+ }
+
+ if (includeOrders) {
+ const powerOrders = (orders && orders.hasOwnProperty(power.name) && orders[power.name]) || [];
+ for (let order of powerOrders) {
+ const tokens = order.split(/ +/);
+ if (!tokens || tokens.length < 3)
+ continue;
+ const unit_loc = tokens[1];
+ if (tokens[2] === 'H')
+ this.issueHoldOrder(unit_loc, power.name);
+ else if (tokens[2] === '-') {
+ const destLoc = tokens[tokens.length - (tokens[tokens.length - 1] === 'VIA' ? 2 : 1)];
+ this.issueMoveOrder(unit_loc, destLoc, power.name);
+ } else if (tokens[2] === 'S') {
+ const destLoc = tokens[tokens.length - 1];
+ if (tokens.includes('-')) {
+ const srcLoc = tokens[4];
+ this.issueSupportMoveOrder(unit_loc, srcLoc, destLoc, power.name);
+ } else {
+ this.issueSupportHoldOrder(unit_loc, destLoc, power.name);
+ }
+ } else if (tokens[2] === 'C') {
+ const srcLoc = tokens[4];
+ const destLoc = tokens[tokens.length - 1];
+ if ((srcLoc !== destLoc) && (tokens.includes('-'))) {
+ this.issueConvoyOrder(unit_loc, srcLoc, destLoc, power.name);
+ }
+ } else if (tokens[2] === 'B') {
+ this.issueBuildOrder(tokens[0], unit_loc, power.name);
+ } else if (tokens[2] === 'D') {
+ this.issueDisbandOrder(unit_loc);
+ } else if (tokens[2] === 'R') {
+ const srcLoc = tokens[1];
+ const destLoc = tokens[3];
+ this.issueMoveOrder(srcLoc, destLoc, power.name);
+ } else {
+ throw new Error(`Unknown error to render (${order}).`);
+ }
+ }
+ }
+ }
+ // Adding remaining supply centers.
+ for (let remainingCenter of scs)
+ this.__add_supply_center(remainingCenter, null);
+ }
+
+ update(game, mapData, showOrders, orders) {
+ this.game = game;
+ this.mapData = mapData;
+ this.clear();
+ this.render(showOrders, orders);
+ }
+}
diff --git a/diplomacy/web/src/gui/diplomacy/utils/dipStorage.jsx b/diplomacy/web/src/gui/diplomacy/utils/dipStorage.jsx
new file mode 100644
index 0000000..db5baad
--- /dev/null
+++ b/diplomacy/web/src/gui/diplomacy/utils/dipStorage.jsx
@@ -0,0 +1,140 @@
+// ==============================================================================
+// 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/>.
+// ==============================================================================
+/* DipStorage scheme:
+global
+- connection
+ - username
+ - hostname
+ - port
+ - showServerFields
+users
+- (username)
+ - games
+ - (game_id)
+ - phase: string
+ - local_orders: {power_name => [orders]}
+*/
+
+let STORAGE = null;
+
+export class DipStorage {
+ static load() {
+ if (!STORAGE) {
+ const global = window.localStorage.global;
+ const users = window.localStorage.users;
+ STORAGE = {
+ global: (global && JSON.parse(global)) || {
+ connection: {
+ username: null,
+ hostname: null,
+ port: null,
+ showServerFields: null
+ }
+ },
+ users: (users && JSON.parse(users)) || {}
+ };
+ }
+ }
+
+ static save() {
+ if (STORAGE) {
+ window.localStorage.global = JSON.stringify(STORAGE.global);
+ window.localStorage.users = JSON.stringify(STORAGE.users);
+ }
+ }
+
+ static getConnectionForm() {
+ DipStorage.load();
+ return Object.assign({}, STORAGE.global.connection);
+ }
+
+ static getUserGames(username) {
+ DipStorage.load();
+ if (STORAGE.users[username])
+ return Object.keys(STORAGE.users[username].games);
+ return null;
+ }
+
+ static getUserGameOrders(username, gameID, gamePhase) {
+ DipStorage.load();
+ if (STORAGE.users[username] && STORAGE.users[username].games[gameID]
+ && STORAGE.users[username].games[gameID].phase === gamePhase)
+ return Object.assign({}, STORAGE.users[username].games[gameID].local_orders);
+ return null;
+ }
+
+ static setConnectionUsername(username) {
+ DipStorage.load();
+ STORAGE.global.connection.username = username;
+ DipStorage.save();
+ }
+
+ static setConnectionHostname(hostname) {
+ DipStorage.load();
+ STORAGE.global.connection.hostname = hostname;
+ DipStorage.save();
+ }
+
+ static setConnectionPort(port) {
+ DipStorage.load();
+ STORAGE.global.connection.port = port;
+ DipStorage.save();
+ }
+
+ static setConnectionshowServerFields(showServerFields) {
+ DipStorage.load();
+ STORAGE.global.connection.showServerFields = showServerFields;
+ DipStorage.save();
+ }
+
+ static addUserGame(username, gameID) {
+ DipStorage.load();
+ if (!STORAGE.users[username])
+ STORAGE.users[username] = {games: {}};
+ if (!STORAGE.users[username].games[gameID])
+ STORAGE.users[username].games[gameID] = {phase: null, local_orders: {}};
+ DipStorage.save();
+ }
+
+ static addUserGameOrders(username, gameID, gamePhase, powerName, orders) {
+ DipStorage.addUserGame(username, gameID);
+ if (STORAGE.users[username].games[gameID].phase !== gamePhase)
+ STORAGE.users[username].games[gameID] = {phase: null, local_orders: {}};
+ STORAGE.users[username].games[gameID].phase = gamePhase;
+ STORAGE.users[username].games[gameID].local_orders[powerName] = orders;
+ DipStorage.save();
+ }
+
+ static removeUserGame(username, gameID) {
+ DipStorage.load();
+ if (STORAGE.users[username] && STORAGE.users[username].games[gameID]) {
+ delete STORAGE.users[username].games[gameID];
+ DipStorage.save();
+ }
+ }
+
+ static clearUserGameOrders(username, gameID, powerName) {
+ DipStorage.addUserGame(username, gameID);
+ if (powerName) {
+ if (STORAGE.users[username].games[gameID].local_orders[powerName])
+ delete STORAGE.users[username].games[gameID].local_orders[powerName];
+ } else {
+ STORAGE.users[username].games[gameID] = {phase: null, local_orders: {}};
+ }
+ DipStorage.save();
+ }
+}
diff --git a/diplomacy/web/src/gui/diplomacy/utils/inline_game_view.jsx b/diplomacy/web/src/gui/diplomacy/utils/inline_game_view.jsx
new file mode 100644
index 0000000..0ada4c9
--- /dev/null
+++ b/diplomacy/web/src/gui/diplomacy/utils/inline_game_view.jsx
@@ -0,0 +1,129 @@
+// ==============================================================================
+// 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 React from "react";
+import {Button} from "../../core/widgets";
+import {JoinForm} from "../forms/join_form";
+import {STRINGS} from "../../../diplomacy/utils/strings";
+
+export class InlineGameView {
+ constructor(page, gameData) {
+ this.page = page;
+ this.game = gameData;
+ this.get = this.get.bind(this);
+ this.joinGame = this.joinGame.bind(this);
+ this.showGame = this.showGame.bind(this);
+ }
+
+ joinGame(formData) {
+ const form = {
+ power_name: formData[`power_name_${this.game.game_id}`],
+ registration_password: formData[`registration_password_${this.game.game_id}`]
+ };
+ if (!form.power_name)
+ form.power_name = null;
+ if (!form.registration_password)
+ form.registration_password = null;
+ form.game_id = this.game.game_id;
+ this.page.channel.joinGame(form)
+ .then((networkGame) => {
+ this.game = networkGame.local;
+ this.page.addToMyGames(this.game);
+ return networkGame.getAllPossibleOrders();
+ })
+ .then(allPossibleOrders => {
+ this.game.setPossibleOrders(allPossibleOrders);
+ this.page.loadGame(this.game, {success: 'Game joined.'});
+ })
+ .catch((error) => {
+ this.page.error('Error when joining game ' + this.game.game_id + ': ' + error);
+ });
+ }
+
+ showGame() {
+ this.page.loadGame(this.game);
+ }
+
+ getJoinUI() {
+ if (this.game.role) {
+ // Game already joined.
+ return (
+ <div className={'games-form'}>
+ <Button key={'button-show-' + this.game.game_id} title={'show'} onClick={this.showGame}/>
+ <Button key={'button-leave-' + this.game.game_id} title={'leave'}
+ onClick={() => this.page.leaveGame(this.game.game_id)}/>
+ </div>
+ );
+ } else {
+ // Game not yet joined.
+ return <JoinForm key={this.game.game_id} game_id={this.game.game_id} powers={this.game.controlled_powers}
+ onSubmit={this.joinGame}/>;
+ }
+ }
+
+ getMyGamesButton() {
+ if (this.page.hasMyGame(this.game.game_id)) {
+ if (!this.game.client) {
+ // Game in My Games and not joined. We can remove it.
+ return <Button key={`my-game-remove`} title={'Remove from My Games'}
+ onClick={() => this.page.removeFromMyGames(this.game.game_id)}/>;
+ }
+ } else {
+ // Game not in My Games, we can add it.
+ return <Button key={`my-game-add`} title={'Add to My Games'}
+ onClick={() => this.page.addToMyGames(this.game)}/>;
+ }
+ return '';
+ }
+
+ get(name) {
+ if (name === 'players') {
+ return `${this.game.n_players} / ${this.game.n_controls}`;
+ }
+ if (name === 'rights') {
+ const elements = [];
+ if (this.game.observer_level) {
+ let levelName = '';
+ if (this.game.observer_level === STRINGS.MASTER_TYPE)
+ levelName = 'master';
+ else if (this.game.observer_level === STRINGS.OMNISCIENT_TYPE)
+ levelName = 'omniscient';
+ else
+ levelName = 'observer';
+ elements.push((<p key={0}><strong>Observer right:</strong><br/>{levelName}</p>));
+ }
+ if (this.game.controlled_powers && this.game.controlled_powers.length) {
+ const powers = this.game.controlled_powers.slice();
+ powers.sort();
+ elements.push((
+ <div key={1}><strong>Currently handled power{powers.length === 1 ? '' : 's'}</strong></div>));
+ for (let power of powers)
+ elements.push((<div key={power}>{power}</div>));
+ }
+ return elements.length ? (<div>{elements}</div>) : '';
+ }
+ if (name === 'rules') {
+ if (this.game.rules)
+ return <div>{this.game.rules.map(rule => <div key={rule}>{rule}</div>)}</div>;
+ return '';
+ }
+ if (name === 'join')
+ return this.getJoinUI();
+ if (name === 'my_games')
+ return this.getMyGamesButton();
+ return this.game[name];
+ }
+}
diff --git a/diplomacy/web/src/gui/diplomacy/utils/map_data.js b/diplomacy/web/src/gui/diplomacy/utils/map_data.js
new file mode 100644
index 0000000..73d5338
--- /dev/null
+++ b/diplomacy/web/src/gui/diplomacy/utils/map_data.js
@@ -0,0 +1,98 @@
+// ==============================================================================
+// 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 {Province} from "./province";
+
+export class MapData {
+ constructor(mapInfo, game) {
+ // mapInfo: {powers: [], supply_centers: [], aliases: {alias: name}, loc_type: {loc => type}, loc_abut: {loc => [abuts]}}
+ // game: a NetworkGame object.
+ this.game = game;
+ this.powers = new Set(mapInfo.powers);
+ this.supplyCenters = new Set(mapInfo.supply_centers);
+ this.aliases = Object.assign({}, mapInfo.aliases);
+ this.provinces = {};
+ for (let entry of Object.entries(mapInfo.loc_type)) {
+ const provinceName = entry[0];
+ const provinceType = entry[1];
+ this.provinces[provinceName] = new Province(provinceName, provinceType, this.supplyCenters.has(provinceName));
+ }
+ for (let entry of Object.entries(mapInfo.loc_abut)) {
+ this.getProvince(entry[0]).setNeighbors(entry[1].map(name => this.getProvince(name)));
+ }
+ for (let province of Object.values(this.provinces)) {
+ province.setCoasts(this.provinces);
+ }
+ for (let power of Object.values(this.game.powers)) {
+ for (let center of power.centers) {
+ this.getProvince(center).setController(power.name, 'C');
+ }
+ for (let loc of power.influence) {
+ this.getProvince(loc).setController(power.name, 'I');
+ }
+ for (let unit of power.units) {
+ this.__add_unit(unit, power.name);
+ }
+ for (let unit of Object.keys(power.retreats)) {
+ this.__add_retreat(unit, power.name);
+ }
+ }
+ for (let entry of Object.entries(this.aliases)) {
+ const alias = entry[0];
+ const provinceName = entry[1];
+ const province = this.getProvince(provinceName);
+ if (province)
+ province.aliases.push(alias);
+ }
+ }
+
+ __add_unit(unit, power_name) {
+ const splitUnit = unit.split(/ +/);
+ const unitType = splitUnit[0];
+ const location = splitUnit[1];
+ const province = this.getProvince(location);
+ province.setController(power_name, 'U');
+ province.unit = unitType;
+ }
+
+ __add_retreat(unit, power_name) {
+ const splitUnit = unit.split(/ +/);
+ const location = splitUnit[1];
+ const province = this.getProvince(location);
+ province.retreatController = power_name;
+ province.retreatUnit = unit;
+ }
+
+ getProvince(abbr) {
+ if (abbr === '')
+ return null;
+ if (abbr[0] === '_')
+ abbr = abbr.substr(1, 3);
+ if (!abbr)
+ return null;
+ if (!this.provinces.hasOwnProperty(abbr)) {
+ const firstLetter = abbr[0];
+ if (firstLetter === firstLetter.toLowerCase()) {
+ abbr = abbr.toUpperCase();
+ } else {
+ abbr = abbr.toLowerCase();
+ }
+ }
+ if (!this.provinces.hasOwnProperty(abbr))
+ abbr = this.aliases[abbr];
+ return this.provinces[abbr];
+ }
+}
diff --git a/diplomacy/web/src/gui/diplomacy/utils/order.js b/diplomacy/web/src/gui/diplomacy/utils/order.js
new file mode 100644
index 0000000..e314b9f
--- /dev/null
+++ b/diplomacy/web/src/gui/diplomacy/utils/order.js
@@ -0,0 +1,24 @@
+// ==============================================================================
+// 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/>.
+// ==============================================================================
+export class Order {
+ constructor(orderString, isLocal) {
+ const pieces = orderString.split(/ +/);
+ this.loc = pieces[1];
+ this.order = orderString;
+ this.local = Boolean(isLocal);
+ }
+}
diff --git a/diplomacy/web/src/gui/diplomacy/utils/order_building.js b/diplomacy/web/src/gui/diplomacy/utils/order_building.js
new file mode 100644
index 0000000..3758898
--- /dev/null
+++ b/diplomacy/web/src/gui/diplomacy/utils/order_building.js
@@ -0,0 +1,211 @@
+// ==============================================================================
+// 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" }]*/
+
+function assertLength(expected, given) {
+ if (expected !== given)
+ throw new Error(`Length error: expected ${expected}, given ${given}.`);
+}
+
+export class ProvinceCheck {
+
+ static retreated(province, powerName) {
+ const retreatProvince = province.getRetreated(powerName);
+ if (!retreatProvince)
+ throw new Error(`No retreated location at province ${province.name}.`);
+ // No confusion possible, we select the only occupied location at this province.
+ return [retreatProvince.retreatUnit];
+ }
+
+ static present(province, powerName) {
+ let unit = null;
+ let presenceProvince = province.getOccupied(powerName);
+ if (presenceProvince) {
+ unit = `${presenceProvince.unit} ${presenceProvince.name}`;
+ } else {
+ presenceProvince = province.getRetreated(powerName);
+ if (!presenceProvince)
+ throw new Error(`No unit or retreat at province ${province.name}.`);
+ unit = presenceProvince.retreatUnit;
+ }
+ return [unit];
+ }
+
+ static occupied(province, powerName) {
+ const occupiedProvince = province.getOccupied(powerName);
+ if (!occupiedProvince)
+ throw new Error(`No occupied location at province ${province.name}.`);
+ // No confusion possible, we select the only occupied location at this province.
+ const unit = occupiedProvince.unit;
+ const name = occupiedProvince.name.toUpperCase();
+ return [`${unit} ${name}`];
+ }
+
+ static occupiedByAny(province, unusedPowerName) {
+ return ProvinceCheck.occupied(province, null);
+ }
+
+ static any(province, unusedPowerName) {
+ // There may be many locations available for a province (e.g. many coasts).
+ return province.getLocationNames();
+ }
+
+ static buildOrder(path) {
+ switch (path[0]) {
+ case 'H':
+ return ProvinceCheck.holdToString(path);
+ case 'M':
+ return ProvinceCheck.moveToString(path);
+ case 'V':
+ return ProvinceCheck.moveViaToString(path);
+ case 'S':
+ return ProvinceCheck.supportToString(path);
+ case 'C':
+ return ProvinceCheck.convoyToString(path);
+ case 'R':
+ return ProvinceCheck.retreatToString(path);
+ case 'D':
+ return ProvinceCheck.disbandToString(path);
+ case 'A':
+ return ProvinceCheck.buildArmyToString(path);
+ case 'F':
+ return ProvinceCheck.buildFleetToString(path);
+ default:
+ throw new Error('Unable to build order from path ' + JSON.stringify(path));
+ }
+ }
+
+ static holdToString(path) {
+ assertLength(2, path.length);
+ return `${path[1]} ${path[0]}`;
+ }
+
+ static moveToString(path) {
+ assertLength(3, path.length);
+ return `${path[1]} - ${path[2]}`;
+ }
+
+ static moveViaToString(path) {
+ return ProvinceCheck.moveToString(path) + ' VIA';
+ }
+
+ static supportToString(path) {
+ assertLength(4, path.length);
+ let order = `${path[1]} ${path[0]} ${path[2]}`;
+ if (path[2].substr(2) !== path[3])
+ order += ` - ${path[3]}`;
+ return order;
+ }
+
+ static convoyToString(path) {
+ assertLength(4, path.length);
+ return `${path[1]} ${path[0]} ${path[2]} - ${path[3]}`;
+ }
+
+ static retreatToString(path) {
+ assertLength(3, path.length);
+ return `${path[1]} ${path[0]} ${path[2]}`;
+ }
+
+ static disbandToString(path) {
+ assertLength(2, path.length);
+ return `${path[1]} ${path[0]}`;
+ }
+
+ static buildArmyToString(path) {
+ assertLength(2, path.length);
+ return `${path[0]} ${path[1]} B`;
+ }
+
+ static buildFleetToString(path) {
+ assertLength(2, path.length);
+ return `${path[0]} ${path[1]} B`;
+ }
+
+}
+
+export const ORDER_BUILDER = {
+ H: {
+ name: 'hold (H)',
+ steps: [ProvinceCheck.occupied]
+ },
+ M: {
+ name: 'move (M)',
+ steps: [ProvinceCheck.occupied, ProvinceCheck.any]
+ },
+ V: {
+ name: 'move VIA (V)',
+ steps: [ProvinceCheck.occupied, ProvinceCheck.any]
+ },
+ S: {
+ name: 'support (S)',
+ steps: [ProvinceCheck.occupied, ProvinceCheck.occupiedByAny, ProvinceCheck.any]
+ },
+ C: {
+ name: 'convoy (C)',
+ steps: [ProvinceCheck.occupied, ProvinceCheck.occupiedByAny, ProvinceCheck.any]
+ },
+ R: {
+ name: 'retreat (R)',
+ steps: [ProvinceCheck.retreated, ProvinceCheck.any]
+ },
+ D: {
+ name: 'disband (D)',
+ steps: [ProvinceCheck.present]
+ },
+ A: {
+ name: 'build army (A)',
+ steps: [ProvinceCheck.any]
+ },
+ F: {
+ name: 'build fleet (F)',
+ steps: [ProvinceCheck.any]
+ },
+};
+
+export const POSSIBLE_ORDERS = {
+ // Allowed orders for movement phase step.
+ M: ['H', 'M', 'V', 'S', 'C'],
+ // Allowed orders for retreat phase step.
+ R: ['R', 'D'],
+ // Allowed orders for adjustment phase step.
+ A: ['D', 'A', 'F'],
+ sorting: {
+ M: {M: 0, V: 1, S: 2, C: 3, H: 4},
+ R: {R: 0, D: 1},
+ A: {A: 0, F: 1, D: 2}
+ },
+ sortOrderTypes: function (arr, phaseType) {
+ arr.sort((a, b) => POSSIBLE_ORDERS.sorting[phaseType][a] - POSSIBLE_ORDERS.sorting[phaseType][b]);
+ }
+};
+
+export function extendOrderBuilding(powerName, orderType, currentOrderPath, location, onBuilding, onBuilt, onError) {
+ const selectedPath = [orderType].concat(currentOrderPath, location);
+ if (selectedPath.length - 1 < ORDER_BUILDER[orderType].steps.length) {
+ // Checker OK, update.
+ onBuilding(powerName, selectedPath);
+ } else {
+ try {
+ // Order created.
+ const orderString = ProvinceCheck.buildOrder(selectedPath);
+ onBuilt(powerName, orderString);
+ } catch (error) {
+ onError(error.toString());
+ }
+ }
+}
diff --git a/diplomacy/web/src/gui/diplomacy/utils/power_view.jsx b/diplomacy/web/src/gui/diplomacy/utils/power_view.jsx
new file mode 100644
index 0000000..1796b1b
--- /dev/null
+++ b/diplomacy/web/src/gui/diplomacy/utils/power_view.jsx
@@ -0,0 +1,59 @@
+// ==============================================================================
+// 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 "../../../diplomacy/utils/strings";
+import React from "react";
+
+function getName(power) {
+ if (power.isEliminated())
+ return <em><s>{power.name.toLowerCase()}</s> (eliminated)</em>;
+ return power.name;
+}
+
+function getController(power) {
+ const controller = power.getController();
+ return <span className={controller === STRINGS.DUMMY ? 'dummy' : 'controller'}>{controller}</span>;
+}
+
+function getOrderFlag(power) {
+ const value = ['no', 'empty', 'yes'][power.order_is_set];
+ return <span className={value}>{value}</span>;
+}
+
+function getWaitFlag(power) {
+ return <span className={power.wait ? 'wait' : 'no-wait'}>{power.wait ? 'yes' : 'no'}</span>;
+}
+
+const GETTERS = {
+ name: getName,
+ controller: getController,
+ order_is_set: getOrderFlag,
+ wait: getWaitFlag
+};
+
+export class PowerView {
+ constructor(power) {
+ this.power = power;
+ }
+
+ static wrap(power) {
+ return new PowerView(power);
+ }
+
+ get(key) {
+ return GETTERS[key](this.power);
+ }
+}
diff --git a/diplomacy/web/src/gui/diplomacy/utils/province.js b/diplomacy/web/src/gui/diplomacy/utils/province.js
new file mode 100644
index 0000000..fc48ac7
--- /dev/null
+++ b/diplomacy/web/src/gui/diplomacy/utils/province.js
@@ -0,0 +1,117 @@
+// ==============================================================================
+// 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/>.
+// ==============================================================================
+const ProvinceType = {
+ WATER: 'WATER',
+ COAST: 'COAST',
+ PORT: 'PORT',
+ LAND: 'LAND'
+};
+
+export class Province {
+ constructor(name, type, isSupplyCenter) {
+ this.name = name;
+ this.type = type;
+ this.coasts = {};
+ this.parent = null;
+ this.neighbors = {};
+ this.isSupplyCenter = isSupplyCenter;
+ this.controller = null; // null or power name.
+ this.controlType = null; // null, C (center), I (influence) or U (unit).
+ this.unit = null; // null, A or F
+ this.retreatController = null;
+ this.retreatUnit = null; // null or `{unit type} {loc}`
+ this.aliases = [];
+ }
+
+ compareControlType(a, b) {
+ const controlTypeLevels = {C: 0, I: 1, U: 2};
+ return controlTypeLevels[a] - controlTypeLevels[b];
+ }
+
+ __set_controller(controller, controlType) {
+ this.controller = controller;
+ this.controlType = controlType;
+ for (let coast of Object.values(this.coasts))
+ coast.setController(controller, controlType);
+ }
+
+ setController(controller, controlType) {
+ if (!['C', 'I', 'U'].includes(controlType))
+ throw new Error(`Invalid province control type (${controlType}), expected 'C', 'I' or 'U'.`);
+ if (this.controller) {
+ const controlTypeComparison = this.compareControlType(controlType, this.controlType);
+ if (controlTypeComparison === 0)
+ throw new Error(`Found 2 powers trying to control same province (${this.name}) with same ` +
+ `control type (${controlType} VS ${this.controlType}).`);
+ if (controlTypeComparison > 0)
+ this.__set_controller(controller, controlType);
+ } else
+ this.__set_controller(controller, controlType);
+ }
+
+ setCoasts(provinces) {
+ const name = this.name.toUpperCase();
+ for (let entry of Object.entries(provinces)) {
+ const pieces = entry[0].split(/[^A-Za-z0-9]+/);
+ if (pieces.length > 1 && pieces[0].toUpperCase() === name) {
+ this.coasts[entry[0]] = entry[1];
+ entry[1].parent = this;
+ }
+ }
+ }
+
+ setNeighbors(neighborProvinces) {
+ for (let province of neighborProvinces)
+ this.neighbors[province.name] = province;
+ }
+
+ getLocationNames() {
+ const arr = Object.keys(this.coasts);
+ arr.splice(0, 0, this.name);
+ return arr;
+ }
+
+ getOccupied(powerName) {
+ if (!this.controller)
+ return null;
+ if (powerName && this.controller !== powerName)
+ return null;
+ if (this.unit)
+ return this;
+ for (let coast of Object.values(this.coasts))
+ if (coast.unit)
+ return coast;
+ return null;
+ }
+
+ getRetreated(powerName) {
+ if (this.retreatController === powerName)
+ return this;
+ for (let coast of Object.values(this.coasts))
+ if (coast.retreatController === powerName)
+ return coast;
+ return null;
+ }
+
+ isCoast() {
+ return this.type === ProvinceType.COAST;
+ }
+
+ isWater() {
+ return this.type === ProvinceType.WATER;
+ }
+}
diff --git a/diplomacy/web/src/gui/diplomacy/widgets/message_view.jsx b/diplomacy/web/src/gui/diplomacy/widgets/message_view.jsx
new file mode 100644
index 0000000..045a108
--- /dev/null
+++ b/diplomacy/web/src/gui/diplomacy/widgets/message_view.jsx
@@ -0,0 +1,57 @@
+// ==============================================================================
+// 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 React from "react";
+import {UTILS} from "../../../diplomacy/utils/utils";
+import PropTypes from 'prop-types';
+
+export class MessageView extends React.Component {
+ // message
+ render() {
+ const message = this.props.message;
+ const owner = this.props.owner;
+ const id = this.props.id ? {id: this.props.id} : {};
+ const messagesLines = message.message.replace('\r\n', '\n').replace('\r', '\n').split('\n');
+ let onClick = null;
+ const classNames = ['game-message'];
+ if (owner === message.sender)
+ classNames.push('message-sender');
+ else {
+ classNames.push('message-recipient');
+ if (message.read || this.props.read)
+ classNames.push('message-read');
+ onClick = this.props.onClick ? {onClick: () => this.props.onClick(message)} : {};
+ }
+ return (
+ <div className={'game-message-wrapper'} {...id}>
+ <div className={classNames.join(' ')} {...onClick}>
+ <div className={'message-header'}>
+ {message.sender} {UTILS.html.UNICODE_SMALL_RIGHT_ARROW} {message.recipient}
+ </div>
+ <div className={'message-content'}>{messagesLines.map((line, lineIndex) => <div key={lineIndex}>{line}</div>)}</div>
+ </div>
+ </div>
+ );
+ }
+}
+
+MessageView.propTypes = {
+ message: PropTypes.object,
+ owner: PropTypes.string,
+ onClick: PropTypes.func,
+ id: PropTypes.string,
+ read: PropTypes.bool
+};
diff --git a/diplomacy/web/src/gui/diplomacy/widgets/power_order.jsx b/diplomacy/web/src/gui/diplomacy/widgets/power_order.jsx
new file mode 100644
index 0000000..28a5421
--- /dev/null
+++ b/diplomacy/web/src/gui/diplomacy/widgets/power_order.jsx
@@ -0,0 +1,79 @@
+// ==============================================================================
+// 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 React from "react";
+import {Button} from "../../core/widgets";
+import PropTypes from 'prop-types';
+
+export class PowerOrder extends React.Component {
+ render() {
+ const orderEntries = this.props.orders ? Object.entries(this.props.orders) : null;
+ let display = null;
+ if (orderEntries) {
+ if (orderEntries.length) {
+ orderEntries.sort((a, b) => a[1].order.localeCompare(b[1].order));
+ display = (
+ <div className={'container order-list'}>
+ {orderEntries.map((entry, index) => (
+ <div
+ className={`row order-entry entry-${1 + index % 2} ` + (entry[1].local ? 'local' : 'server')}
+ key={index}>
+ <div className={'col align-self-center order'}>
+ <span className={'order-string'}>{entry[1].order}</span>
+ {entry[1].local ? '' : <span className={'order-mark'}> [S]</span>}
+ </div>
+ <div className={'col remove-button'}>
+ <Button title={'-'} onClick={() => this.props.onRemove(this.props.name, entry[1])}/>
+ </div>
+ </div>
+ ))}
+ </div>
+ );
+ } else if (this.props.serverCount === 0) {
+ display = (<div className={'empty-orders'}>Empty orders set</div>);
+ } else {
+ display = (<div className={'empty-orders'}>Local empty orders set</div>);
+ }
+ } else {
+ if (this.props.serverCount < 0) {
+ display = <div className={'no-orders'}>No orders!</div>;
+ } else if (this.props.serverCount === 0) {
+ display = <div className={'empty-orders'}>Empty orders set</div>;
+ } else {
+ display = (<div className={'empty-orders'}>Local empty orders set</div>);
+ }
+ }
+ return (
+ <div className={'power-orders'}>
+ <div className={'title'}>
+ <span className={'name'}>{this.props.name}</span>
+ <span className={this.props.wait ? 'wait' : 'no-wait'}>
+ {(this.props.wait ? ' ' : ' not') + ' waiting'}
+ </span>
+ </div>
+ {display}
+ </div>
+ );
+ }
+}
+
+PowerOrder.propTypes = {
+ wait: PropTypes.bool,
+ name: PropTypes.string,
+ orders: PropTypes.object,
+ serverCount: PropTypes.number,
+ onRemove: PropTypes.func,
+};
diff --git a/diplomacy/web/src/index.css b/diplomacy/web/src/index.css
new file mode 100644
index 0000000..f33b116
--- /dev/null
+++ b/diplomacy/web/src/index.css
@@ -0,0 +1,401 @@
+/** Bootstrap. **/
+/** Common. **/
+
+a.dropdown-item {
+ cursor: pointer;
+}
+
+.top-msg {
+ border-bottom: 1px solid black;
+ font-weight: bold;
+ text-align: center;
+ position: -webkit-sticky;
+ position: sticky;
+ top: 0;
+ z-index: 1;
+ background-color: white;
+}
+
+.top-msg .msg {
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.top-msg .with-msg {
+ cursor: pointer;
+}
+
+.top-msg .no-msg {
+ text-overflow: initial;
+}
+
+.top-msg .msg + .msg {
+ border-left: 1px solid black;
+}
+
+.top-msg .error {
+ color: red;
+}
+
+.top-msg .info {
+ color: blue;
+}
+
+.top-msg .success {
+ color: green;
+}
+
+#past-map svg, #current-map svg {
+ display: block;
+ width: 100%;
+ height: auto;
+}
+
+.history-current-orders {
+ background-color: red;
+ color: white;
+ font-weight: bold;
+ position: absolute;
+ top: 0;
+ right: 0;
+ border: 4px solid black;
+ margin: 5px 24px;
+ padding: 5px 10px 5px 10px;
+}
+
+table caption {
+ caption-side: top;
+}
+
+main {
+ padding: 10px;
+}
+
+.power-actions-form button + button, .panel-orders .bar button + button, .games-form button + button {
+ margin-left: 0.5rem;
+}
+
+.link-unread-message {
+ display: block;
+ text-align: center;
+ font-weight: bold;
+ text-decoration: underline;
+ margin-bottom: 1rem;
+}
+
+.link-unread-message:hover {
+ text-decoration: none;
+}
+
+.power-name {
+ font-weight: bold;
+ color: red;
+ text-align: center;
+}
+
+#current-power {
+ color: red;
+ font-weight: bold;
+ text-align: center;
+}
+
+.page-messages {
+ position: fixed;
+ bottom: 0;
+ right: 0;
+ z-index: 1;
+ width: 30%;
+}
+
+.page-message {
+ border: 8px solid;
+ padding: 10px;
+ cursor: pointer;
+ font-size: 1.3rem;
+ font-weight: bold;
+}
+
+.page-message.error {
+ border-color: rgb(245, 205, 205);
+ background-color: rgba(255, 215, 215, 0.9);
+ color: rgb(200, 120, 120);
+}
+
+.page-message.info {
+ border-color: rgb(245, 245, 205);
+ background-color: rgba(255, 255, 215, 0.9);
+ color: rgb(200, 200, 120);
+}
+
+.page-message.success {
+ border-color: rgb(205, 245, 205);
+ background-color: rgba(215, 255, 215, 0.9);
+ color: rgb(120, 200, 120);
+}
+
+.order-result {
+ font-weight: bold;
+}
+
+.order-result .bounce {
+ color: red;
+}
+
+.order-result .success {
+ color: green;
+}
+
+.past-orders {
+ font-size: 0.9rem;
+ max-height: 500px;
+ overflow: auto;
+}
+
+.past-orders .row:nth-child(odd) {
+ background-color: rgb(244, 244, 245);
+}
+
+.past-orders .row .past-power-name, .past-orders .row .past-power-orders {
+ padding: 5px;
+}
+
+.past-orders .row .past-power-name {
+ font-weight: bold;
+}
+
+.past-orders .row .past-power-orders > div:before {
+ content: "\2192";
+ margin-right: 1em;
+}
+
+.bar > * {
+ display: inline-block;
+}
+
+.page > .title {
+ border-bottom: 1px solid silver;
+ padding: 10px;
+}
+
+.left {
+ float: left;
+}
+
+.right {
+ float: right;
+}
+
+.action {
+ cursor: pointer;
+}
+
+.action .updated {
+ font-weight: bold;
+}
+
+.action .update {
+ font-size: 0.8em;
+ color: white;
+ background-color: red;
+ display: inline-block;
+ padding: 0 0.4em 0 0.4em;
+ margin-left: 0.4em;
+ border: 1px solid lightcoral;
+}
+
+.orders, .game-messages {
+ max-height: 500px;
+ overflow: auto;
+ border: 1px solid silver;
+ padding: 10px;
+}
+
+.panel-orders > .bar {
+ border-top: 1px solid silver;
+ border-left: 1px solid silver;
+ border-right: 1px solid silver;
+}
+
+.panel-orders .summary {
+ border-left: 1px solid silver;
+ border-right: 1px solid silver;
+}
+
+.panel-orders .summary > span + span:before {
+ content: ', ';
+}
+
+.fancy-wrapper {
+ position: fixed;
+ left: 0;
+ top: 0;
+ width: 100%;
+ height: 100%;
+ padding-top: 10%;
+ background-color: rgba(100, 100, 110, 0.5);
+}
+
+.fancy-box {
+ border: 1px solid silver;
+ position: relative;
+ margin: auto;
+}
+
+.fancy-box .fancy-bar {
+ background-color: rgb(240, 240, 240);
+ border-bottom: 1px solid silver;
+ padding-top: 10px;
+ padding-bottom: 10px;
+}
+
+.fancy-box .fancy-button {
+ text-align: right;
+}
+
+.fancy-box .fancy-content {
+ padding-top: 10px;
+ padding-bottom: 10px;
+ background-color: white;
+}
+
+.power-orders {
+ padding-bottom: 8px;
+}
+
+.power-orders .no-orders, .empty-orders {
+ text-align: center;
+ font-weight: bold;
+ font-style: italic;
+}
+
+.power-orders .no-orders {
+ color: lightgray;
+}
+
+.power-orders .empty-orders {
+ color: gray;
+}
+
+.power-orders .title {
+ background-color: rgb(230, 230, 235);
+ padding: 10px;
+ margin-bottom: 2px;
+}
+
+.power-orders .title .name {
+ font-weight: bold;
+}
+
+.dummy, .neutral {
+ font-style: italic;
+ color: gray;
+}
+
+.controller {
+ font-weight: bold;
+}
+
+.power-orders .title .wait, .wait, .no {
+ font-weight: bold;
+ color: red;
+}
+
+.power-orders .title .no-wait, .no-wait, .yes {
+ font-weight: bold;
+ color: green;
+}
+
+.empty {
+ font-weight: bold;
+ color: orange;
+}
+
+.power-orders .title button {
+ font-family: "Courier New", Courier, monospace;
+}
+
+.power-orders .order-entry {
+ padding-top: 2px;
+ padding-bottom: 2px;
+}
+
+.power-orders .order-entry.server .order-string {
+ text-decoration: underline;
+}
+
+.power-orders .order-entry.server .order-mark {
+ color: red;
+}
+
+.power-orders .entry-2 {
+ background-color: rgb(248, 248, 248);
+}
+
+.power-orders .order {
+ font-weight: bold;
+}
+
+.power-orders .remove-button {
+ text-align: right;
+}
+
+.game-message {
+ padding: 10px;
+ width: 75%;
+ border-width: 4px;
+ border-style: solid;
+ border-radius: 10px;
+}
+
+.game-message .message-header {
+ font-weight: bold;
+}
+
+.game-message.message-recipient {
+ float: left;
+ border-color: rgb(240, 200, 200);
+ background-color: rgb(255, 220, 220);
+ cursor: pointer;
+}
+
+.game-message.message-recipient.message-read {
+ border-color: rgb(200, 240, 200);
+ background-color: rgb(220, 255, 220);
+ cursor: default;
+}
+
+.game-message.message-sender {
+ float: right;
+ border-color: rgb(200, 200, 240);
+ background-color: rgb(220, 220, 255);
+}
+
+.game-message-wrapper {
+ overflow: auto;
+ clear: both;
+}
+
+.game-message-wrapper + .game-message-wrapper {
+ margin-top: 10px;
+}
+
+.button-server {
+ border: 1px solid silver;
+ border-radius: 4px;
+ padding: 5px;
+ cursor: pointer;
+ margin-bottom: 10px;
+}
+
+.button-server:hover {
+ background-color: rgb(245, 245, 245);
+}
+
+.button-server:active {
+ background-color: rgb(230, 230, 230);
+}
+
+/** Page login. **/
+/** Page games. **/
+/** Page game. **/
diff --git a/diplomacy/web/src/index.js b/diplomacy/web/src/index.js
new file mode 100644
index 0000000..d074bce
--- /dev/null
+++ b/diplomacy/web/src/index.js
@@ -0,0 +1,28 @@
+// ==============================================================================
+// 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 React from 'react';
+import ReactDOM from 'react-dom';
+import {Page} from "./gui/core/page";
+import 'popper.js';
+import 'bootstrap/dist/js/bootstrap';
+import 'bootstrap/dist/css/bootstrap.min.css';
+import './index.css';
+
+
+// ========================================
+
+ReactDOM.render(<Page/>, document.getElementById('root'));
diff --git a/diplomacy/web/src/standard.svg b/diplomacy/web/src/standard.svg
new file mode 120000
index 0000000..abb544e
--- /dev/null
+++ b/diplomacy/web/src/standard.svg
@@ -0,0 +1 @@
+../../maps/svg/standard.svg \ No newline at end of file