From 6187faf20384b0c5a4966343b2d4ca47f8b11e45 Mon Sep 17 00:00:00 2001 From: Philip Paquette Date: Wed, 26 Sep 2018 07:48:55 -0400 Subject: Release v1.0.0 - Diplomacy Game Engine - AGPL v3+ License --- diplomacy/web/src/diplomacy/client/channel.js | 256 ++++ diplomacy/web/src/diplomacy/client/connection.js | 340 ++++++ .../web/src/diplomacy/client/game_instance_set.js | 76 ++ diplomacy/web/src/diplomacy/client/network_game.js | 297 +++++ .../src/diplomacy/client/notification_managers.js | 127 ++ .../src/diplomacy/client/request_future_context.js | 63 + .../web/src/diplomacy/client/response_managers.js | 118 ++ .../src/diplomacy/communication/notifications.js | 56 + .../web/src/diplomacy/communication/requests.js | 120 ++ .../web/src/diplomacy/communication/responses.js | 42 + diplomacy/web/src/diplomacy/engine/game.js | 507 ++++++++ diplomacy/web/src/diplomacy/engine/message.js | 34 + diplomacy/web/src/diplomacy/engine/power.js | 129 ++ diplomacy/web/src/diplomacy/utils/diplog.js | 45 + diplomacy/web/src/diplomacy/utils/future.js | 55 + diplomacy/web/src/diplomacy/utils/future_event.js | 41 + diplomacy/web/src/diplomacy/utils/sorted_dict.js | 109 ++ diplomacy/web/src/diplomacy/utils/strings.js | 86 ++ diplomacy/web/src/diplomacy/utils/utils.js | 188 +++ diplomacy/web/src/gui/core/content.jsx | 51 + diplomacy/web/src/gui/core/fancybox.jsx | 59 + diplomacy/web/src/gui/core/forms.jsx | 116 ++ diplomacy/web/src/gui/core/layouts.jsx | 55 + diplomacy/web/src/gui/core/page.jsx | 434 +++++++ diplomacy/web/src/gui/core/table.jsx | 112 ++ diplomacy/web/src/gui/core/tabs.jsx | 96 ++ diplomacy/web/src/gui/core/widgets.jsx | 102 ++ .../gui/diplomacy/contents/content_connection.jsx | 91 ++ .../src/gui/diplomacy/contents/content_game.jsx | 1235 ++++++++++++++++++++ .../src/gui/diplomacy/contents/content_games.jsx | 140 +++ .../src/gui/diplomacy/forms/connection_form.jsx | 123 ++ .../web/src/gui/diplomacy/forms/create_form.jsx | 95 ++ .../web/src/gui/diplomacy/forms/find_form.jsx | 70 ++ .../web/src/gui/diplomacy/forms/join_form.jsx | 77 ++ .../web/src/gui/diplomacy/forms/message_form.jsx | 53 + .../src/gui/diplomacy/forms/power_actions_form.jsx | 120 ++ .../gui/diplomacy/forms/select_location_form.jsx | 36 + .../src/gui/diplomacy/forms/select_via_form.jsx | 35 + .../web/src/gui/diplomacy/map/dom_order_builder.js | 278 +++++ .../web/src/gui/diplomacy/map/dom_past_map.js | 112 ++ diplomacy/web/src/gui/diplomacy/map/map.jsx | 94 ++ diplomacy/web/src/gui/diplomacy/map/renderer.js | 615 ++++++++++ .../web/src/gui/diplomacy/utils/dipStorage.jsx | 140 +++ .../src/gui/diplomacy/utils/inline_game_view.jsx | 129 ++ diplomacy/web/src/gui/diplomacy/utils/map_data.js | 98 ++ diplomacy/web/src/gui/diplomacy/utils/order.js | 24 + .../web/src/gui/diplomacy/utils/order_building.js | 211 ++++ .../web/src/gui/diplomacy/utils/power_view.jsx | 59 + diplomacy/web/src/gui/diplomacy/utils/province.js | 117 ++ .../web/src/gui/diplomacy/widgets/message_view.jsx | 57 + .../web/src/gui/diplomacy/widgets/power_order.jsx | 79 ++ diplomacy/web/src/index.css | 401 +++++++ diplomacy/web/src/index.js | 28 + diplomacy/web/src/standard.svg | 1 + 54 files changed, 8232 insertions(+) create mode 100644 diplomacy/web/src/diplomacy/client/channel.js create mode 100644 diplomacy/web/src/diplomacy/client/connection.js create mode 100644 diplomacy/web/src/diplomacy/client/game_instance_set.js create mode 100644 diplomacy/web/src/diplomacy/client/network_game.js create mode 100644 diplomacy/web/src/diplomacy/client/notification_managers.js create mode 100644 diplomacy/web/src/diplomacy/client/request_future_context.js create mode 100644 diplomacy/web/src/diplomacy/client/response_managers.js create mode 100644 diplomacy/web/src/diplomacy/communication/notifications.js create mode 100644 diplomacy/web/src/diplomacy/communication/requests.js create mode 100644 diplomacy/web/src/diplomacy/communication/responses.js create mode 100644 diplomacy/web/src/diplomacy/engine/game.js create mode 100644 diplomacy/web/src/diplomacy/engine/message.js create mode 100644 diplomacy/web/src/diplomacy/engine/power.js create mode 100644 diplomacy/web/src/diplomacy/utils/diplog.js create mode 100644 diplomacy/web/src/diplomacy/utils/future.js create mode 100644 diplomacy/web/src/diplomacy/utils/future_event.js create mode 100644 diplomacy/web/src/diplomacy/utils/sorted_dict.js create mode 100644 diplomacy/web/src/diplomacy/utils/strings.js create mode 100644 diplomacy/web/src/diplomacy/utils/utils.js create mode 100644 diplomacy/web/src/gui/core/content.jsx create mode 100644 diplomacy/web/src/gui/core/fancybox.jsx create mode 100644 diplomacy/web/src/gui/core/forms.jsx create mode 100644 diplomacy/web/src/gui/core/layouts.jsx create mode 100644 diplomacy/web/src/gui/core/page.jsx create mode 100644 diplomacy/web/src/gui/core/table.jsx create mode 100644 diplomacy/web/src/gui/core/tabs.jsx create mode 100644 diplomacy/web/src/gui/core/widgets.jsx create mode 100644 diplomacy/web/src/gui/diplomacy/contents/content_connection.jsx create mode 100644 diplomacy/web/src/gui/diplomacy/contents/content_game.jsx create mode 100644 diplomacy/web/src/gui/diplomacy/contents/content_games.jsx create mode 100644 diplomacy/web/src/gui/diplomacy/forms/connection_form.jsx create mode 100644 diplomacy/web/src/gui/diplomacy/forms/create_form.jsx create mode 100644 diplomacy/web/src/gui/diplomacy/forms/find_form.jsx create mode 100644 diplomacy/web/src/gui/diplomacy/forms/join_form.jsx create mode 100644 diplomacy/web/src/gui/diplomacy/forms/message_form.jsx create mode 100644 diplomacy/web/src/gui/diplomacy/forms/power_actions_form.jsx create mode 100644 diplomacy/web/src/gui/diplomacy/forms/select_location_form.jsx create mode 100644 diplomacy/web/src/gui/diplomacy/forms/select_via_form.jsx create mode 100644 diplomacy/web/src/gui/diplomacy/map/dom_order_builder.js create mode 100644 diplomacy/web/src/gui/diplomacy/map/dom_past_map.js create mode 100644 diplomacy/web/src/gui/diplomacy/map/map.jsx create mode 100644 diplomacy/web/src/gui/diplomacy/map/renderer.js create mode 100644 diplomacy/web/src/gui/diplomacy/utils/dipStorage.jsx create mode 100644 diplomacy/web/src/gui/diplomacy/utils/inline_game_view.jsx create mode 100644 diplomacy/web/src/gui/diplomacy/utils/map_data.js create mode 100644 diplomacy/web/src/gui/diplomacy/utils/order.js create mode 100644 diplomacy/web/src/gui/diplomacy/utils/order_building.js create mode 100644 diplomacy/web/src/gui/diplomacy/utils/power_view.jsx create mode 100644 diplomacy/web/src/gui/diplomacy/utils/province.js create mode 100644 diplomacy/web/src/gui/diplomacy/widgets/message_view.jsx create mode 100644 diplomacy/web/src/gui/diplomacy/widgets/power_order.jsx create mode 100644 diplomacy/web/src/index.css create mode 100644 diplomacy/web/src/index.js create mode 120000 diplomacy/web/src/standard.svg (limited to 'diplomacy/web/src') 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 . +// ============================================================================== +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 . +// ============================================================================== +/*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 . +// ============================================================================== +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 . +// ============================================================================== +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 . +// ============================================================================== +/*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 . +// ============================================================================== +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 . +// ============================================================================== +/*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 . +// ============================================================================== +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 . +// ============================================================================== +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 . +// ============================================================================== +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 . +// ============================================================================== +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 . +// ============================================================================== +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 . +// ============================================================================== +/** 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 . +// ============================================================================== +/*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 . +// ============================================================================== +/** 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 . +// ============================================================================== +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 . +// ============================================================================== +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 . +// ============================================================================== +/** 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 . +// ============================================================================== +/** 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 . +// ============================================================================== +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. , or
...
, 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 . +// ============================================================================== +import React from 'react'; +import {Button} from "./widgets"; +import PropTypes from 'prop-types'; + +const TIMES = '\u00D7'; + +export class FancyBox extends React.Component { + // open-tag () + // PROPERTIES + // title + // onClose + render() { + return ( +
+
{ + if (!event) + event = window.event; + if (event.hasOwnProperty('cancelBubble')) + event.cancelBubble = true; + if (event.stopPropagation) + event.stopPropagation(); + }}> +
+
{this.props.title}
+
+
+
+
+
{this.props.children}
+
+
+
+ ); + } +} + + +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 . +// ============================================================================== +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 +
+ {content.navigation.map((nav, index) => { + const navTitle = nav[0]; + const navAction = nav[1]; + return {navTitle}; + })} +
+ + )} + + + )) || ( +
{title}
+ )} + {content.component} + {this.state.onFancyBox && ( + + {this.state.onFancyBox()} + + )} + + ); + } +} 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 . +// ============================================================================== +//// 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 ( + + {header.map((column, colIndex) => {column[2]})} + + ); + } + + static getBodyRow(header, row, rowIndex, wrapper) { + const wrapped = wrapper(row); + return ( + {header.map((headerColumn, colIndex) => {wrapped.get(headerColumn[1])})} + ); + } + + static getBodyLines(header, data, wrapper) { + return ({data.map((row, rowIndex) => Table.getBodyRow(header, row, rowIndex, wrapper))}); + } + + render() { + const header = Table.getHeader(this.props.columns); + return ( +
+ + + {Table.getHeaderLine(header)} + {Table.getBodyLines(header, this.props.data, this.props.wrapper)} +
{this.props.caption} ({this.props.data.length})
+
+ ); + } +} + +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 . +// ============================================================================== +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 ( +
+ {this.props.children} +
+ ); + } +} + +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 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 ( +
+ + {this.props.children} +
+ ); + } +} + +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 . +// ============================================================================== +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.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 ( +
+
+ {this.props.title} + {this.props.highlight !== null + && this.props.highlight !== undefined + && {this.props.highlight}} +
+
+ ); + } +} + +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 . +// ============================================================================== +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: + }; + } + + 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
; + } +} 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 . +// ============================================================================== +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 ( +
+

