diff options
author | notoraptor <notoraptor@users.noreply.github.com> | 2019-07-17 15:16:43 -0400 |
---|---|---|
committer | Philip Paquette <pcpaquette@gmail.com> | 2019-07-17 15:16:43 -0400 |
commit | 2701df1e3b03c7c605ccf212a02987d53fbd0609 (patch) | |
tree | d3637573d8585e32914c33cbd03ec0baf9c68ae3 /diplomacy/web/src/gui/pages | |
parent | e9872eea32d4f66b9c7ca8c14d530c18f6c18506 (diff) |
[web] Make button "Delete all" remove only orders from current se… (#49)
- Make button "Delete all" remove only orders from current selected power.
- Reorganize code
- [web] Remove bugged and useless function gameReloaded() from game page.
- This function caused error `engine.getPhaseType is not a function` for
games with deadlines.
- Move function saveGameToDisk into its own file.
- [web] Add documentation to methods involved in orders management to help understand what happens.
- Move methods getServerOrders() from game GUI component to game engine object.
- Rename method onSetNoOrders to onSetEmptyOrdersSet.
- Rename property in PowerActionsForm: onNoOrders => onPass.
- [web] Update sending orders to send request clearOrders when local orders list is null.
- Renamed local file:
- components/power_order => power_orders
- forms/power_actions_form => power_order_creation_form
- Move power orders buttons bar to a separate file:
- components/power_orders_actions_bar
- [web] Improve messages about local/server defined orders.
Diffstat (limited to 'diplomacy/web/src/gui/pages')
-rw-r--r-- | diplomacy/web/src/gui/pages/content_connection.jsx | 101 | ||||
-rw-r--r-- | diplomacy/web/src/gui/pages/content_game.jsx | 1303 | ||||
-rw-r--r-- | diplomacy/web/src/gui/pages/content_games.jsx | 171 | ||||
-rw-r--r-- | diplomacy/web/src/gui/pages/page.jsx | 375 |
4 files changed, 1950 insertions, 0 deletions
diff --git a/diplomacy/web/src/gui/pages/content_connection.jsx b/diplomacy/web/src/gui/pages/content_connection.jsx new file mode 100644 index 0000000..8cd1d6e --- /dev/null +++ b/diplomacy/web/src/gui/pages/content_connection.jsx @@ -0,0 +1,101 @@ +// ============================================================================== +// 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 {Connection} from "../../diplomacy/client/connection"; +import {ConnectionForm} from "../forms/connection_form"; +import {DipStorage} from "../utils/dipStorage"; +import {Helmet} from "react-helmet"; +import {Navigation} from "../components/navigation"; +import {PageContext} from "../components/page_context"; + +export class ContentConnection extends React.Component { + constructor(props) { + super(props); + this.connection = null; + this.onSubmit = this.onSubmit.bind(this); + } + + onSubmit(data) { + const page = this.context; + 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:'); + this.connection.onReconnectionError = page.onReconnectionError; + // Page is passed as logger object (with methods info(), error(), success()) when connecting. + this.connection.connect(page) + .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) { + page.success('Found ' + gamesInfo.length + ' user games.'); + page.updateMyGames(gamesInfo); + } + page.loadGames({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() { + const title = 'Connection'; + return ( + <main> + <Helmet> + <title>{title} | Diplomacy</title> + </Helmet> + <Navigation title={title}/> + <ConnectionForm onSubmit={this.onSubmit}/> + </main> + ); + } + + componentDidMount() { + window.scrollTo(0, 0); + } +} + +ContentConnection.contextType = PageContext; diff --git a/diplomacy/web/src/gui/pages/content_game.jsx b/diplomacy/web/src/gui/pages/content_game.jsx new file mode 100644 index 0000000..f1bc76c --- /dev/null +++ b/diplomacy/web/src/gui/pages/content_game.jsx @@ -0,0 +1,1303 @@ +// ============================================================================== +// Copyright (C) 2019 - Philip Paquette, Steven Bocco +// +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU Affero General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) any +// later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +// FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +// details. +// +// You should have received a copy of the GNU Affero General Public License along +// with this program. If not, see <https://www.gnu.org/licenses/>. +// ============================================================================== +import React from "react"; +import Scrollchor from 'react-scrollchor'; +import {SelectLocationForm} from "../forms/select_location_form"; +import {SelectViaForm} from "../forms/select_via_form"; +import {Order} from "../utils/order"; +import {Row} from "../components/layouts"; +import {Tabs} from "../components/tabs"; +import {Map} from "../map/map"; +import {extendOrderBuilding, ORDER_BUILDER, POSSIBLE_ORDERS} from "../utils/order_building"; +import {PowerOrderCreationForm} from "../forms/power_order_creation_form"; +import {MessageForm} from "../forms/message_form"; +import {UTILS} from "../../diplomacy/utils/utils"; +import {Message} from "../../diplomacy/engine/message"; +import {PowerOrders} from "../components/power_orders"; +import {MessageView} from "../components/message_view"; +import {STRINGS} from "../../diplomacy/utils/strings"; +import {Diplog} from "../../diplomacy/utils/diplog"; +import {Table} from "../components/table"; +import {PowerView} from "../utils/power_view"; +import {FancyBox} from "../components/fancybox"; +import {DipStorage} from "../utils/dipStorage"; +import Helmet from 'react-helmet'; +import {Navigation} from "../components/navigation"; +import {PageContext} from "../components/page_context"; +import PropTypes from 'prop-types'; +import {Help} from "../components/help"; +import {Tab} from "../components/tab"; +import {Button} from "../components/button"; +import {saveGameToDisk} from "../utils/saveGameToDisk"; +import {Game} from '../../diplomacy/engine/game'; +import {PowerOrdersActionBar} from "../components/power_orders_actions_bar"; + +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] +}; + +export class ContentGame extends React.Component { + + 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, + 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.onRemoveAllCurrentPowerOrders = this.onRemoveAllCurrentPowerOrders.bind(this); + this.onRemoveOrder = this.onRemoveOrder.bind(this); + this.onSelectLocation = this.onSelectLocation.bind(this); + this.onSelectVia = this.onSelectVia.bind(this); + this.onSetEmptyOrdersSet = this.onSetEmptyOrdersSet.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 getServerWaitFlags(engine) { + const wait = {}; + const controllablePowers = engine.getControllablePowers(); + for (let powerName of controllablePowers) { + wait[powerName] = engine.powers[powerName].wait; + } + return wait; + } + + static getOrderBuilding(powerName, orderType, orderPath) { + return { + type: orderType, + path: orderPath, + power: powerName, + builder: orderType && ORDER_BUILDER[orderType] + }; + } + + getPage() { + return this.context; + } + + closeFancyBox() { + this.setState({ + fancy_title: null, + fancy_function: null, + on_fancy_close: null, + orderBuildingPath: [] + }); + } + + // [ Methods used to handle current map. + + setSelectedLocation(location, powerName, orderType, orderPath) { + if (!location) + return; + extendOrderBuilding( + powerName, orderType, orderPath, location, + this.onOrderBuilding, this.onOrderBuilt, this.getPage().error + ); + this.setState({ + fancy_title: null, + fancy_function: null, + on_fancy_close: null + }); + } + + setSelectedVia(moveType, powerName, orderPath, location) { + if (!moveType || !['M', 'V'].includes(moveType)) + return; + extendOrderBuilding( + powerName, moveType, orderPath, location, + this.onOrderBuilding, this.onOrderBuilt, this.getPage().error + ); + this.setState({ + fancy_title: null, + fancy_function: null, + on_fancy_close: null + }); + } + + onSelectLocation(possibleLocations, powerName, orderType, orderPath) { + const title = `Select location to continue building order: ${orderPath.join(' ')} ... (press ESC or close button to cancel building)`; + const func = () => (<SelectLocationForm locations={possibleLocations} + onSelect={(location) => this.setSelectedLocation(location, powerName, orderType, orderPath)}/>); + this.setState({ + fancy_title: title, + fancy_function: func, + on_fancy_close: this.closeFancyBox + }); + } + + onSelectVia(location, powerName, orderPath) { + const title = `Select move type for move order: ${orderPath.join(' ')}`; + const func = () => ( + <SelectViaForm onSelect={(moveType) => this.setSelectedVia(moveType, powerName, orderPath, location)}/>); + this.setState({ + fancy_title: title, + fancy_function: func, + on_fancy_close: this.closeFancyBox + }); + } + + // ] + + getMapInfo() { + return this.getPage().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().load(`game: ${engine.game_id}`, <ContentGame data={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()}`); + }); + } + + // [ Network game notifications. + + /** + * Return True if given network game is the game currently displayed on the interface. + * @param {NetworkGame} networkGame - network game to check + * @returns {boolean} + */ + networkGameIsDisplayed(networkGame) { + return this.getPage().getName() === `game: ${networkGame.local.game_id}`; + } + + notifiedNetworkGame(networkGame, notification) { + if (this.networkGameIsDisplayed(networkGame)) { + const msg = `Game (${networkGame.local.game_id}) received notification ${notification.name}.`; + this.getPage().load( + `game: ${networkGame.local.game_id}`, + <ContentGame data={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.getPage().disconnectGame(networkGame.local.game_id); + if (this.networkGameIsDisplayed(networkGame)) { + const page = this.getPage(); + page.loadGames( + {error: `${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().load( + `game: ${networkGame.local.game_id}`, + <ContentGame data={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().load( + `game: ${networkGame.local.game_id}`, + <ContentGame data={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, tabPastMessages: null, tabCurrentMessages: null}); + } + + 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.getPage(); + networkGame.sendGameMessage({message: message}) + .then(() => { + page.load( + `game: ${engine.game_id}`, + <ContentGame data={engine}/>, + {success: `Message sent: ${JSON.stringify(message)}`} + ); + }) + .catch(error => page.error(error.toString())); + } + + onProcessGame() { + const page = this.getPage(); + this.props.data.client.process() + .then(() => page.success('Game processed.')) + .catch(err => { + page.error(err.toString()); + }); + } + + /** + * Get name of current power selected on the game page. + * @returns {null|string} + */ + getCurrentPowerName() { + const engine = this.props.data; + const controllablePowers = engine.getControllablePowers(); + return this.state.power || (controllablePowers.length && controllablePowers[0]); + } + + __get_wait(engine) { + return this.state.wait ? this.state.wait : ContentGame.getServerWaitFlags(engine); + } + + // [ Methods involved in orders management. + + /** + * Return a dictionary of local orders for given game engine. + * Returned dictionary maps each power name to either: + * - a dictionary of orders, mapping a location to an Order object with boolean flag `local` correctly set + * to determine if that order is a new local order or is a copy of an existing server order for this power. + * - null or empty dictionary, if there are no local orders defined for this power. + * @param {Game} engine - game engine from which we must get local orders + * @returns {{}} + * @private + */ + __get_orders(engine) { + const orders = engine.getServerOrders(); + 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; + } + + /** + * Save given orders into local storage. + * @param orders - orders to save + * @private + */ + __store_orders(orders) { + 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); + } + } + + /** + * Reset local orders and replace them with current server orders. + */ + reloadServerOrders() { + // TODO: This method should reset orders to server version only for current powers, not for all powers as she currently does. + const serverOrders = this.props.data.getServerOrders(); + this.__store_orders(serverOrders); + this.setState({orders: serverOrders}); + } + + /** + * Remove given order from local orders of given power name. + * @param {string} powerName - power name + * @param {Order} order - order to remove + */ + 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}); + } + } + + /** + * Remove all local orders for current selected power + */ + onRemoveAllCurrentPowerOrders() { + const currentPowerName = this.getCurrentPowerName(); + if (currentPowerName) { + const engine = this.props.data; + const allOrders = this.__get_orders(engine); + if (!allOrders.hasOwnProperty(currentPowerName)) { + this.getPage().error(`Unknown power ${currentPowerName}.`); + return; + } + allOrders[currentPowerName] = null; + this.__store_orders(allOrders); + this.setState({orders: allOrders}); + } + } + + /** + * Set an empty local orders set for given power name. + * @param {string} powerName - power name + */ + onSetEmptyOrdersSet(powerName) { + const orders = this.__get_orders(this.props.data); + orders[powerName] = {}; + this.__store_orders(orders); + this.setState({orders: orders}); + } + + /** + * Send local orders to server. + */ + setOrders() { + const serverOrders = this.props.data.getServerOrders(); + 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. + same = localPowerOrders === null; + // Otherwise, we have local orders set (even empty local orders). + } else if (serverPowerOrders.length === 0) { + // Empty orders set on server. + // If we have empty orders set locally, then it's same thing. + same = localPowerOrders && localPowerOrders.length === 0; + // Otherwise, we have either local non-empty orders set or local null order. + } 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(); + same = true; + for (let i = 0; i < localPowerOrders.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}: ${localPowerOrders ? JSON.stringify(localPowerOrders) : null}`); + let requestCall = null; + if (localPowerOrders) { + requestCall = this.props.data.client.setOrders({power_name: powerName, orders: localPowerOrders}); + } else { + requestCall = this.props.data.client.clearOrders({power_name: powerName}); + } + requestCall + .then(() => { + this.getPage().success('Orders sent.'); + }) + .catch(err => { + this.getPage().error(err.toString()); + }) + .then(() => { + this.reloadServerOrders(); + }); + } + } + + // ] + + onOrderBuilding(powerName, path) { + const pathToSave = path.slice(1); + this.getPage().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.getPage().success(`Built order: ${orderString}`); + this.__store_orders(allOrders); + this.setState(state); + } + + 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) { + this.setState({ + historyPhaseIndex: newPhaseIndex, + historyCurrentLoc: null, + historyCurrentOrders: null + }); + } + + onChangePastPhase(event) { + this.__change_past_phase(event.target.value); + } + + onChangePastPhaseIndex(increment) { + const selectObject = document.getElementById('select-past-phase'); + if (selectObject) { + // 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}); + } + + onClickMessage(message) { + if (!message.read) { + message.read = true; + let protagonist = message.sender; + if (message.recipient === 'GLOBAL') + protagonist = message.recipient; + this.getPage().load(`game: ${this.props.data.game_id}`, <ContentGame data={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 + }); + } + + // [ Rendering methods. + + renderOrders(engine, currentPowerName) { + const serverOrders = this.props.data.getServerOrders(); + const orders = this.__get_orders(engine); + const wait = this.__get_wait(engine); + + const render = []; + render.push(<PowerOrders key={currentPowerName} name={currentPowerName} wait={wait[currentPowerName]} + orders={orders[currentPowerName]} + serverCount={serverOrders[currentPowerName] ? UTILS.javascript.count(serverOrders[currentPowerName]) : -1} + onRemove={this.onRemoveOrder}/>); + return render; + } + + renderPastMessages(engine, role) { + const messageChannels = engine.getMessageChannels(role, true); + const tabNames = []; + for (let powerName of Object.keys(engine.powers)) if (powerName !== role) + tabNames.push(powerName); + tabNames.sort(); + tabNames.push('GLOBAL'); + const titles = tabNames.map(tabName => (tabName === 'GLOBAL' ? tabName : tabName.substr(0, 3))); + const currentTabId = this.state.tabPastMessages || tabNames[0]; + + return ( + <div className={'panel-messages'} key={'panel-messages'}> + {/* Messages. */} + <Tabs menu={tabNames} titles={titles} onChange={this.onChangeTabPastMessages} active={currentTabId}> + {tabNames.map(protagonist => ( + <Tab key={protagonist} className={'game-messages'} display={currentTabId === protagonist}> + {(!messageChannels.hasOwnProperty(protagonist) || !messageChannels[protagonist].length ? + (<div className={'no-game-message'}>No + messages{engine.isPlayerGame() ? ` with ${protagonist}` : ''}.</div>) : + messageChannels[protagonist].map((message, index) => ( + <MessageView key={index} phase={engine.phase} owner={role} message={message} + read={true}/> + )) + )} + </Tab> + ))} + </Tabs> + </div> + ); + } + + renderCurrentMessages(engine, role) { + const messageChannels = engine.getMessageChannels(role, true); + const tabNames = []; + for (let powerName of Object.keys(engine.powers)) if (powerName !== role) + tabNames.push(powerName); + tabNames.sort(); + tabNames.push('GLOBAL'); + const titles = tabNames.map(tabName => (tabName === 'GLOBAL' ? tabName : tabName.substr(0, 3))); + const currentTabId = this.state.tabCurrentMessages || tabNames[0]; + const highlights = this.state.messageHighlights; + const unreadMarked = new Set(); + + return ( + <div className={'panel-messages'} key={'panel-messages'}> + {/* Messages. */} + <Tabs menu={tabNames} titles={titles} onChange={this.onChangeTabCurrentMessages} active={currentTabId} + highlights={highlights}> + {tabNames.map(protagonist => ( + <Tab key={protagonist} className={'game-messages'} display={currentTabId === protagonist} + id={`panel-current-messages-${protagonist}`}> + {(!messageChannels.hasOwnProperty(protagonist) || !messageChannels[protagonist].length ? + (<div className={'no-game-message'}>No + messages{engine.isPlayerGame() ? ` with ${protagonist}` : ''}.</div>) : + (messageChannels[protagonist].map((message, index) => { + let id = null; + if (!message.read && !unreadMarked.has(protagonist)) { + if (engine.isOmniscientGame() || message.sender !== role) { + unreadMarked.add(protagonist); + id = `${protagonist}-unread`; + } + } + return <MessageView key={index} phase={engine.phase} owner={role} + message={message} + read={message.phase !== engine.phase} + id={id} onClick={this.onClickMessage}/>; + })) + )} + </Tab> + ))} + </Tabs> + {/* Link to go to first unread received message. */} + {unreadMarked.has(currentTabId) && ( + <Scrollchor className={'link-unread-message'} + to={`${currentTabId}-unread`} + target={`panel-current-messages-${currentTabId}`}> + Go to 1st unread message + </Scrollchor> + )} + {/* Send form. */} + {engine.isPlayerGame() && ( + <MessageForm sender={role} recipient={currentTabId} onSubmit={form => + this.sendMessage(engine.client, currentTabId, form.message)}/>)} + </div> + ); + } + + renderMapForResults(gameEngine, showOrders) { + return <Map key={'past-map'} + id={'past-map'} + game={gameEngine} + mapInfo={this.getMapInfo(gameEngine.map_name)} + onError={this.getPage().error} + onHover={showOrders ? this.displayLocationOrders : null} + showOrders={Boolean(showOrders)} + orders={(gameEngine.order_history.contains(gameEngine.phase) && gameEngine.order_history.get(gameEngine.phase)) || null} + />; + } + + renderMapForMessages(gameEngine, showOrders) { + return <Map key={'messages-map'} + id={'messages-map'} + game={gameEngine} + mapInfo={this.getMapInfo(gameEngine.map_name)} + onError={this.getPage().error} + onHover={showOrders ? this.displayLocationOrders : null} + showOrders={Boolean(showOrders)} + orders={(gameEngine.order_history.contains(gameEngine.phase) && gameEngine.order_history.get(gameEngine.phase)) || null} + />; + } + + renderMapForCurrent(gameEngine, powerName, orderType, orderPath) { + const rawOrders = this.__get_orders(gameEngine); + const orders = {}; + for (let entry of Object.entries(rawOrders)) { + orders[entry[0]] = []; + if (entry[1]) { + for (let orderObject of Object.values(entry[1])) + orders[entry[0]].push(orderObject.order); + } + } + return <Map key={'current-map'} + id={'current-map'} + game={gameEngine} + mapInfo={this.getMapInfo(gameEngine.map_name)} + onError={this.getPage().error} + orderBuilding={ContentGame.getOrderBuilding(powerName, orderType, orderPath)} + onOrderBuilding={this.onOrderBuilding} + onOrderBuilt={this.onOrderBuilt} + showOrders={true} + orders={orders} + onSelectLocation={this.onSelectLocation} + onSelectVia={this.onSelectVia}/>; + } + + __get_engine_to_display(initialEngine) { + const pastPhases = initialEngine.state_history.values().map(state => state.name); + pastPhases.push(initialEngine.phase); + 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 = ( + pastPhases[phaseIndex] === initialEngine.phase ? + initialEngine : initialEngine.cloneAt(pastPhases[phaseIndex]) + ); + return {engine, pastPhases, phaseIndex}; + } + + __form_phases(pastPhases, phaseIndex) { + return ( + <form key={1} className={'form-inline mb-4'}> + <Button title={UTILS.html.UNICODE_LEFT_ARROW} onClick={this.onDecrementPastPhase} pickEvent={true} + disabled={phaseIndex === 0}/> + <div className="form-group mx-1"> + <select className={'form-control custom-select'} + id={'select-past-phase'} + value={phaseIndex} + onChange={this.onChangePastPhase}> + {pastPhases.map((phaseName, index) => <option key={index} value={index}>{phaseName}</option>)} + </select> + </div> + <Button title={UTILS.html.UNICODE_RIGHT_ARROW} onClick={this.onIncrementPastPhase} pickEvent={true} + disabled={phaseIndex === pastPhases.length - 1}/> + <div className="form-group mx-1"> + <input className={'form-check-input'} id={'show-orders'} type={'checkbox'} + checked={this.state.historyShowOrders} onChange={this.onChangeShowPastOrders}/> + <label className={'form-check-label'} htmlFor={'show-orders'}>Show orders</label> + </div> + </form> + ); + } + + renderTabResults(toDisplay, initialEngine) { + const {engine, pastPhases, phaseIndex} = this.__get_engine_to_display(initialEngine); + let orders = {}; + let orderResult = null; + if (engine.order_history.contains(engine.phase)) + orders = engine.order_history.get(engine.phase); + if (engine.result_history.contains(engine.phase)) + orderResult = engine.result_history.get(engine.phase); + let countOrders = 0; + for (let powerOrders of Object.values(orders)) { + if (powerOrders) + countOrders += powerOrders.length; + } + const powerNames = Object.keys(orders); + powerNames.sort(); + + const getOrderResult = (order) => { + if (orderResult) { + const pieces = order.split(/ +/); + const unit = `${pieces[0]} ${pieces[1]}`; + if (orderResult.hasOwnProperty(unit)) { + const resultsToParse = orderResult[unit]; + if (!resultsToParse.length) + resultsToParse.push(''); + const results = []; + for (let r of resultsToParse) { + if (results.length) + results.push(', '); + results.push(<span key={results.length} className={r || 'success'}>{r || 'OK'}</span>); + } + return <span className={'order-result'}> ({results})</span>; + } + } + return ''; + }; + + const orderView = [ + this.__form_phases(pastPhases, phaseIndex), + (((countOrders && ( + <div key={2} className={'past-orders container'}> + {powerNames.map(powerName => !orders[powerName] || !orders[powerName].length ? '' : ( + <div key={powerName} className={'row'}> + <div className={'past-power-name col-sm-2'}>{powerName}</div> + <div className={'past-power-orders col-sm-10'}> + {orders[powerName].map((order, index) => ( + <div key={index}>{order}{getOrderResult(order)}</div> + ))} + </div> + </div> + ))} + </div> + )) || <div key={2} className={'no-orders'}>No orders for this phase!</div> + )) + ]; + + return ( + <Tab id={'tab-phase-history'} display={toDisplay}> + <Row> + <div className={'col-xl'}> + {this.state.historyCurrentOrders && ( + <div className={'history-current-orders'}>{this.state.historyCurrentOrders.join(', ')}</div> + )} + {this.renderMapForResults(engine, this.state.historyShowOrders)} + </div> + <div className={'col-xl'}>{orderView}</div> + </Row> + {toDisplay && <HotKey keys={['arrowleft']} onKeysCoincide={this.onDecrementPastPhase}/>} + {toDisplay && <HotKey keys={['arrowright']} onKeysCoincide={this.onIncrementPastPhase}/>} + {toDisplay && <HotKey keys={['home']} onKeysCoincide={this.displayFirstPastPhase}/>} + {toDisplay && <HotKey keys={['end']} onKeysCoincide={this.displayLastPastPhase}/>} + </Tab> + ); + } + + renderTabMessages(toDisplay, initialEngine, currentPowerName) { + const {engine, pastPhases, phaseIndex} = this.__get_engine_to_display(initialEngine); + + return ( + <Tab id={'tab-phase-history'} display={toDisplay}> + <Row> + <div className={'col-xl'}> + {this.state.historyCurrentOrders && ( + <div className={'history-current-orders'}>{this.state.historyCurrentOrders.join(', ')}</div> + )} + {this.renderMapForMessages(engine, this.state.historyShowOrders)} + </div> + <div className={'col-xl'}> + {this.__form_phases(pastPhases, phaseIndex)} + {pastPhases[phaseIndex] === initialEngine.phase ? ( + this.renderCurrentMessages(initialEngine, currentPowerName) + ) : ( + this.renderPastMessages(engine, currentPowerName) + )} + </div> + </Row> + {toDisplay && <HotKey keys={['arrowleft']} onKeysCoincide={this.onDecrementPastPhase}/>} + {toDisplay && <HotKey keys={['arrowright']} onKeysCoincide={this.onIncrementPastPhase}/>} + {toDisplay && <HotKey keys={['home']} onKeysCoincide={this.displayFirstPastPhase}/>} + {toDisplay && <HotKey keys={['end']} onKeysCoincide={this.displayLastPastPhase}/>} + </Tab> + ); + } + + renderTabCurrentPhase(toDisplay, engine, powerName, orderType, orderPath, currentPowerName, currentTabOrderCreation) { + const powerNames = Object.keys(engine.powers); + powerNames.sort(); + const orderedPowers = powerNames.map(pn => engine.powers[pn]); + return ( + <Tab id={'tab-current-phase'} display={toDisplay}> + <Row> + <div className={'col-xl'}> + {this.renderMapForCurrent(engine, powerName, orderType, orderPath)} + </div> + <div className={'col-xl'}> + {/* Orders. */} + <div className={'panel-orders mb-4'}> + {currentTabOrderCreation ? <div className="mb-4">{currentTabOrderCreation}</div> : ''} + <PowerOrdersActionBar + onReset={this.reloadServerOrders} + onDeleteAll={this.onRemoveAllCurrentPowerOrders} + onUpdate={this.setOrders} + onProcess={(!this.props.data.isPlayerGame() + && this.props.data.observer_level === STRINGS.MASTER_TYPE) ? + this.onProcessGame : null}/> + <div className={'orders'}>{this.renderOrders(this.props.data, powerName)}</div> + <div className={'table-responsive'}> + <Table className={'table table-striped table-sm'} + caption={'Powers info'} + columns={TABLE_POWER_VIEW} + data={orderedPowers} + wrapper={PowerView.wrap}/> + </div> + </div> + </div> + </Row> + </Tab> + ); + } + + // ] + + // [ React.Component overridden methods. + + render() { + this.props.data.displayed = true; + const page = this.context; + const engine = this.props.data; + const title = ContentGame.gameTitle(engine); + const navigation = [ + ['Help', () => page.loadFancyBox('Help', () => <Help/>)], + ['Load a game from disk', page.loadGameFromDisk], + ['Save game to disk', () => saveGameToDisk(engine, page.error)], + [`${UTILS.html.UNICODE_SMALL_LEFT_ARROW} Games`, () => page.loadGames()], + [`${UTILS.html.UNICODE_SMALL_LEFT_ARROW} Leave game`, () => page.leaveGame(engine.game_id)], + [`${UTILS.html.UNICODE_SMALL_LEFT_ARROW} Logout`, page.logout] + ]; + const phaseType = engine.getPhaseType(); + const controllablePowers = engine.getControllablePowers(); + if (this.props.data.client) + this.bindCallbacks(this.props.data.client); + + if (engine.phase === 'FORMING') + return <main> + <div className={'forming'}>Game not yet started!</div> + </main>; + + const tabNames = []; + const tabTitles = []; + let hasTabPhaseHistory = false; + let hasTabCurrentPhase = false; + if (engine.state_history.size()) { + hasTabPhaseHistory = true; + tabNames.push('phase_history'); + tabTitles.push('Results'); + } + tabNames.push('messages'); + tabTitles.push('Messages'); + if (controllablePowers.length && phaseType && !engine.isObserverGame()) { + hasTabCurrentPhase = true; + tabNames.push('current_phase'); + tabTitles.push('Current'); + } + if (!tabNames.length) { + // This should never happen, but let's display this message. + return <main> + <div className={'no-data'}>No data in this game!</div> + </main>; + } + const mainTab = this.state.tabMain && tabNames.includes(this.state.tabMain) ? this.state.tabMain : tabNames[tabNames.length - 1]; + + const currentPowerName = this.state.power || (controllablePowers.length && controllablePowers[0]); + let currentPower = null; + let orderTypeToLocs = null; + let allowedPowerOrderTypes = null; + let orderBuildingType = null; + let buildCount = null; + if (hasTabCurrentPhase) { + currentPower = engine.getPower(currentPowerName); + orderTypeToLocs = engine.getOrderTypeToLocs(currentPowerName); + allowedPowerOrderTypes = Object.keys(orderTypeToLocs); + // canOrder = allowedPowerOrderTypes.length + if (allowedPowerOrderTypes.length) { + POSSIBLE_ORDERS.sortOrderTypes(allowedPowerOrderTypes, phaseType); + if (this.state.orderBuildingType && allowedPowerOrderTypes.includes(this.state.orderBuildingType)) + orderBuildingType = this.state.orderBuildingType; + else + orderBuildingType = allowedPowerOrderTypes[0]; + } + buildCount = engine.getBuildsCount(currentPowerName); + } + + const navAfterTitle = ( + (controllablePowers.length === 1 && + <span className="power-name">{controllablePowers[0]}</span>) || ( + <form className="form-inline form-current-power"> + <select className="form-control custom-select custom-control-inline" id="current-power" + value={currentPowerName} onChange={this.onChangeCurrentPower}> + {controllablePowers.map( + powerName => <option key={powerName} value={powerName}>{powerName}</option>)} + </select> + </form> + ) + ); + + const currentTabOrderCreation = hasTabCurrentPhase && ( + <div> + <PowerOrderCreationForm orderType={orderBuildingType} + orderTypes={allowedPowerOrderTypes} + onChange={this.onChangeOrderType} + onPass={() => this.onSetEmptyOrdersSet(currentPowerName)} + onSetWaitFlag={() => this.setWaitFlag(!currentPower.wait)} + onVote={this.vote} + role={engine.role} + power={currentPower}/> + {(allowedPowerOrderTypes.length && ( + <span> + <strong>Orderable locations</strong>: {orderTypeToLocs[orderBuildingType].join(', ')} + </span> + )) + || (<strong> No orderable location.</strong>)} + {phaseType === 'A' && ( + (buildCount === null && ( + <strong> (unknown build count)</strong> + )) + || (buildCount === 0 ? ( + <strong> (nothing to build or disband)</strong> + ) : (buildCount > 0 ? ( + <strong> ({buildCount} unit{buildCount > 1 && 's'} may be built)</strong> + ) : ( + <strong> ({-buildCount} unit{buildCount < -1 && 's'} to disband)</strong> + ))) + )} + </div> + ); + + return ( + <main> + <Helmet> + <title>{title} | Diplomacy</title> + </Helmet> + <Navigation title={title} + afterTitle={navAfterTitle} + username={page.channel.username} + navigation={navigation}/> + <Tabs menu={tabNames} titles={tabTitles} onChange={this.onChangeMainTab} active={mainTab}> + {/* Tab Phase history. */} + {(hasTabPhaseHistory && this.renderTabResults(mainTab === 'phase_history', engine)) || ''} + {this.renderTabMessages(mainTab === 'messages', engine, currentPowerName)} + {/* Tab Current phase. */} + {(hasTabCurrentPhase && this.renderTabCurrentPhase( + mainTab === 'current_phase', + engine, + currentPowerName, + orderBuildingType, + this.state.orderBuildingPath, + currentPowerName, + currentTabOrderCreation + )) || ''} + </Tabs> + {this.state.fancy_title && ( + <FancyBox title={this.state.fancy_title} onClose={this.state.on_fancy_close}> + {this.state.fancy_function()} + </FancyBox>)} + </main> + ); + } + + componentDidMount() { + window.scrollTo(0, 0); + if (this.props.data.client) + this.reloadDeadlineTimer(this.props.data.client); + this.props.data.displayed = true; + // Try to prevent scrolling when pressing keys Home and End. + document.onkeydown = (event) => { + if (['home', 'end'].includes(event.key.toLowerCase())) { + // Try to prevent scrolling. + if (event.hasOwnProperty('cancelBubble')) + event.cancelBubble = true; + if (event.stopPropagation) + event.stopPropagation(); + if (event.preventDefault) + event.preventDefault(); + } + }; + } + + componentDidUpdate() { + this.props.data.displayed = true; + } + + componentWillUnmount() { + this.clearScheduleTimeout(); + this.props.data.displayed = false; + document.onkeydown = null; + } + + // ] + +} + +ContentGame.contextType = PageContext; +ContentGame.propTypes = { + data: PropTypes.instanceOf(Game).isRequired +}; diff --git a/diplomacy/web/src/gui/pages/content_games.jsx b/diplomacy/web/src/gui/pages/content_games.jsx new file mode 100644 index 0000000..31bd1af --- /dev/null +++ b/diplomacy/web/src/gui/pages/content_games.jsx @@ -0,0 +1,171 @@ +// ============================================================================== +// 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 {Tabs} from "../components/tabs"; +import {Table} from "../components/table"; +import {FindForm} from "../forms/find_form"; +import {CreateForm} from "../forms/create_form"; +import {InlineGameView} from "../utils/inline_game_view"; +import {STRINGS} from "../../diplomacy/utils/strings"; +import {Helmet} from "react-helmet"; +import {Navigation} from "../components/navigation"; +import {PageContext} from "../components/page_context"; +import {ContentGame} from "./content_game"; +import PropTypes from 'prop-types'; +import {Tab} from "../components/tab"; + +const TABLE_LOCAL_GAMES = { + game_id: ['Game ID', 0], + deadline: ['Deadline', 1], + rights: ['Rights', 2], + rules: ['Rules', 3], + players: ['Players/Expected', 4], + status: ['Status', 5], + phase: ['Phase', 6], + join: ['Join', 7], + actions: ['Actions', 8], +}; + +export class ContentGames extends React.Component { + + constructor(props) { + super(props); + this.state = {tab: null}; + this.changeTab = this.changeTab.bind(this); + this.onFind = this.onFind.bind(this); + this.onCreate = this.onCreate.bind(this); + this.wrapGameData = this.wrapGameData.bind(this); + } + + getPage() { + return this.context; + } + + onFind(form) { + for (let field of ['game_id', 'status', 'include_protected', 'for_omniscience']) + if (!form[field]) + form[field] = null; + this.getPage().channel.listGames(form) + .then((data) => { + this.getPage().success('Found ' + data.length + ' data.'); + this.getPage().addGamesFound(data); + this.getPage().loadGames(); + }) + .catch((error) => { + this.getPage().error('Error when looking for distant games: ' + error); + }); + } + + onCreate(form) { + for (let key of Object.keys(form)) { + if (form[key] === '') + form[key] = null; + } + if (form.n_controls !== null) + form.n_controls = parseInt(form.n_controls, 10); + if (form.deadline !== null) + form.deadline = parseInt(form.deadline, 10); + form.rules = ['POWER_CHOICE']; + for (let rule of STRINGS.PUBLIC_RULES) { + const rule_id = `rule_${rule.toLowerCase()}`; + if (form.hasOwnProperty(rule_id)) { + if (form[rule_id]) + form.rules.push(rule); + delete form[rule_id]; + } + } + let networkGame = null; + this.getPage().channel.createGame(form) + .then((game) => { + this.getPage().addToMyGames(game.local); + networkGame = game; + return networkGame.getAllPossibleOrders(); + }) + .then(allPossibleOrders => { + networkGame.local.setPossibleOrders(allPossibleOrders); + this.getPage().load( + `game: ${networkGame.local.game_id}`, + <ContentGame data={networkGame.local}/>, + {success: 'Game created.'} + ); + }) + .catch((error) => { + this.getPage().error('Error when creating a game: ' + error); + }); + } + + changeTab(tabIndex) { + this.setState({tab: tabIndex}); + } + + wrapGameData(gameData) { + return new InlineGameView(this.getPage(), gameData); + } + + render() { + const title = 'Games'; + const page = this.getPage(); + const navigation = [ + ['load a game from disk', page.loadGameFromDisk], + ['logout', page.logout] + ]; + const myGames = this.props.myGames; + const gamesFound = this.props.gamesFound; + myGames.sort((a, b) => b.timestamp_created - a.timestamp_created); + gamesFound.sort((a, b) => b.timestamp_created - a.timestamp_created); + const tab = this.state.tab ? this.state.tab : (myGames.length ? 'my-games' : 'find'); + return ( + <main> + <Helmet> + <title>{title} | Diplomacy</title> + </Helmet> + <Navigation title={title} username={page.channel.username} navigation={navigation}/> + <Tabs menu={['create', 'find', 'my-games']} titles={['Create', 'Find', 'My Games']} + onChange={this.changeTab} active={tab}> + {tab === 'create' ? ( + <Tab id="tab-games-create" display={true}> + <CreateForm onSubmit={this.onCreate}/> + </Tab> + ) : ''} + {tab === 'find' ? ( + <Tab id="tab-games-find" display={true}> + <FindForm onSubmit={this.onFind}/> + <Table className={"table table-striped"} caption={"Games"} columns={TABLE_LOCAL_GAMES} + data={gamesFound} wrapper={this.wrapGameData}/> + </Tab> + ) : ''} + {tab === 'my-games' ? ( + <Tab id={'tab-my-games'} display={true}> + <Table className={"table table-striped"} caption={"My games"} columns={TABLE_LOCAL_GAMES} + data={myGames} wrapper={this.wrapGameData}/> + </Tab> + ) : ''} + </Tabs> + </main> + ); + } + + componentDidMount() { + window.scrollTo(0, 0); + } +} + +ContentGames.contextType = PageContext; +ContentGames.propTypes = { + gamesFound: PropTypes.array.isRequired, + myGames: PropTypes.array.isRequired +}; diff --git a/diplomacy/web/src/gui/pages/page.jsx b/diplomacy/web/src/gui/pages/page.jsx new file mode 100644 index 0000000..cd36f6c --- /dev/null +++ b/diplomacy/web/src/gui/pages/page.jsx @@ -0,0 +1,375 @@ +// ============================================================================== +// Copyright (C) 2019 - Philip Paquette, Steven Bocco +// +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU Affero General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) any +// later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +// FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +// details. +// +// You should have received a copy of the GNU Affero General Public License along +// with this program. If not, see <https://www.gnu.org/licenses/>. +// ============================================================================== +/** Main class to use to create app GUI. **/ + +import React from "react"; +import {ContentConnection} from "./content_connection"; +import {UTILS} from "../../diplomacy/utils/utils"; +import {Diplog} from "../../diplomacy/utils/diplog"; +import {FancyBox} from "../components/fancybox"; +import {DipStorage} from "../utils/dipStorage"; +import {PageContext} from "../components/page_context"; +import {ContentGames} from "./content_games"; +import {loadGameFromDisk} from "../utils/load_game_from_disk"; +import {ContentGame} from "./content_game"; + +export class Page extends React.Component { + + constructor(props) { + super(props); + this.connection = null; + this.channel = null; + this.availableMaps = null; + this.state = { + // fancybox, + fancyTitle: null, + onFancyBox: null, + // Page messages + error: null, + info: null, + success: null, + // Page content parameters + name: null, + body: null, + // Games. + games: {}, // Games found. + myGames: {} // Games locally stored. + }; + this.error = this.error.bind(this); + this.info = this.info.bind(this); + this.success = this.success.bind(this); + this.logout = this.logout.bind(this); + this.loadGameFromDisk = this.loadGameFromDisk.bind(this); + this.unloadFancyBox = this.unloadFancyBox.bind(this); + this._post_remove = this._post_remove.bind(this); + this._add_to_my_games = this._add_to_my_games.bind(this); + this._remove_from_my_games = this._remove_from_my_games.bind(this); + this._remove_from_games = this._remove_from_games.bind(this); + this.onReconnectionError = this.onReconnectionError.bind(this); + } + + static wrapMessage(message) { + return message ? `(${UTILS.date()}) ${message}` : ''; + } + + static __sort_games(games) { + // Sort games with not-joined games first, else compare game ID. + games.sort((a, b) => (((a.role ? 1 : 0) - (b.role ? 1 : 0)) || a.game_id.localeCompare(b.game_id))); + return games; + } + + static defaultPage() { + return <ContentConnection/>; + } + + onReconnectionError(error) { + this.__disconnect(error); + } + + //// Methods to load a global fancybox. + + loadFancyBox(title, callback) { + this.setState({fancyTitle: title, onFancyBox: callback}); + } + + unloadFancyBox() { + this.setState({fancyTitle: null, onFancyBox: null}); + } + + //// Methods to load a page. + + load(name, body, messages) { + const newState = {}; + if (messages) { + for (let key of ['error', 'info', 'success']) + newState[key] = Page.wrapMessage(messages[key]); + } + Diplog.printMessages(newState); + newState.name = name; + newState.body = body; + this.setState(newState); + } + + loadGames(messages) { + this.load( + 'games', + <ContentGames myGames={this.getMyGames()} gamesFound={this.getGamesFound()}/>, + messages + ); + } + + loadGameFromDisk() { + loadGameFromDisk( + (game) => this.load( + `game: ${game.game_id}`, + <ContentGame data={game}/>, + {success: `Game loaded from disk: ${game.game_id}`} + ), + this.error + ); + } + + getName() { + return this.state.name; + } + + //// Methods to sign out channel and go back to connection page. + + __disconnect(error) { + // Clear local data and go back to connection page. + this.connection.close(); + this.connection = null; + this.channel = null; + this.availableMaps = null; + const message = Page.wrapMessage(error ? `${error.toString()}` : `Disconnected from channel and server.`); + Diplog.success(message); + this.setState({ + error: error ? message : null, + info: null, + success: error ? null : message, + name: null, + body: null, + // When disconnected, remove all games previously loaded. + games: {}, + myGames: {} + }); + } + + logout() { + // Disconnect channel and go back to connection page. + if (this.channel) { + this.channel.logout() + .then(() => this.__disconnect()) + .catch(error => this.error(`Error while disconnecting: ${error.toString()}.`)); + } else { + this.__disconnect(); + } + } + + //// Methods to be used to set page title and messages. + + error(message) { + message = Page.wrapMessage(message); + Diplog.error(message); + this.setState({error: message}); + } + + info(message) { + message = Page.wrapMessage(message); + Diplog.info(message); + this.setState({info: message}); + } + + success(message) { + message = Page.wrapMessage(message); + Diplog.success(message); + this.setState({success: message}); + } + + warn(message) { + this.info(message); + } + + //// Methods to manage games. + + updateMyGames(gamesToAdd) { + // Update state myGames with given games. This method does not update local storage. + const myGames = Object.assign({}, this.state.myGames); + let gamesFound = null; + for (let gameToAdd of gamesToAdd) { + myGames[gameToAdd.game_id] = gameToAdd; + if (this.state.games.hasOwnProperty(gameToAdd.game_id)) { + if (!gamesFound) + gamesFound = Object.assign({}, this.state.games); + gamesFound[gameToAdd.game_id] = gameToAdd; + } + } + if (!gamesFound) + gamesFound = this.state.games; + this.setState({myGames: myGames, games: gamesFound}); + } + + getGame(gameID) { + if (this.state.myGames.hasOwnProperty(gameID)) + return this.state.myGames[gameID]; + return this.state.games[gameID]; + } + + getMyGames() { + return Page.__sort_games(Object.values(this.state.myGames)); + } + + getGamesFound() { + return Page.__sort_games(Object.values(this.state.games)); + } + + addGamesFound(gamesToAdd) { + const gamesFound = {}; + for (let game of gamesToAdd) { + gamesFound[game.game_id] = ( + this.state.myGames.hasOwnProperty(game.game_id) ? + this.state.myGames[game.game_id] : game + ); + } + this.setState({games: gamesFound}); + } + + leaveGame(gameID) { + if (this.state.myGames.hasOwnProperty(gameID)) { + const game = this.state.myGames[gameID]; + if (game.client) { + game.client.leave() + .then(() => { + this.disconnectGame(gameID).then(() => { + this.loadGames({info: `Game ${gameID} left.`}); + }); + }) + .catch(error => this.error(`Error when leaving game ${gameID}: ${error.toString()}`)); + } + } else { + this.loadGames({info: `No game to left.`}); + } + } + + _post_remove(gameID) { + this.disconnectGame(gameID) + .then(() => { + const myGames = this._remove_from_my_games(gameID); + const games = this._remove_from_games(gameID); + this.setState( + {games, myGames}, + () => this.loadGames({info: `Game ${gameID} deleted.`})); + }); + } + + removeGame(gameID) { + const game = this.getGame(gameID); + if (game) { + if (game.client) { + game.client.remove() + .then(() => this._post_remove(gameID)) + .catch(error => this.error(`Error when deleting game ${gameID}: ${error.toString()}`)); + } else { + this.channel.joinGame({game_id: gameID}) + .then(networkGame => { + networkGame.remove() + .then(() => this._post_remove(gameID)) + .catch(error => this.error(`Error when deleting game ${gameID}: ${error.toString()}`)); + }) + .catch(error => this.error(`Error when connecting to game to delete (${gameID}): ${error.toString()}`)); + } + } + } + + + disconnectGame(gameID) { + const game = this.getGame(gameID); + if (game) { + if (game.client) + game.client.clearAllCallbacks(); + return this.channel.getGamesInfo({games: [gameID]}) + .then(gamesInfo => { + this.updateMyGames(gamesInfo); + }) + .catch(error => this.error(`Error while leaving game ${gameID}: ${error.toString()}`)); + } + return null; + } + + _add_to_my_games(game) { + const myGames = Object.assign({}, this.state.myGames); + const gamesFound = this.state.games.hasOwnProperty(game.game_id) ? Object.assign({}, this.state.games) : this.state.games; + myGames[game.game_id] = game; + if (gamesFound.hasOwnProperty(game.game_id)) + gamesFound[game.game_id] = game; + return {myGames: myGames, games: gamesFound}; + } + + _remove_from_my_games(gameID) { + if (this.state.myGames.hasOwnProperty(gameID)) { + const games = Object.assign({}, this.state.myGames); + delete games[gameID]; + DipStorage.removeUserGame(this.channel.username, gameID); + return games; + } else { + return this.state.myGames; + } + } + + _remove_from_games(gameID) { + if (this.state.games.hasOwnProperty(gameID)) { + const games = Object.assign({}, this.state.games); + delete games[gameID]; + return games; + } else { + return this.state.games; + } + } + + addToMyGames(game) { + // Update state myGames with given game **and** update local storage. + DipStorage.addUserGame(this.channel.username, game.game_id); + this.setState(this._add_to_my_games(game), () => this.loadGames()); + } + + removeFromMyGames(gameID) { + const myGames = this._remove_from_my_games(gameID); + if (myGames !== this.state.myGames) + this.setState({myGames}, () => this.loadGames()); + } + + hasMyGame(gameID) { + return this.state.myGames.hasOwnProperty(gameID); + } + + //// Render method. + + render() { + const successMessage = this.state.success || '-'; + const infoMessage = this.state.info || '-'; + const errorMessage = this.state.error || '-'; + return ( + <PageContext.Provider value={this}> + <div className="page container-fluid" id={this.state.contentName}> + <div className={'top-msg row'}> + <div title={successMessage !== '-' ? successMessage : ''} + className={'col-sm-4 msg success ' + (this.state.success ? 'with-msg' : 'no-msg')} + onClick={() => this.success()}> + {successMessage} + </div> + <div title={infoMessage !== '-' ? infoMessage : ''} + className={'col-sm-4 msg info ' + (this.state.info ? 'with-msg' : 'no-msg')} + onClick={() => this.info()}> + {infoMessage} + </div> + <div title={errorMessage !== '-' ? errorMessage : ''} + className={'col-sm-4 msg error ' + (this.state.error ? 'with-msg' : 'no-msg')} + onClick={() => this.error()}> + {errorMessage} + </div> + </div> + {this.state.body || Page.defaultPage()} + {this.state.onFancyBox && ( + <FancyBox title={this.state.fancyTitle} onClose={this.unloadFancyBox}> + {this.state.onFancyBox()} + </FancyBox> + )} + </div> + </PageContext.Provider> + ); + } +} |