aboutsummaryrefslogtreecommitdiff
path: root/diplomacy/web/src/gui/utils
diff options
context:
space:
mode:
Diffstat (limited to 'diplomacy/web/src/gui/utils')
-rw-r--r--diplomacy/web/src/gui/utils/dipStorage.jsx140
-rw-r--r--diplomacy/web/src/gui/utils/inline_game_view.jsx158
-rw-r--r--diplomacy/web/src/gui/utils/load_game_from_disk.js83
-rw-r--r--diplomacy/web/src/gui/utils/map_data.js98
-rw-r--r--diplomacy/web/src/gui/utils/order.js24
-rw-r--r--diplomacy/web/src/gui/utils/order_building.js211
-rw-r--r--diplomacy/web/src/gui/utils/power_view.jsx65
-rw-r--r--diplomacy/web/src/gui/utils/province.js117
-rw-r--r--diplomacy/web/src/gui/utils/saveGameToDisk.js18
9 files changed, 914 insertions, 0 deletions
diff --git a/diplomacy/web/src/gui/utils/dipStorage.jsx b/diplomacy/web/src/gui/utils/dipStorage.jsx
new file mode 100644
index 0000000..db5baad
--- /dev/null
+++ b/diplomacy/web/src/gui/utils/dipStorage.jsx
@@ -0,0 +1,140 @@
+// ==============================================================================
+// Copyright (C) 2019 - Philip Paquette, Steven Bocco
+//
+// This program is free software: you can redistribute it and/or modify it under
+// the terms of the GNU Affero General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option) any
+// later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+// FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
+// details.
+//
+// You should have received a copy of the GNU Affero General Public License along
+// with this program. If not, see <https://www.gnu.org/licenses/>.
+// ==============================================================================
+/* DipStorage scheme:
+global
+- connection
+ - username
+ - hostname
+ - port
+ - showServerFields
+users
+- (username)
+ - games
+ - (game_id)
+ - phase: string
+ - local_orders: {power_name => [orders]}
+*/
+
+let STORAGE = null;
+
+export class DipStorage {
+ static load() {
+ if (!STORAGE) {
+ const global = window.localStorage.global;
+ const users = window.localStorage.users;
+ STORAGE = {
+ global: (global && JSON.parse(global)) || {
+ connection: {
+ username: null,
+ hostname: null,
+ port: null,
+ showServerFields: null
+ }
+ },
+ users: (users && JSON.parse(users)) || {}
+ };
+ }
+ }
+
+ static save() {
+ if (STORAGE) {
+ window.localStorage.global = JSON.stringify(STORAGE.global);
+ window.localStorage.users = JSON.stringify(STORAGE.users);
+ }
+ }
+
+ static getConnectionForm() {
+ DipStorage.load();
+ return Object.assign({}, STORAGE.global.connection);
+ }
+
+ static getUserGames(username) {
+ DipStorage.load();
+ if (STORAGE.users[username])
+ return Object.keys(STORAGE.users[username].games);
+ return null;
+ }
+
+ static getUserGameOrders(username, gameID, gamePhase) {
+ DipStorage.load();
+ if (STORAGE.users[username] && STORAGE.users[username].games[gameID]
+ && STORAGE.users[username].games[gameID].phase === gamePhase)
+ return Object.assign({}, STORAGE.users[username].games[gameID].local_orders);
+ return null;
+ }
+
+ static setConnectionUsername(username) {
+ DipStorage.load();
+ STORAGE.global.connection.username = username;
+ DipStorage.save();
+ }
+
+ static setConnectionHostname(hostname) {
+ DipStorage.load();
+ STORAGE.global.connection.hostname = hostname;
+ DipStorage.save();
+ }
+
+ static setConnectionPort(port) {
+ DipStorage.load();
+ STORAGE.global.connection.port = port;
+ DipStorage.save();
+ }
+
+ static setConnectionshowServerFields(showServerFields) {
+ DipStorage.load();
+ STORAGE.global.connection.showServerFields = showServerFields;
+ DipStorage.save();
+ }
+
+ static addUserGame(username, gameID) {
+ DipStorage.load();
+ if (!STORAGE.users[username])
+ STORAGE.users[username] = {games: {}};
+ if (!STORAGE.users[username].games[gameID])
+ STORAGE.users[username].games[gameID] = {phase: null, local_orders: {}};
+ DipStorage.save();
+ }
+
+ static addUserGameOrders(username, gameID, gamePhase, powerName, orders) {
+ DipStorage.addUserGame(username, gameID);
+ if (STORAGE.users[username].games[gameID].phase !== gamePhase)
+ STORAGE.users[username].games[gameID] = {phase: null, local_orders: {}};
+ STORAGE.users[username].games[gameID].phase = gamePhase;
+ STORAGE.users[username].games[gameID].local_orders[powerName] = orders;
+ DipStorage.save();
+ }
+
+ static removeUserGame(username, gameID) {
+ DipStorage.load();
+ if (STORAGE.users[username] && STORAGE.users[username].games[gameID]) {
+ delete STORAGE.users[username].games[gameID];
+ DipStorage.save();
+ }
+ }
+
+ static clearUserGameOrders(username, gameID, powerName) {
+ DipStorage.addUserGame(username, gameID);
+ if (powerName) {
+ if (STORAGE.users[username].games[gameID].local_orders[powerName])
+ delete STORAGE.users[username].games[gameID].local_orders[powerName];
+ } else {
+ STORAGE.users[username].games[gameID] = {phase: null, local_orders: {}};
+ }
+ DipStorage.save();
+ }
+}
diff --git a/diplomacy/web/src/gui/utils/inline_game_view.jsx b/diplomacy/web/src/gui/utils/inline_game_view.jsx
new file mode 100644
index 0000000..ec2ca46
--- /dev/null
+++ b/diplomacy/web/src/gui/utils/inline_game_view.jsx
@@ -0,0 +1,158 @@
+// ==============================================================================
+// Copyright (C) 2019 - Philip Paquette, Steven Bocco
+//
+// This program is free software: you can redistribute it and/or modify it under
+// the terms of the GNU Affero General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option) any
+// later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+// FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
+// details.
+//
+// You should have received a copy of the GNU Affero General Public License along
+// with this program. If not, see <https://www.gnu.org/licenses/>.
+// ==============================================================================
+import React from "react";
+import {JoinForm} from "../forms/join_form";
+import {STRINGS} from "../../diplomacy/utils/strings";
+import {ContentGame} from "../pages/content_game";
+import {Button} from "../components/button";
+import {DeleteButton} from "../components/delete_button";
+
+export class InlineGameView {
+ constructor(page, gameData) {
+ this.page = page;
+ this.game = gameData;
+ this.get = this.get.bind(this);
+ this.joinGame = this.joinGame.bind(this);
+ this.showGame = this.showGame.bind(this);
+ }
+
+ joinGame(formData) {
+ const form = {
+ power_name: formData[`power_name_${this.game.game_id}`],
+ registration_password: formData[`registration_password_${this.game.game_id}`]
+ };
+ if (!form.power_name)
+ form.power_name = null;
+ if (!form.registration_password)
+ form.registration_password = null;
+ form.game_id = this.game.game_id;
+ this.page.channel.joinGame(form)
+ .then((networkGame) => {
+ this.game = networkGame.local;
+ this.page.addToMyGames(this.game);
+ return networkGame.getAllPossibleOrders();
+ })
+ .then(allPossibleOrders => {
+ this.game.setPossibleOrders(allPossibleOrders);
+ this.page.load(
+ `game: ${this.game.game_id}`,
+ <ContentGame data={this.game}/>,
+ {success: 'Game joined.'}
+ );
+ })
+ .catch((error) => {
+ this.page.error('Error when joining game ' + this.game.game_id + ': ' + error);
+ });
+ }
+
+ showGame() {
+ this.page.load(`game: ${this.game.game_id}`, <ContentGame data={this.game}/>);
+ }
+
+ getJoinUI() {
+ if (this.game.role) {
+ // Game already joined.
+ return (
+ <div className={'games-form'}>
+ <Button key={'button-show-' + this.game.game_id} title={'show'} onClick={this.showGame}/>
+ <Button key={'button-leave-' + this.game.game_id} title={'leave'}
+ onClick={() => this.page.leaveGame(this.game.game_id)}/>
+ </div>
+ );
+ } else {
+ // Game not yet joined.
+ return <JoinForm key={this.game.game_id} game_id={this.game.game_id} powers={this.game.controlled_powers}
+ password_required={this.game.registration_password}
+ onSubmit={this.joinGame}/>;
+ }
+ }
+
+ getActionButtons() {
+ const buttons = [];
+ // Button to add/remove game from "My games" list.
+ if (this.page.hasMyGame(this.game.game_id)) {
+ if (!this.game.client) {
+ // Game in My Games and not joined. We can remove it.
+ buttons.push(<Button key={`my-game-remove`} title={'Remove from My Games'}
+ small={true} large={true}
+ onClick={() => this.page.removeFromMyGames(this.game.game_id)}/>);
+ }
+ } else {
+ // Game not in My Games, we can add it.
+ buttons.push(<Button key={`my-game-add`} title={'Add to My Games'}
+ small={true} large={true}
+ onClick={() => this.page.addToMyGames(this.game)}/>);
+ }
+ // Button to delete game.
+ if ([STRINGS.MASTER_TYPE, STRINGS.OMNISCIENT_TYPE].includes(this.game.observer_level)) {
+ buttons.push(
+ <DeleteButton key={`game-delete-${this.game.game_id}`}
+ title={'Delete this game'}
+ confirmTitle={'Click again to confirm deletion'}
+ waitingTitle={'Deleting ...'}
+ onClick={() => this.page.removeGame(this.game.game_id)}/>
+ );
+ }
+ return buttons;
+ }
+
+ get(name) {
+ if (name === 'players') {
+ return `${this.game.n_players} / ${this.game.n_controls}`;
+ }
+ if (name === 'rights') {
+ const elements = [];
+ if (this.game.observer_level) {
+ let levelName = '';
+ if (this.game.observer_level === STRINGS.MASTER_TYPE)
+ levelName = 'master';
+ else if (this.game.observer_level === STRINGS.OMNISCIENT_TYPE)
+ levelName = 'omniscient';
+ else
+ levelName = 'observer';
+ elements.push((<p key={0}><strong>Observer right:</strong><br/>{levelName}</p>));
+ }
+ if (this.game.controlled_powers && this.game.controlled_powers.length) {
+ const powers = this.game.controlled_powers.slice();
+ powers.sort();
+ elements.push((
+ <div key={1}><strong>Currently handled power{powers.length === 1 ? '' : 's'}</strong></div>));
+ for (let power of powers)
+ elements.push((<div key={power}>{power}</div>));
+ }
+ return elements.length ? (<div>{elements}</div>) : '';
+ }
+ if (name === 'rules') {
+ if (this.game.rules)
+ return <div>{this.game.rules.map(rule => <div key={rule}>{rule}</div>)}</div>;
+ return '';
+ }
+ if (name === 'join')
+ return this.getJoinUI();
+ if (name === 'actions')
+ return this.getActionButtons();
+ if (name === 'game_id') {
+ const date = new Date(this.game.timestamp_created / 1000);
+ const dateString = `${date.toLocaleDateString()} - ${date.toLocaleTimeString()}`;
+ return <div>
+ <div><strong>{this.game.game_id}</strong></div>
+ <div>({dateString})</div>
+ </div>;
+ }
+ return this.game[name];
+ }
+}
diff --git a/diplomacy/web/src/gui/utils/load_game_from_disk.js b/diplomacy/web/src/gui/utils/load_game_from_disk.js
new file mode 100644
index 0000000..ca49aa0
--- /dev/null
+++ b/diplomacy/web/src/gui/utils/load_game_from_disk.js
@@ -0,0 +1,83 @@
+import $ from "jquery";
+import {STRINGS} from "../../diplomacy/utils/strings";
+import {Game} from "../../diplomacy/engine/game";
+
+export function loadGameFromDisk(onLoad, onError) {
+ const input = $(document.createElement('input'));
+ input.attr("type", "file");
+ input.trigger('click');
+ input.change(event => {
+ const file = event.target.files[0];
+ if (!file.name.match(/\.json$/i)) {
+ onError(`Invalid JSON filename ${file.name}`);
+ return;
+ }
+ const reader = new FileReader();
+ reader.onload = () => {
+ const savedData = JSON.parse(reader.result);
+ const gameObject = {};
+ gameObject.game_id = `(local) ${savedData.id}`;
+ gameObject.map_name = savedData.map;
+ gameObject.rules = savedData.rules;
+ gameObject.state_history = {};
+ gameObject.message_history = {};
+ gameObject.order_history = {};
+ gameObject.result_history = {};
+
+ // Load all saved phases (expect the latest one) to history fields.
+ for (let i = 0; i < savedData.phases.length - 1; ++i) {
+ const savedPhase = savedData.phases[i];
+ const gameState = savedPhase.state;
+ const phaseOrders = savedPhase.orders || {};
+ const phaseResults = savedPhase.results || {};
+ const phaseMessages = {};
+ if (savedPhase.messages) {
+ for (let message of savedPhase.messages) {
+ phaseMessages[message.time_sent] = message;
+ }
+ }
+ if (!gameState.name)
+ gameState.name = savedPhase.name;
+ gameObject.state_history[gameState.name] = gameState;
+ gameObject.message_history[gameState.name] = phaseMessages;
+ gameObject.order_history[gameState.name] = phaseOrders;
+ gameObject.result_history[gameState.name] = phaseResults;
+ }
+
+ // Load latest phase separately and use it later to define the current game phase.
+ const latestPhase = savedData.phases[savedData.phases.length - 1];
+ const latestGameState = latestPhase.state;
+ const latestPhaseOrders = latestPhase.orders || {};
+ const latestPhaseResults = latestPhase.results || {};
+ const latestPhaseMessages = {};
+ if (latestPhase.messages) {
+ for (let message of latestPhase.messages) {
+ latestPhaseMessages[message.time_sent] = message;
+ }
+ }
+ if (!latestGameState.name)
+ latestGameState.name = latestPhase.name;
+ // TODO: NB: What is latest phase in loaded JSON contains order results? Not sure if it is well handled.
+ gameObject.result_history[latestGameState.name] = latestPhaseResults;
+
+ gameObject.messages = [];
+ gameObject.role = STRINGS.OBSERVER_TYPE;
+ gameObject.status = STRINGS.COMPLETED;
+ gameObject.timestamp_created = 0;
+ gameObject.deadline = 0;
+ gameObject.n_controls = 0;
+ gameObject.registration_password = '';
+ const game = new Game(gameObject);
+
+ // Set game current phase and state using latest phase found in JSON file.
+ game.setPhaseData({
+ name: latestGameState.name,
+ state: latestGameState,
+ orders: latestPhaseOrders,
+ messages: latestPhaseMessages
+ });
+ onLoad(game);
+ };
+ reader.readAsText(file);
+ });
+}
diff --git a/diplomacy/web/src/gui/utils/map_data.js b/diplomacy/web/src/gui/utils/map_data.js
new file mode 100644
index 0000000..73d5338
--- /dev/null
+++ b/diplomacy/web/src/gui/utils/map_data.js
@@ -0,0 +1,98 @@
+// ==============================================================================
+// Copyright (C) 2019 - Philip Paquette, Steven Bocco
+//
+// This program is free software: you can redistribute it and/or modify it under
+// the terms of the GNU Affero General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option) any
+// later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+// FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
+// details.
+//
+// You should have received a copy of the GNU Affero General Public License along
+// with this program. If not, see <https://www.gnu.org/licenses/>.
+// ==============================================================================
+import {Province} from "./province";
+
+export class MapData {
+ constructor(mapInfo, game) {
+ // mapInfo: {powers: [], supply_centers: [], aliases: {alias: name}, loc_type: {loc => type}, loc_abut: {loc => [abuts]}}
+ // game: a NetworkGame object.
+ this.game = game;
+ this.powers = new Set(mapInfo.powers);
+ this.supplyCenters = new Set(mapInfo.supply_centers);
+ this.aliases = Object.assign({}, mapInfo.aliases);
+ this.provinces = {};
+ for (let entry of Object.entries(mapInfo.loc_type)) {
+ const provinceName = entry[0];
+ const provinceType = entry[1];
+ this.provinces[provinceName] = new Province(provinceName, provinceType, this.supplyCenters.has(provinceName));
+ }
+ for (let entry of Object.entries(mapInfo.loc_abut)) {
+ this.getProvince(entry[0]).setNeighbors(entry[1].map(name => this.getProvince(name)));
+ }
+ for (let province of Object.values(this.provinces)) {
+ province.setCoasts(this.provinces);
+ }
+ for (let power of Object.values(this.game.powers)) {
+ for (let center of power.centers) {
+ this.getProvince(center).setController(power.name, 'C');
+ }
+ for (let loc of power.influence) {
+ this.getProvince(loc).setController(power.name, 'I');
+ }
+ for (let unit of power.units) {
+ this.__add_unit(unit, power.name);
+ }
+ for (let unit of Object.keys(power.retreats)) {
+ this.__add_retreat(unit, power.name);
+ }
+ }
+ for (let entry of Object.entries(this.aliases)) {
+ const alias = entry[0];
+ const provinceName = entry[1];
+ const province = this.getProvince(provinceName);
+ if (province)
+ province.aliases.push(alias);
+ }
+ }
+
+ __add_unit(unit, power_name) {
+ const splitUnit = unit.split(/ +/);
+ const unitType = splitUnit[0];
+ const location = splitUnit[1];
+ const province = this.getProvince(location);
+ province.setController(power_name, 'U');
+ province.unit = unitType;
+ }
+
+ __add_retreat(unit, power_name) {
+ const splitUnit = unit.split(/ +/);
+ const location = splitUnit[1];
+ const province = this.getProvince(location);
+ province.retreatController = power_name;
+ province.retreatUnit = unit;
+ }
+
+ getProvince(abbr) {
+ if (abbr === '')
+ return null;
+ if (abbr[0] === '_')
+ abbr = abbr.substr(1, 3);
+ if (!abbr)
+ return null;
+ if (!this.provinces.hasOwnProperty(abbr)) {
+ const firstLetter = abbr[0];
+ if (firstLetter === firstLetter.toLowerCase()) {
+ abbr = abbr.toUpperCase();
+ } else {
+ abbr = abbr.toLowerCase();
+ }
+ }
+ if (!this.provinces.hasOwnProperty(abbr))
+ abbr = this.aliases[abbr];
+ return this.provinces[abbr];
+ }
+}
diff --git a/diplomacy/web/src/gui/utils/order.js b/diplomacy/web/src/gui/utils/order.js
new file mode 100644
index 0000000..e314b9f
--- /dev/null
+++ b/diplomacy/web/src/gui/utils/order.js
@@ -0,0 +1,24 @@
+// ==============================================================================
+// Copyright (C) 2019 - Philip Paquette, Steven Bocco
+//
+// This program is free software: you can redistribute it and/or modify it under
+// the terms of the GNU Affero General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option) any
+// later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+// FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
+// details.
+//
+// You should have received a copy of the GNU Affero General Public License along
+// with this program. If not, see <https://www.gnu.org/licenses/>.
+// ==============================================================================
+export class Order {
+ constructor(orderString, isLocal) {
+ const pieces = orderString.split(/ +/);
+ this.loc = pieces[1];
+ this.order = orderString;
+ this.local = Boolean(isLocal);
+ }
+}
diff --git a/diplomacy/web/src/gui/utils/order_building.js b/diplomacy/web/src/gui/utils/order_building.js
new file mode 100644
index 0000000..3758898
--- /dev/null
+++ b/diplomacy/web/src/gui/utils/order_building.js
@@ -0,0 +1,211 @@
+// ==============================================================================
+// Copyright (C) 2019 - Philip Paquette, Steven Bocco
+//
+// This program is free software: you can redistribute it and/or modify it under
+// the terms of the GNU Affero General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option) any
+// later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+// FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
+// details.
+//
+// You should have received a copy of the GNU Affero General Public License along
+// with this program. If not, see <https://www.gnu.org/licenses/>.
+// ==============================================================================
+/*eslint no-unused-vars: ["error", { "args": "none" }]*/
+
+function assertLength(expected, given) {
+ if (expected !== given)
+ throw new Error(`Length error: expected ${expected}, given ${given}.`);
+}
+
+export class ProvinceCheck {
+
+ static retreated(province, powerName) {
+ const retreatProvince = province.getRetreated(powerName);
+ if (!retreatProvince)
+ throw new Error(`No retreated location at province ${province.name}.`);
+ // No confusion possible, we select the only occupied location at this province.
+ return [retreatProvince.retreatUnit];
+ }
+
+ static present(province, powerName) {
+ let unit = null;
+ let presenceProvince = province.getOccupied(powerName);
+ if (presenceProvince) {
+ unit = `${presenceProvince.unit} ${presenceProvince.name}`;
+ } else {
+ presenceProvince = province.getRetreated(powerName);
+ if (!presenceProvince)
+ throw new Error(`No unit or retreat at province ${province.name}.`);
+ unit = presenceProvince.retreatUnit;
+ }
+ return [unit];
+ }
+
+ static occupied(province, powerName) {
+ const occupiedProvince = province.getOccupied(powerName);
+ if (!occupiedProvince)
+ throw new Error(`No occupied location at province ${province.name}.`);
+ // No confusion possible, we select the only occupied location at this province.
+ const unit = occupiedProvince.unit;
+ const name = occupiedProvince.name.toUpperCase();
+ return [`${unit} ${name}`];
+ }
+
+ static occupiedByAny(province, unusedPowerName) {
+ return ProvinceCheck.occupied(province, null);
+ }
+
+ static any(province, unusedPowerName) {
+ // There may be many locations available for a province (e.g. many coasts).
+ return province.getLocationNames();
+ }
+
+ static buildOrder(path) {
+ switch (path[0]) {
+ case 'H':
+ return ProvinceCheck.holdToString(path);
+ case 'M':
+ return ProvinceCheck.moveToString(path);
+ case 'V':
+ return ProvinceCheck.moveViaToString(path);
+ case 'S':
+ return ProvinceCheck.supportToString(path);
+ case 'C':
+ return ProvinceCheck.convoyToString(path);
+ case 'R':
+ return ProvinceCheck.retreatToString(path);
+ case 'D':
+ return ProvinceCheck.disbandToString(path);
+ case 'A':
+ return ProvinceCheck.buildArmyToString(path);
+ case 'F':
+ return ProvinceCheck.buildFleetToString(path);
+ default:
+ throw new Error('Unable to build order from path ' + JSON.stringify(path));
+ }
+ }
+
+ static holdToString(path) {
+ assertLength(2, path.length);
+ return `${path[1]} ${path[0]}`;
+ }
+
+ static moveToString(path) {
+ assertLength(3, path.length);
+ return `${path[1]} - ${path[2]}`;
+ }
+
+ static moveViaToString(path) {
+ return ProvinceCheck.moveToString(path) + ' VIA';
+ }
+
+ static supportToString(path) {
+ assertLength(4, path.length);
+ let order = `${path[1]} ${path[0]} ${path[2]}`;
+ if (path[2].substr(2) !== path[3])
+ order += ` - ${path[3]}`;
+ return order;
+ }
+
+ static convoyToString(path) {
+ assertLength(4, path.length);
+ return `${path[1]} ${path[0]} ${path[2]} - ${path[3]}`;
+ }
+
+ static retreatToString(path) {
+ assertLength(3, path.length);
+ return `${path[1]} ${path[0]} ${path[2]}`;
+ }
+
+ static disbandToString(path) {
+ assertLength(2, path.length);
+ return `${path[1]} ${path[0]}`;
+ }
+
+ static buildArmyToString(path) {
+ assertLength(2, path.length);
+ return `${path[0]} ${path[1]} B`;
+ }
+
+ static buildFleetToString(path) {
+ assertLength(2, path.length);
+ return `${path[0]} ${path[1]} B`;
+ }
+
+}
+
+export const ORDER_BUILDER = {
+ H: {
+ name: 'hold (H)',
+ steps: [ProvinceCheck.occupied]
+ },
+ M: {
+ name: 'move (M)',
+ steps: [ProvinceCheck.occupied, ProvinceCheck.any]
+ },
+ V: {
+ name: 'move VIA (V)',
+ steps: [ProvinceCheck.occupied, ProvinceCheck.any]
+ },
+ S: {
+ name: 'support (S)',
+ steps: [ProvinceCheck.occupied, ProvinceCheck.occupiedByAny, ProvinceCheck.any]
+ },
+ C: {
+ name: 'convoy (C)',
+ steps: [ProvinceCheck.occupied, ProvinceCheck.occupiedByAny, ProvinceCheck.any]
+ },
+ R: {
+ name: 'retreat (R)',
+ steps: [ProvinceCheck.retreated, ProvinceCheck.any]
+ },
+ D: {
+ name: 'disband (D)',
+ steps: [ProvinceCheck.present]
+ },
+ A: {
+ name: 'build army (A)',
+ steps: [ProvinceCheck.any]
+ },
+ F: {
+ name: 'build fleet (F)',
+ steps: [ProvinceCheck.any]
+ },
+};
+
+export const POSSIBLE_ORDERS = {
+ // Allowed orders for movement phase step.
+ M: ['H', 'M', 'V', 'S', 'C'],
+ // Allowed orders for retreat phase step.
+ R: ['R', 'D'],
+ // Allowed orders for adjustment phase step.
+ A: ['D', 'A', 'F'],
+ sorting: {
+ M: {M: 0, V: 1, S: 2, C: 3, H: 4},
+ R: {R: 0, D: 1},
+ A: {A: 0, F: 1, D: 2}
+ },
+ sortOrderTypes: function (arr, phaseType) {
+ arr.sort((a, b) => POSSIBLE_ORDERS.sorting[phaseType][a] - POSSIBLE_ORDERS.sorting[phaseType][b]);
+ }
+};
+
+export function extendOrderBuilding(powerName, orderType, currentOrderPath, location, onBuilding, onBuilt, onError) {
+ const selectedPath = [orderType].concat(currentOrderPath, location);
+ if (selectedPath.length - 1 < ORDER_BUILDER[orderType].steps.length) {
+ // Checker OK, update.
+ onBuilding(powerName, selectedPath);
+ } else {
+ try {
+ // Order created.
+ const orderString = ProvinceCheck.buildOrder(selectedPath);
+ onBuilt(powerName, orderString);
+ } catch (error) {
+ onError(error.toString());
+ }
+ }
+}
diff --git a/diplomacy/web/src/gui/utils/power_view.jsx b/diplomacy/web/src/gui/utils/power_view.jsx
new file mode 100644
index 0000000..df76da4
--- /dev/null
+++ b/diplomacy/web/src/gui/utils/power_view.jsx
@@ -0,0 +1,65 @@
+// ==============================================================================
+// Copyright (C) 2019 - Philip Paquette, Steven Bocco
+//
+// This program is free software: you can redistribute it and/or modify it under
+// the terms of the GNU Affero General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option) any
+// later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+// FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
+// details.
+//
+// You should have received a copy of the GNU Affero General Public License along
+// with this program. If not, see <https://www.gnu.org/licenses/>.
+// ==============================================================================
+import {STRINGS} from "../../diplomacy/utils/strings";
+import React from "react";
+
+function getName(power) {
+ if (power.isEliminated())
+ return <span className="dummy"><em><s>{power.name.toLowerCase()}</s> (eliminated)</em></span>;
+ return power.name;
+}
+
+function getController(power) {
+ if (power.isEliminated())
+ return <span className="dummy"><em>N/A</em></span>;
+ const controller = power.getController();
+ return <span className={controller === STRINGS.DUMMY ? 'dummy' : 'controller'}>{controller}</span>;
+}
+
+function getOrderFlag(power) {
+ if (power.isEliminated())
+ return <span className="dummy"><em>N/A</em></span>;
+ const value = ['no', 'empty', 'yes'][power.order_is_set];
+ return <span className={value}>{value}</span>;
+}
+
+function getWaitFlag(power) {
+ if (power.isEliminated())
+ return <span className="dummy"><em>N/A</em></span>;
+ return <span className={power.wait ? 'wait' : 'no-wait'}>{power.wait ? 'yes' : 'no'}</span>;
+}
+
+const GETTERS = {
+ name: getName,
+ controller: getController,
+ order_is_set: getOrderFlag,
+ wait: getWaitFlag
+};
+
+export class PowerView {
+ constructor(power) {
+ this.power = power;
+ }
+
+ static wrap(power) {
+ return new PowerView(power);
+ }
+
+ get(key) {
+ return GETTERS[key](this.power);
+ }
+}
diff --git a/diplomacy/web/src/gui/utils/province.js b/diplomacy/web/src/gui/utils/province.js
new file mode 100644
index 0000000..fe54a82
--- /dev/null
+++ b/diplomacy/web/src/gui/utils/province.js
@@ -0,0 +1,117 @@
+// ==============================================================================
+// Copyright (C) 2019 - Philip Paquette, Steven Bocco
+//
+// This program is free software: you can redistribute it and/or modify it under
+// the terms of the GNU Affero General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option) any
+// later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+// FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
+// details.
+//
+// You should have received a copy of the GNU Affero General Public License along
+// with this program. If not, see <https://www.gnu.org/licenses/>.
+// ==============================================================================
+const ProvinceType = {
+ WATER: 'WATER',
+ COAST: 'COAST',
+ PORT: 'PORT',
+ LAND: 'LAND'
+};
+
+export class Province {
+ constructor(name, type, isSupplyCenter) {
+ this.name = name;
+ this.type = type;
+ this.coasts = {};
+ this.parent = null;
+ this.neighbors = {};
+ this.isSupplyCenter = isSupplyCenter;
+ this.controller = null; // null or power name.
+ this.controlType = null; // null, C (center), I (influence) or U (unit).
+ this.unit = null; // null, A or F
+ this.retreatController = null;
+ this.retreatUnit = null; // null or `{unit type} {loc}`
+ this.aliases = [];
+ }
+
+ compareControlType(a, b) {
+ const controlTypeLevels = {C: 0, I: 1, U: 2};
+ return controlTypeLevels[a] - controlTypeLevels[b];
+ }
+
+ __set_controller(controller, controlType) {
+ this.controller = controller;
+ this.controlType = controlType;
+ for (let coast of Object.values(this.coasts))
+ coast.setController(controller, controlType);
+ }
+
+ setController(controller, controlType) {
+ if (!['C', 'I', 'U'].includes(controlType))
+ throw new Error(`Invalid province control type (${controlType}), expected 'C', 'I' or 'U'.`);
+ if (this.controller && this.controller !== controller) {
+ const controlTypeComparison = this.compareControlType(controlType, this.controlType);
+ if (controlTypeComparison === 0)
+ throw new Error(`Found 2 powers (${this.controller}, ${controller}) trying to control same province ` +
+ `(${this.name}) with same control type (${controlType} VS ${this.controlType}).`);
+ if (controlTypeComparison > 0)
+ this.__set_controller(controller, controlType);
+ } else
+ this.__set_controller(controller, controlType);
+ }
+
+ setCoasts(provinces) {
+ const name = this.name.toUpperCase();
+ for (let entry of Object.entries(provinces)) {
+ const pieces = entry[0].split(/[^A-Za-z0-9]+/);
+ if (pieces.length > 1 && pieces[0].toUpperCase() === name) {
+ this.coasts[entry[0]] = entry[1];
+ entry[1].parent = this;
+ }
+ }
+ }
+
+ setNeighbors(neighborProvinces) {
+ for (let province of neighborProvinces)
+ this.neighbors[province.name] = province;
+ }
+
+ getLocationNames() {
+ const arr = Object.keys(this.coasts);
+ arr.splice(0, 0, this.name);
+ return arr;
+ }
+
+ getOccupied(powerName) {
+ if (!this.controller)
+ return null;
+ if (powerName && this.controller !== powerName)
+ return null;
+ if (this.unit)
+ return this;
+ for (let coast of Object.values(this.coasts))
+ if (coast.unit)
+ return coast;
+ return null;
+ }
+
+ getRetreated(powerName) {
+ if (this.retreatController === powerName)
+ return this;
+ for (let coast of Object.values(this.coasts))
+ if (coast.retreatController === powerName)
+ return coast;
+ return null;
+ }
+
+ isCoast() {
+ return this.type === ProvinceType.COAST;
+ }
+
+ isWater() {
+ return this.type === ProvinceType.WATER;
+ }
+}
diff --git a/diplomacy/web/src/gui/utils/saveGameToDisk.js b/diplomacy/web/src/gui/utils/saveGameToDisk.js
new file mode 100644
index 0000000..aae69a4
--- /dev/null
+++ b/diplomacy/web/src/gui/utils/saveGameToDisk.js
@@ -0,0 +1,18 @@
+export function saveGameToDisk(game, onError) {
+ 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 => onError(`Error while saving game: ${exc.toString()}`));
+ } else {
+ onError(`Cannot save this game.`);
+ }
+}