When building an order, press ESC to reset build.

+

Press letter associated to an order type to start building an order of this type. +
Order type letter is indicated in order type name after order type radio button. +

+

In Phase History tab, use keyboard left and right arrows to navigate in past phases.

+
+ ); +} + +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', () => )], + ['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: + }; + } + + 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 = () => ( 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 = () => ( + 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(); + 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 ( +
+ {/* Messages. */} + + {tabNames.map(protagonist => ( + + {(!messageChannels.hasOwnProperty(protagonist) || !messageChannels[protagonist].length ? + (
No + messages{engine.isPlayerGame() ? ` with ${protagonist}` : ''}.
) : + messageChannels[protagonist].map((message, index) => ( + + )) + )} +
+ ))} +
+
+ ); + } + + 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 ( +
+ {/* Messages. */} + + {tabNames.map(protagonist => ( + + {(!messageChannels.hasOwnProperty(protagonist) || !messageChannels[protagonist].length ? + (
No + messages{engine.isPlayerGame() ? ` with ${protagonist}` : ''}.
) : + (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 ; + })) + )} +
+ ))} +
+ {/* Link to go to first unread received message. */} + {unreadMarked.has(currentTabId) && ( + + Go to 1st unread message + + )} + {/* Send form. */} + {engine.isPlayerGame() && ( + + this.sendMessage(engine.client, currentTabId, form.message)}/>)} +
+ ); + } + + renderPastMap(gameEngine, showOrders) { + return ; + } + + 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 ; + } + + 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({r || 'OK'}); + } + return ({results}); + } + } + return ''; + }; + + const orderView = [ + (
+