diff options
Diffstat (limited to 'diplomacy/web/src/gui')
32 files changed, 5113 insertions, 0 deletions
diff --git a/diplomacy/web/src/gui/core/content.jsx b/diplomacy/web/src/gui/core/content.jsx new file mode 100644 index 0000000..416ba9e --- /dev/null +++ b/diplomacy/web/src/gui/core/content.jsx @@ -0,0 +1,51 @@ +// ============================================================================== +// Copyright (C) 2019 - Philip Paquette, Steven Bocco +// +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU Affero General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) any +// later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +// FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +// details. +// +// You should have received a copy of the GNU Affero General Public License along +// with this program. If not, see <https://www.gnu.org/licenses/>. +// ============================================================================== +import React from 'react'; +import PropTypes from 'prop-types'; + +export class Content extends React.Component { + // PROPERTIES: + // page: pointer to parent Page object + // data: data for current content + + // Each derived class must implement this static method. + static builder(page, data) { + return { + // page title (string) + title: `${data ? 'with data' : 'without data'}`, + // page navigation links: array of couples + // (navigation title, navigation callback ( onClick=() => callback() )) + navigation: [], + // page content: React component (e.g. <MyComponent/>, or <div class="content">...</div>, etc). + component: null + }; + } + + getPage() { + return this.props.page; + } + + componentDidMount() { + window.scrollTo(0, 0); + } +} + + +Content.propTypes = { + page: PropTypes.object.isRequired, + data: PropTypes.object +}; diff --git a/diplomacy/web/src/gui/core/fancybox.jsx b/diplomacy/web/src/gui/core/fancybox.jsx new file mode 100644 index 0000000..4d1013d --- /dev/null +++ b/diplomacy/web/src/gui/core/fancybox.jsx @@ -0,0 +1,59 @@ +// ============================================================================== +// Copyright (C) 2019 - Philip Paquette, Steven Bocco +// +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU Affero General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) any +// later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +// FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +// details. +// +// You should have received a copy of the GNU Affero General Public License along +// with this program. If not, see <https://www.gnu.org/licenses/>. +// ============================================================================== +import React from 'react'; +import {Button} from "./widgets"; +import PropTypes from 'prop-types'; + +const TIMES = '\u00D7'; + +export class FancyBox extends React.Component { + // open-tag (<FancyBox></FancyBox>) + // PROPERTIES + // title + // onClose + render() { + return ( + <div className={'fancy-wrapper'} onClick={this.props.onClose}> + <div className={'fancy-box container'} onClick={(event) => { + if (!event) + event = window.event; + if (event.hasOwnProperty('cancelBubble')) + event.cancelBubble = true; + if (event.stopPropagation) + event.stopPropagation(); + }}> + <div className={'row fancy-bar'}> + <div className={'col-11 align-self-center fancy-title'}>{this.props.title}</div> + <div className={'col-1 fancy-button'}> + <Button title={TIMES} color={'danger'} onClick={this.props.onClose}/> + </div> + </div> + <div className={'row'}> + <div className={'col fancy-content'}>{this.props.children}</div> + </div> + </div> + </div> + ); + } +} + + +FancyBox.propTypes = { + title: PropTypes.string.isRequired, + onClose: PropTypes.func.isRequired, + children: PropTypes.oneOfType([PropTypes.array, PropTypes.object]) +}; diff --git a/diplomacy/web/src/gui/core/forms.jsx b/diplomacy/web/src/gui/core/forms.jsx new file mode 100644 index 0000000..76d188c --- /dev/null +++ b/diplomacy/web/src/gui/core/forms.jsx @@ -0,0 +1,116 @@ +// ============================================================================== +// Copyright (C) 2019 - Philip Paquette, Steven Bocco +// +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU Affero General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) any +// later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +// FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +// details. +// +// You should have received a copy of the GNU Affero General Public License along +// with this program. If not, see <https://www.gnu.org/licenses/>. +// ============================================================================== +import React from "react"; +import {Button} from "./widgets"; +import {UTILS} from "../../diplomacy/utils/utils"; + +export class Forms { + static createOnChangeCallback(component, callback) { + return (event) => { + const value = UTILS.html.isCheckBox(event.target) ? event.target.checked : event.target.value; + const fieldName = UTILS.html.isRadioButton(event.target) ? event.target.name : event.target.id; + const update = {[fieldName]: value}; + const state = Object.assign({}, component.state, update); + if (callback) + callback(state); + component.setState(state); + }; + } + + static createOnSubmitCallback(component, callback, resetState) { + return (event) => { + if (callback) + callback(Object.assign({}, component.state)); + if (resetState) + component.setState(resetState); + event.preventDefault(); + }; + } + + static createOnResetCallback(component, onChangeCallback, resetState) { + return (event) => { + if (onChangeCallback) + onChangeCallback(resetState); + component.setState(resetState); + if (event && event.preventDefault) + event.preventDefault(); + }; + } + + static getValue(fieldValues, fieldName, defaultValue) { + return fieldValues.hasOwnProperty(fieldName) ? fieldValues[fieldName] : defaultValue; + } + + static createReset(title, large, onReset) { + return <Button key={'reset'} title={title || 'reset'} onClick={onReset} pickEvent={true} large={large}/>; + } + + static createSubmit(title, large, onSubmit) { + return <Button key={'submit'} title={title || 'submit'} onClick={onSubmit} pickEvent={true} large={large}/>; + } + + static createButton(title, fn, color, large) { + const wrapFn = (event) => { + fn(); + event.preventDefault(); + }; + return <Button large={large} key={title} color={color} title={title} onClick={wrapFn} pickEvent={true}/>; + } + + static createCheckbox(id, title, value, onChange) { + const input = <input className={'form-check-input'} key={id} type={'checkbox'} id={id} checked={value} + onChange={onChange}/>; + const label = <label className={'form-check-label'} key={`label-${id}`} htmlFor={id}>{title}</label>; + return [input, label]; + } + + static createRadio(name, value, title, currentValue, onChange) { + const id = `[${name}][${value}]`; + const input = <input className={'form-check-input'} key={id} type={'radio'} + name={name} value={value} checked={currentValue === value} + id={id} onChange={onChange}/>; + const label = <label className={'form-check-label'} key={`label-${id}`} htmlFor={id}>{title || value}</label>; + return [input, label]; + } + + static createRow(label, input) { + return ( + <div className={'form-group row'}> + {label} + <div className={'col'}>{input}</div> + </div> + ); + } + + static createLabel(htmFor, title, className) { + return <label className={className} htmlFor={htmFor}>{title}</label>; + } + + static createColLabel(htmlFor, title) { + return Forms.createLabel(htmlFor, title, 'col'); + } + + static createSelectOptions(values, none) { + const options = values.slice(); + const components = options.map((option, index) => <option key={index} value={option}>{option}</option>); + if (none) { + components.splice(0, 0, [<option key={-1} value={''}>{none === true ? '(none)' : `${none}`}</option>]); + } + return components; + } +} + diff --git a/diplomacy/web/src/gui/core/layouts.jsx b/diplomacy/web/src/gui/core/layouts.jsx new file mode 100644 index 0000000..78189e4 --- /dev/null +++ b/diplomacy/web/src/gui/core/layouts.jsx @@ -0,0 +1,55 @@ +// ============================================================================== +// Copyright (C) 2019 - Philip Paquette, Steven Bocco +// +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU Affero General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) any +// later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +// FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +// details. +// +// You should have received a copy of the GNU Affero General Public License along +// with this program. If not, see <https://www.gnu.org/licenses/>. +// ============================================================================== +import React from 'react'; +import PropTypes from 'prop-types'; + +class Div extends React.Component { + getClassName() { + return ''; + } + + render() { + return ( + <div className={this.getClassName() + (this.props.className ? ' ' + this.props.className : '')}> + {this.props.children} + </div> + ); + } +} + +Div.propTypes = { + className: PropTypes.string, + children: PropTypes.oneOfType([PropTypes.array, PropTypes.object]) +}; + +export class Bar extends Div { + getClassName() { + return 'bar'; + } +} + +export class Row extends Div { + getClassName() { + return 'row'; + } +} + +export class Col extends Div { + getClassName() { + return 'col'; + } +} diff --git a/diplomacy/web/src/gui/core/page.jsx b/diplomacy/web/src/gui/core/page.jsx new file mode 100644 index 0000000..5ca09fd --- /dev/null +++ b/diplomacy/web/src/gui/core/page.jsx @@ -0,0 +1,434 @@ +// ============================================================================== +// Copyright (C) 2019 - Philip Paquette, Steven Bocco +// +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU Affero General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) any +// later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +// FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +// details. +// +// You should have received a copy of the GNU Affero General Public License along +// with this program. If not, see <https://www.gnu.org/licenses/>. +// ============================================================================== +/** Main class to use to create app GUI. **/ + +import React from "react"; +import {ContentConnection} from "../diplomacy/contents/content_connection"; +import {ContentGames} from "../diplomacy/contents/content_games"; +import {ContentGame} from "../diplomacy/contents/content_game"; +import {UTILS} from "../../diplomacy/utils/utils"; +import {Diplog} from "../../diplomacy/utils/diplog"; +import {STRINGS} from "../../diplomacy/utils/strings"; +import {Game} from "../../diplomacy/engine/game"; +import Octicon, {Person} from '@githubprimer/octicons-react'; +import $ from "jquery"; +import {FancyBox} from "./fancybox"; +import {DipStorage} from "../diplomacy/utils/dipStorage"; + +const CONTENTS = { + connection: ContentConnection, + games: ContentGames, + game: ContentGame +}; + +export class Page extends React.Component { + + constructor(props) { + super(props); + this.connection = null; + this.channel = null; + this.availableMaps = null; + this.state = { + // fancybox, + fancyTitle: null, + onFancyBox: null, + // Page messages + error: null, + info: null, + success: null, + title: null, + // Page content parameters + contentName: 'connection', + contentData: null, + // Games. + games: {}, // Games found. + myGames: {} // Games locally stored. + }; + this.loadPage = this.loadPage.bind(this); + this.loadConnection = this.loadConnection.bind(this); + this.loadGames = this.loadGames.bind(this); + this.loadGame = this.loadGame.bind(this); + this.loadGameFromDisk = this.loadGameFromDisk.bind(this); + this.logout = this.logout.bind(this); + this.error = this.error.bind(this); + this.info = this.info.bind(this); + this.success = this.success.bind(this); + this.unloadFancyBox = this.unloadFancyBox.bind(this); + } + + static wrapMessage(message) { + return message ? `(${UTILS.date()}) ${message}` : ''; + } + + static __sort_games(games) { + // Sort games with not-joined games first, else compare game ID. + games.sort((a, b) => (((a.role ? 1 : 0) - (b.role ? 1 : 0)) || a.game_id.localeCompare(b.game_id))); + return games; + } + + copyState(updatedFields) { + return Object.assign({}, this.state, updatedFields || {}); + } + + //// Methods to check page type. + + __page_is(contentName, contentData) { + return this.state.contentName === contentName && (!contentData || this.state.contentData === contentData); + } + + pageIsConnection(contentData) { + return this.__page_is('connection', contentData); + } + + pageIsGames(contentData) { + return this.__page_is('games', contentData); + } + + pageIsGame(contentData) { + return this.__page_is('game', contentData); + } + + //// Methods to load a global fancybox. + + loadFancyBox(title, callback) { + this.setState({fancyTitle: title, onFancyBox: callback}); + } + + unloadFancyBox() { + this.setState({fancyTitle: null, onFancyBox: null}); + } + + //// Methods to load a page. + + loadPage(contentName, contentData, messages) { + messages = messages || {}; + messages.error = Page.wrapMessage(messages.error); + messages.info = Page.wrapMessage(messages.info); + messages.success = Page.wrapMessage(messages.success); + Diplog.printMessages(messages); + this.setState(this.copyState({ + error: messages.error, + info: messages.info, + success: messages.success, + contentName: contentName, + contentData: contentData, + title: null, + fancyTitle: null, + onFancyBox: null + })); + } + + loadConnection(contentData, messages) { + this.loadPage('connection', contentData, messages); + } + + loadGames(contentData, messages) { + this.loadPage('games', contentData, messages); + } + + loadGame(gameInfo, messages) { + this.loadPage('game', gameInfo, messages); + } + + loadGameFromDisk() { + const input = $(document.createElement('input')); + input.attr("type", "file"); + input.trigger('click'); + input.change(event => { + const file = event.target.files[0]; + if (!file.name.match(/\.json$/i)) { + this.error(`Invalid JSON filename ${file.name}`); + } else { + const reader = new FileReader(); + reader.onload = () => { + const savedData = JSON.parse(reader.result); + const gameObject = {}; + gameObject.game_id = `(local) ${savedData.id}`; + gameObject.map_name = savedData.map; + gameObject.rules = savedData.rules; + const state_history = {}; + const message_history = {}; + const order_history = {}; + const result_history = {}; + for (let savedPhase of savedData.phases) { + const gameState = savedPhase.state; + const phaseOrders = savedPhase.orders || {}; + const phaseResults = savedPhase.results || {}; + const phaseMessages = {}; + if (savedPhase.messages) { + for (let message of savedPhase.messages) { + phaseMessages[message.time_sent] = message; + } + } + if (!gameState.name) + gameState.name = savedPhase.name; + state_history[gameState.name] = gameState; + order_history[gameState.name] = phaseOrders; + message_history[gameState.name] = phaseMessages; + result_history[gameState.name] = phaseResults; + } + gameObject.state_history = state_history; + gameObject.message_history = message_history; + gameObject.order_history = order_history; + gameObject.state_history = state_history; + gameObject.result_history = result_history; + gameObject.messages = []; + gameObject.role = STRINGS.OBSERVER_TYPE; + gameObject.status = STRINGS.COMPLETED; + gameObject.timestamp_created = 0; + gameObject.deadline = 0; + gameObject.n_controls = 0; + gameObject.registration_password = ''; + const game = new Game(gameObject); + this.loadGame(game); + }; + reader.readAsText(file); + } + }); + } + + //// Methods to sign out channel and go back to connection page. + + __disconnect() { + // Clear local data and go back to connection page. + this.connection.close(); + this.connection = null; + this.channel = null; + this.availableMaps = null; + const message = Page.wrapMessage(`Disconnected from channel and server.`); + Diplog.success(message); + this.setState(this.copyState({ + error: null, + info: null, + success: message, + contentName: 'connection', + contentData: null, + // When disconnected, remove all games previously loaded. + games: {}, + myGames: {} + })); + } + + logout() { + // Disconnect channel and go back to connection page. + if (this.channel) { + this.channel.logout() + .then(() => this.__disconnect()) + .catch(error => this.error(`Error while disconnecting: ${error.toString()}.`)); + } else { + this.__disconnect(); + } + } + + //// Methods to be used to set page title and messages. + + setTitle(title) { + this.setState({title: title}); + } + + error(message) { + message = Page.wrapMessage(message); + Diplog.error(message); + this.setState({error: message}); + } + + info(message) { + message = Page.wrapMessage(message); + Diplog.info(message); + this.setState({info: message}); + } + + success(message) { + message = Page.wrapMessage(message); + Diplog.success(message); + this.setState({success: message}); + } + + warn(message) { + this.info(message); + } + + //// Methods to manage games. + + updateMyGames(gamesToAdd) { + // Update state myGames with given games. This method does not update local storage. + const myGames = Object.assign({}, this.state.myGames); + let gamesFound = null; + for (let gameToAdd of gamesToAdd) { + myGames[gameToAdd.game_id] = gameToAdd; + if (this.state.games.hasOwnProperty(gameToAdd.game_id)) { + if (!gamesFound) + gamesFound = Object.assign({}, this.state.games); + gamesFound[gameToAdd.game_id] = gameToAdd; + } + } + if (!gamesFound) + gamesFound = this.state.games; + this.setState({myGames: myGames, games: gamesFound}); + } + + getMyGames() { + return Page.__sort_games(Object.values(this.state.myGames)); + } + + getGamesFound() { + return Page.__sort_games(Object.values(this.state.games)); + } + + addGamesFound(gamesToAdd) { + const gamesFound = {}; + for (let game of gamesToAdd) { + gamesFound[game.game_id] = ( + this.state.myGames.hasOwnProperty(game.game_id) ? + this.state.myGames[game.game_id] : game + ); + } + this.setState({games: gamesFound}); + } + + leaveGame(gameID) { + if (this.state.myGames.hasOwnProperty(gameID)) { + const game = this.state.myGames[gameID]; + if (game.client) { + game.client.leave() + .then(() => { + this.disconnectGame(gameID); + this.loadGames(null, {info: `Game ${gameID} left.`}); + }) + .catch(error => this.error(`Error when leaving game ${gameID}: ${error.toString()}`)); + } + } + } + + disconnectGame(gameID) { + if (this.state.myGames.hasOwnProperty(gameID)) { + const game = this.state.myGames[gameID]; + if (game.client) + game.client.clearAllCallbacks(); + this.channel.getGamesInfo({games: [gameID]}) + .then(gamesInfo => { + this.updateMyGames(gamesInfo); + }) + .catch(error => this.error(`Error while leaving game ${gameID}: ${error.toString()}`)); + } + } + + addToMyGames(game) { + // Update state myGames with given game **and** update local storage. + const myGames = Object.assign({}, this.state.myGames); + const gamesFound = this.state.games.hasOwnProperty(game.game_id) ? Object.assign({}, this.state.games) : this.state.games; + myGames[game.game_id] = game; + if (gamesFound.hasOwnProperty(game.game_id)) + gamesFound[game.game_id] = game; + DipStorage.addUserGame(this.channel.username, game.game_id); + this.setState({myGames: myGames, games: gamesFound}); + } + + removeFromMyGames(gameID) { + if (this.state.myGames.hasOwnProperty(gameID)) { + const games = Object.assign({}, this.state.myGames); + delete games[gameID]; + DipStorage.removeUserGame(this.channel.username, gameID); + this.setState({myGames: games}); + } + } + + hasMyGame(gameID) { + return this.state.myGames.hasOwnProperty(gameID); + } + + //// Render method. + + render() { + const content = CONTENTS[this.state.contentName].builder(this, this.state.contentData); + const hasNavigation = UTILS.javascript.hasArray(content.navigation); + + // NB: I currently don't find a better way to update document title from content details. + const successMessage = this.state.success || '-'; + const infoMessage = this.state.info || '-'; + const errorMessage = this.state.error || '-'; + const title = this.state.title || content.title; + document.title = title + ' | Diplomacy'; + + return ( + <div className="page container-fluid" id={this.state.contentName}> + <div className={'top-msg row'}> + <div title={successMessage !== '-' ? successMessage : ''} + className={'col-sm-4 msg success ' + (this.state.success ? 'with-msg' : 'no-msg')} + onClick={() => this.success()}> + {successMessage} + </div> + <div title={infoMessage !== '-' ? infoMessage : ''} + className={'col-sm-4 msg info ' + (this.state.info ? 'with-msg' : 'no-msg')} + onClick={() => this.info()}> + {infoMessage} + </div> + <div title={errorMessage !== '-' ? errorMessage : ''} + className={'col-sm-4 msg error ' + (this.state.error ? 'with-msg' : 'no-msg')} + onClick={() => this.error()}> + {errorMessage} + </div> + </div> + {((hasNavigation || this.channel) && ( + <div className={'title row'}> + <div className={'col align-self-center'}><strong>{title}</strong></div> + <div className={'col-sm-1'}> + {(!hasNavigation && ( + <div className={'float-right'}> + <strong> + <u className={'mr-2'}>{this.channel.username}</u> + <Octicon icon={Person}/> + </strong> + </div> + )) || ( + <div className="dropdown float-right"> + <button className="btn btn-secondary dropdown-toggle" type="button" + id="dropdownMenuButton" data-toggle="dropdown" + aria-haspopup="true" aria-expanded="false"> + {(this.channel && this.channel.username && ( + <span> + <u className={'mr-2'}>{this.channel.username}</u> + <Octicon icon={Person}/> + </span> + )) || 'Menu'} + </button> + <div className="dropdown-menu dropdown-menu-right" + aria-labelledby="dropdownMenuButton"> + {content.navigation.map((nav, index) => { + const navTitle = nav[0]; + const navAction = nav[1]; + return <a key={index} className="dropdown-item" + onClick={navAction}>{navTitle}</a>; + })} + </div> + </div> + )} + </div> + </div> + )) || ( + <div className={'title'}><strong>{title}</strong></div> + )} + {content.component} + {this.state.onFancyBox && ( + <FancyBox title={this.state.fancyTitle} onClose={this.unloadFancyBox}> + {this.state.onFancyBox()} + </FancyBox> + )} + </div> + ); + } +} diff --git a/diplomacy/web/src/gui/core/table.jsx b/diplomacy/web/src/gui/core/table.jsx new file mode 100644 index 0000000..cb729e7 --- /dev/null +++ b/diplomacy/web/src/gui/core/table.jsx @@ -0,0 +1,112 @@ +// ============================================================================== +// Copyright (C) 2019 - Philip Paquette, Steven Bocco +// +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU Affero General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) any +// later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +// FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +// details. +// +// You should have received a copy of the GNU Affero General Public License along +// with this program. If not, see <https://www.gnu.org/licenses/>. +// ============================================================================== +//// Tables. + +import React from "react"; +import PropTypes from 'prop-types'; + +class DefaultWrapper { + constructor(data) { + this.data = data; + this.get = this.get.bind(this); + } + + get(fieldName) { + return this.data[fieldName]; + } +} + +function defaultWrapper(data) { + return new DefaultWrapper(data); +} + +export class Table extends React.Component { + // className + // caption + // columns : {name: [title, order]} + // data: [objects with expected column names] + // wrapper: (optional) function to use to wrap one data entry into an object before accessing fields. + // Must return an instance with a method get(name). + // If provided: wrapper(data_entry).get(field_name) + // else: data_entry[field_name] + + constructor(props) { + super(props); + if (!this.props.wrapper) + this.props.wrapper = defaultWrapper; + } + + static getHeader(columns) { + const header = []; + for (let entry of Object.entries(columns)) { + const name = entry[0]; + const title = entry[1][0]; + const order = entry[1][1]; + header.push([order, name, title]); + } + header.sort((a, b) => { + let t = a[0] - b[0]; + if (t === 0) + t = a[1].localeCompare(b[1]); + if (t === 0) + t = a[2].localeCompare(b[2]); + return t; + }); + return header; + } + + static getHeaderLine(header) { + return ( + <thead className={'thead-light'}> + <tr>{header.map((column, colIndex) => <th key={colIndex}>{column[2]}</th>)}</tr> + </thead> + ); + } + + static getBodyRow(header, row, rowIndex, wrapper) { + const wrapped = wrapper(row); + return (<tr key={rowIndex}> + {header.map((headerColumn, colIndex) => <td className={'align-middle'} + key={colIndex}>{wrapped.get(headerColumn[1])}</td>)} + </tr>); + } + + static getBodyLines(header, data, wrapper) { + return (<tbody>{data.map((row, rowIndex) => Table.getBodyRow(header, row, rowIndex, wrapper))}</tbody>); + } + + render() { + const header = Table.getHeader(this.props.columns); + return ( + <div className={'table-responsive'}> + <table className={this.props.className}> + <caption>{this.props.caption} ({this.props.data.length})</caption> + {Table.getHeaderLine(header)} + {Table.getBodyLines(header, this.props.data, this.props.wrapper)} + </table> + </div> + ); + } +} + +Table.propTypes = { + wrapper: PropTypes.func, + columns: PropTypes.object, + className: PropTypes.string, + caption: PropTypes.string, + data: PropTypes.array +}; diff --git a/diplomacy/web/src/gui/core/tabs.jsx b/diplomacy/web/src/gui/core/tabs.jsx new file mode 100644 index 0000000..6123219 --- /dev/null +++ b/diplomacy/web/src/gui/core/tabs.jsx @@ -0,0 +1,96 @@ +// ============================================================================== +// Copyright (C) 2019 - Philip Paquette, Steven Bocco +// +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU Affero General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) any +// later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +// FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +// details. +// +// You should have received a copy of the GNU Affero General Public License along +// with this program. If not, see <https://www.gnu.org/licenses/>. +// ============================================================================== +import React from "react"; +import {Action} from "./widgets"; +import PropTypes from 'prop-types'; + +export class Tab extends React.Component { + render() { + const style = { + display: this.props.display ? 'block' : 'none' + }; + const id = this.props.id ? {id: this.props.id} : {}; + return ( + <div className={'tab mb-4 ' + this.props.className} style={style} {...id}> + {this.props.children} + </div> + ); + } +} + +Tab.propTypes = { + display: PropTypes.bool, + className: PropTypes.string, + id: PropTypes.string, + children: PropTypes.oneOfType([PropTypes.array, PropTypes.object]) +}; + +Tab.defaultProps = { + display: false, + className: '', + id: '' +}; + +export class Tabs extends React.Component { + /** PROPERTIES + * active: index of active menu (must be > menu.length). + * highlights: dictionary mapping a menu indice to a highlight message + * onChange: callback(index): receive index of menu to display. + * **/ + + generateTabAction(tabTitle, tabId, isActive, onChange, highlight) { + return <Action isActive={isActive} + title={tabTitle} + onClick={() => onChange(tabId)} + highlight={highlight} + key={tabId}/>; + } + + render() { + if (!this.props.menu.length) + throw new Error(`No tab menu given.`); + if (this.props.menu.length !== this.props.titles.length) + throw new Error(`Menu length (${this.props.menu.length}) != titles length (${this.props.titles.length})`); + if (this.props.active && !this.props.menu.includes(this.props.active)) + throw new Error(`Invalid active tab name, got ${this.props.active}, expected one of: ${this.props.menu.join(', ')}`); + const active = this.props.active || this.props.menu[0]; + return ( + <div className={'tabs mb-3'}> + <nav className={'tabs-bar nav nav-tabs justify-content-center mb-3'}> + {this.props.menu.map((tabName, index) => this.generateTabAction( + this.props.titles[index], tabName, active === tabName, this.props.onChange, + (this.props.highlights.hasOwnProperty(tabName) && this.props.highlights[tabName]) || null + ))} + </nav> + {this.props.children} + </div> + ); + } +} + +Tabs.propTypes = { + menu: PropTypes.arrayOf(PropTypes.string).isRequired, // tab names + titles: PropTypes.arrayOf(PropTypes.string).isRequired, // tab titles + onChange: PropTypes.func.isRequired, // callback(tab name) + children: PropTypes.array.isRequired, + active: PropTypes.string, // current active tab name + highlights: PropTypes.object, // {tab name => highligh message (optional)} +}; + +Tabs.defaultProps = { + highlights: {} +}; diff --git a/diplomacy/web/src/gui/core/widgets.jsx b/diplomacy/web/src/gui/core/widgets.jsx new file mode 100644 index 0000000..62a5eb4 --- /dev/null +++ b/diplomacy/web/src/gui/core/widgets.jsx @@ -0,0 +1,102 @@ +// ============================================================================== +// Copyright (C) 2019 - Philip Paquette, Steven Bocco +// +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU Affero General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) any +// later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +// FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +// details. +// +// You should have received a copy of the GNU Affero General Public License along +// with this program. If not, see <https://www.gnu.org/licenses/>. +// ============================================================================== +import React from "react"; +import PropTypes from 'prop-types'; + +export class Button extends React.Component { + /** Bootstrap button. + * Bootstrap classes: + * - btn + * - btn-primary + * - mx-1 (margin-left 1px, margin-right 1px) + * Props: title (str), onClick (function). + * **/ + // title + // onClick + // pickEvent = false + // large = false + // small = false + + constructor(props) { + super(props); + this.onClick = this.onClick.bind(this); + } + + onClick(event) { + if (this.props.onClick) + this.props.onClick(this.props.pickEvent ? event : null); + } + + render() { + return ( + <button + className={`btn btn-${this.props.color || 'secondary'}` + (this.props.large ? ' btn-block' : '') + (this.props.small ? ' btn-sm' : '')} + disabled={this.props.disabled} + onClick={this.onClick}> + <strong>{this.props.title}</strong> + </button> + ); + } +} + +Button.propTypes = { + title: PropTypes.string.isRequired, + onClick: PropTypes.func.isRequired, + color: PropTypes.string, + large: PropTypes.bool, + small: PropTypes.bool, + pickEvent: PropTypes.bool, + disabled: PropTypes.bool +}; + +Button.defaultPropTypes = { + disabled: false +}; + + +export class Action extends React.Component { + // title + // isActive + // onClick + // See Button parameters. + + render() { + return ( + <div className="action nav-item" onClick={this.props.onClick}> + <div + className={'nav-link' + (this.props.isActive ? ' active' : '') + (this.props.highlight !== null ? ' updated' : '')}> + {this.props.title} + {this.props.highlight !== null + && this.props.highlight !== undefined + && <span className={'update'}>{this.props.highlight}</span>} + </div> + </div> + ); + } +} + +Action.propTypes = { + title: PropTypes.string.isRequired, + onClick: PropTypes.func.isRequired, + highlight: PropTypes.any, + isActive: PropTypes.bool +}; + +Action.defaultProps = { + highlight: null, + isActive: false +}; diff --git a/diplomacy/web/src/gui/diplomacy/contents/content_connection.jsx b/diplomacy/web/src/gui/diplomacy/contents/content_connection.jsx new file mode 100644 index 0000000..8aa7fb1 --- /dev/null +++ b/diplomacy/web/src/gui/diplomacy/contents/content_connection.jsx @@ -0,0 +1,91 @@ +// ============================================================================== +// Copyright (C) 2019 - Philip Paquette, Steven Bocco +// +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU Affero General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) any +// later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +// FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +// details. +// +// You should have received a copy of the GNU Affero General Public License along +// with this program. If not, see <https://www.gnu.org/licenses/>. +// ============================================================================== +import React from 'react'; +import {Content} from "../../core/content"; +import {Connection} from "../../../diplomacy/client/connection"; +import {ConnectionForm} from "../forms/connection_form"; +import {DipStorage} from "../utils/dipStorage"; + +export class ContentConnection extends Content { + constructor(props) { + super(props); + this.connection = null; + this.onSubmit = this.onSubmit.bind(this); + } + + static builder(page, data) { + return { + title: 'Connection', + navigation: [], + component: <ContentConnection page={page} data={data}/> + }; + } + + onSubmit(data) { + const page = this.getPage(); + for (let fieldName of ['hostname', 'port', 'username', 'password', 'showServerFields']) + if (!data.hasOwnProperty(fieldName)) + return page.error(`Missing ${fieldName}, got ${JSON.stringify(data)}`); + page.info('Connecting ...'); + if (this.connection) { + this.connection.currentConnectionProcessing.stop(); + } + this.connection = new Connection(data.hostname, data.port, window.location.protocol.toLowerCase() === 'https:'); + // Page is passed as logger object (with methods info(), error(), success()) when connecting. + this.connection.connect(this.getPage()) + .then(() => { + page.connection = this.connection; + this.connection = null; + page.success(`Successfully connected to server ${data.username}:${data.port}`); + page.connection.authenticate(data.username, data.password, false) + .catch((error) => { + page.error(`Unable to sign in, trying to create an account, error: ${error}`); + return page.connection.authenticate(data.username, data.password, true); + }) + .then((channel) => { + page.channel = channel; + return channel.getAvailableMaps(); + }) + .then(availableMaps => { + page.availableMaps = availableMaps; + const userGameIndices = DipStorage.getUserGames(page.channel.username); + if (userGameIndices && userGameIndices.length) { + return page.channel.getGamesInfo({games: userGameIndices}); + } else { + return null; + } + }) + .then((gamesInfo) => { + if (gamesInfo) { + this.getPage().success('Found ' + gamesInfo.length + ' user games.'); + this.getPage().updateMyGames(gamesInfo); + } + page.loadGames(null, {success: `Account ${data.username} connected.`}); + }) + .catch((error) => { + page.error('Error while authenticating: ' + error + ' Please re-try.'); + }); + }) + .catch((error) => { + page.error('Error while connecting: ' + error + ' Please re-try.'); + }); + } + + render() { + return <main><ConnectionForm onSubmit={this.onSubmit}/></main>; + } +} diff --git a/diplomacy/web/src/gui/diplomacy/contents/content_game.jsx b/diplomacy/web/src/gui/diplomacy/contents/content_game.jsx new file mode 100644 index 0000000..81a689d --- /dev/null +++ b/diplomacy/web/src/gui/diplomacy/contents/content_game.jsx @@ -0,0 +1,1235 @@ +// ============================================================================== +// Copyright (C) 2019 - Philip Paquette, Steven Bocco +// +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU Affero General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) any +// later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +// FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +// details. +// +// You should have received a copy of the GNU Affero General Public License along +// with this program. If not, see <https://www.gnu.org/licenses/>. +// ============================================================================== +import React from "react"; +import Scrollchor from 'react-scrollchor'; +import {SelectLocationForm} from "../forms/select_location_form"; +import {SelectViaForm} from "../forms/select_via_form"; +import {Order} from "../utils/order"; +import {Button} from "../../core/widgets"; +import {Bar, Row} from "../../core/layouts"; +import {Content} from "../../core/content"; +import {Tab, Tabs} from "../../core/tabs"; +import {Map} from "../map/map"; +import {extendOrderBuilding, ORDER_BUILDER, POSSIBLE_ORDERS} from "../utils/order_building"; +import {PowerActionsForm} from "../forms/power_actions_form"; +import {MessageForm} from "../forms/message_form"; +import {UTILS} from "../../../diplomacy/utils/utils"; +import {Message} from "../../../diplomacy/engine/message"; +import {PowerOrder} from "../widgets/power_order"; +import {MessageView} from "../widgets/message_view"; +import {STRINGS} from "../../../diplomacy/utils/strings"; +import {Diplog} from "../../../diplomacy/utils/diplog"; +import {Table} from "../../core/table"; +import {PowerView} from "../utils/power_view"; +import {FancyBox} from "../../core/fancybox"; +import {DipStorage} from "../utils/dipStorage"; + +const HotKey = require('react-shortcut'); + +/* Order management in game page. + * When editing orders locally, we have to compare it to server orders + * to determine when we need to update orders on server side. There are + * 9 comparison cases, depending on orders: + * SERVER LOCAL DECISION + * null null 0 (same) + * null {} 1 (different, user wants to send "no orders" on server) + * null {orders} 1 (different, user defines new orders locally) + * {} null 0 (assumed same: user is not allowed to "delete" a "no orders": he can only add new orders) + * {} {} 0 (same) + * {} {orders} 1 (different, user defines new orders locally and wants to overwrite the "no-orders" on server) + * {orders} null 1 (different, user wants to delete all server orders, will result to "no-orders") + * {orders} {} 1 (different, user wants to delete all server orders, will result to "no-orders") + * {orders} {orders} same if we have exactly same orders on both server and local + * */ + +const TABLE_POWER_VIEW = { + name: ['Power', 0], + controller: ['Controller', 1], + order_is_set: ['With orders', 2], + wait: ['Waiting', 3] +}; + +function Help() { + return ( + <div> + <p>When building an order, press <strong>ESC</strong> to reset build.</p> + <p>Press letter associated to an order type to start building an order of this type. + <br/> Order type letter is indicated in order type name after order type radio button. + </p> + <p>In Phase History tab, use keyboard left and right arrows to navigate in past phases.</p> + </div> + ); +} + +export class ContentGame extends Content { + + constructor(props) { + super(props); + // Load local orders from local storage (if available). + const savedOrders = this.props.data.client ? DipStorage.getUserGameOrders( + this.props.data.client.channel.username, + this.props.data.game_id, + this.props.data.phase + ) : null; + let orders = null; + if (savedOrders) { + orders = {}; + for (let entry of Object.entries(savedOrders)) { + let powerOrders = null; + const powerName = entry[0]; + if (entry[1]) { + powerOrders = {}; + for (let orderString of entry[1]) { + const order = new Order(orderString, true); + powerOrders[order.loc] = order; + } + } + orders[powerName] = powerOrders; + } + } + this.schedule_timeout_id = null; + this.state = { + tabMain: null, + tabPastMessages: null, + tabCurrentMessages: null, + messageHighlights: {}, + historyPhaseIndex: null, + historyShowOrders: true, + historySubView: 0, + historyCurrentLoc: null, + historyCurrentOrders: null, + wait: null, // {power name => bool} + orders: orders, // {power name => {loc => {local: bool, order: str}}} + power: null, + orderBuildingType: null, + orderBuildingPath: [], + fancy_title: null, + fancy_function: null, + on_fancy_close: null, + }; + + // Bind some class methods to this instance. + this.closeFancyBox = this.closeFancyBox.bind(this); + this.displayFirstPastPhase = this.displayFirstPastPhase.bind(this); + this.displayLastPastPhase = this.displayLastPastPhase.bind(this); + this.displayLocationOrders = this.displayLocationOrders.bind(this); + this.getMapInfo = this.getMapInfo.bind(this); + this.notifiedGamePhaseUpdated = this.notifiedGamePhaseUpdated.bind(this); + this.notifiedLocalStateChange = this.notifiedLocalStateChange.bind(this); + this.notifiedNetworkGame = this.notifiedNetworkGame.bind(this); + this.notifiedNewGameMessage = this.notifiedNewGameMessage.bind(this); + this.notifiedPowersControllers = this.notifiedPowersControllers.bind(this); + this.onChangeCurrentPower = this.onChangeCurrentPower.bind(this); + this.onChangeMainTab = this.onChangeMainTab.bind(this); + this.onChangeOrderType = this.onChangeOrderType.bind(this); + this.onChangePastPhase = this.onChangePastPhase.bind(this); + this.onChangePastPhaseIndex = this.onChangePastPhaseIndex.bind(this); + this.onChangeShowPastOrders = this.onChangeShowPastOrders.bind(this); + this.onChangeTabCurrentMessages = this.onChangeTabCurrentMessages.bind(this); + this.onChangeTabPastMessages = this.onChangeTabPastMessages.bind(this); + this.onClickMessage = this.onClickMessage.bind(this); + this.onDecrementPastPhase = this.onDecrementPastPhase.bind(this); + this.onIncrementPastPhase = this.onIncrementPastPhase.bind(this); + this.onOrderBuilding = this.onOrderBuilding.bind(this); + this.onOrderBuilt = this.onOrderBuilt.bind(this); + this.onProcessGame = this.onProcessGame.bind(this); + this.onRemoveAllOrders = this.onRemoveAllOrders.bind(this); + this.onRemoveOrder = this.onRemoveOrder.bind(this); + this.onSelectLocation = this.onSelectLocation.bind(this); + this.onSelectVia = this.onSelectVia.bind(this); + this.onSetNoOrders = this.onSetNoOrders.bind(this); + this.reloadServerOrders = this.reloadServerOrders.bind(this); + this.renderOrders = this.renderOrders.bind(this); + this.sendMessage = this.sendMessage.bind(this); + this.setOrders = this.setOrders.bind(this); + this.setSelectedLocation = this.setSelectedLocation.bind(this); + this.setSelectedVia = this.setSelectedVia.bind(this); + this.setWaitFlag = this.setWaitFlag.bind(this); + this.vote = this.vote.bind(this); + } + + static gameTitle(game) { + let title = `${game.game_id} | ${game.phase} | ${game.status} | ${game.role} | ${game.map_name}`; + const remainingTime = game.deadline_timer; + if (remainingTime === undefined) + title += ` (deadline: ${game.deadline} sec)`; + else if (remainingTime) + title += ` (remaining ${remainingTime} sec)`; + return title; + } + + static saveGameToDisk(game, page) { + if (game.client) { + game.client.save() + .then((savedData) => { + const domLink = document.createElement('a'); + domLink.setAttribute( + 'href', 'data:text/plain;charset=utf-8,' + encodeURIComponent(JSON.stringify(savedData))); + domLink.setAttribute('download', `${game.game_id}.json`); + domLink.style.display = 'none'; + document.body.appendChild(domLink); + domLink.click(); + document.body.removeChild(domLink); + }) + .catch(exc => page.error(`Error while saving game: ${exc.toString()}`)); + } else { + page.error(`Cannot save this game.`); + } + } + + static builder(page, data) { + return { + title: ContentGame.gameTitle(data), + navigation: [ + ['Help', () => page.loadFancyBox('Help', () => <Help/>)], + ['Load a game from disk', page.loadGameFromDisk], + ['Save game to disk', () => ContentGame.saveGameToDisk(data)], + [`${UTILS.html.UNICODE_SMALL_LEFT_ARROW} Games`, page.loadGames], + [`${UTILS.html.UNICODE_SMALL_LEFT_ARROW} Leave game`, () => page.leaveGame(data.game_id)], + [`${UTILS.html.UNICODE_SMALL_LEFT_ARROW} Logout`, page.logout] + ], + component: <ContentGame page={page} data={data}/> + }; + } + + static getServerWaitFlags(engine) { + const wait = {}; + const controllablePowers = engine.getControllablePowers(); + for (let powerName of controllablePowers) { + wait[powerName] = engine.powers[powerName].wait; + } + return wait; + } + + static getServerOrders(engine) { + const orders = {}; + const controllablePowers = engine.getControllablePowers(); + for (let powerName of controllablePowers) { + const powerOrders = {}; + let countOrders = 0; + const power = engine.powers[powerName]; + for (let orderString of power.orders) { + const serverOrder = new Order(orderString, false); + powerOrders[serverOrder.loc] = serverOrder; + ++countOrders; + } + orders[powerName] = (countOrders || power.order_is_set) ? powerOrders : null; + } + return orders; + } + + static getOrderBuilding(powerName, orderType, orderPath) { + return { + type: orderType, + path: orderPath, + power: powerName, + builder: orderType && ORDER_BUILDER[orderType] + }; + } + + closeFancyBox() { + this.setState({ + fancy_title: null, + fancy_function: null, + on_fancy_close: null, + orderBuildingPath: [] + }); + } + + setSelectedLocation(location, powerName, orderType, orderPath) { + if (!location) + return; + extendOrderBuilding( + powerName, orderType, orderPath, location, + this.onOrderBuilding, this.onOrderBuilt, this.getPage().error + ); + this.setState({ + fancy_title: null, + fancy_function: null, + on_fancy_close: null + }); + } + + setSelectedVia(moveType, powerName, orderPath, location) { + if (!moveType || !['M', 'V'].includes(moveType)) + return; + extendOrderBuilding( + powerName, moveType, orderPath, location, + this.onOrderBuilding, this.onOrderBuilt, this.getPage().error + ); + this.setState({ + fancy_title: null, + fancy_function: null, + on_fancy_close: null + }); + } + + onSelectLocation(possibleLocations, powerName, orderType, orderPath) { + const title = `Select location to continue building order: ${orderPath.join(' ')} ... (press ESC or close button to cancel building)`; + const func = () => (<SelectLocationForm locations={possibleLocations} + onSelect={(location) => this.setSelectedLocation(location, powerName, orderType, orderPath)}/>); + this.setState({ + fancy_title: title, + fancy_function: func, + on_fancy_close: this.closeFancyBox + }); + } + + onSelectVia(location, powerName, orderPath) { + const title = `Select move type for move order: ${orderPath.join(' ')}`; + const func = () => ( + <SelectViaForm onSelect={(moveType) => this.setSelectedVia(moveType, powerName, orderPath, location)}/>); + this.setState({ + fancy_title: title, + fancy_function: func, + on_fancy_close: this.closeFancyBox + }); + } + + __get_orders(engine) { + const orders = ContentGame.getServerOrders(engine); + if (this.state.orders) { + for (let powerName of Object.keys(orders)) { + const serverPowerOrders = orders[powerName]; + const localPowerOrders = this.state.orders[powerName]; + if (localPowerOrders) { + for (let localOrder of Object.values(localPowerOrders)) { + localOrder.local = ( + !serverPowerOrders + || !serverPowerOrders.hasOwnProperty(localOrder.loc) + || serverPowerOrders[localOrder.loc].order !== localOrder.order + ); + } + } + orders[powerName] = localPowerOrders; + } + } + return orders; + } + + __get_wait(engine) { + return this.state.wait ? this.state.wait : ContentGame.getServerWaitFlags(engine); + } + + getMapInfo() { + return this.props.page.availableMaps[this.props.data.map_name]; + } + + clearScheduleTimeout() { + if (this.schedule_timeout_id) { + clearInterval(this.schedule_timeout_id); + this.schedule_timeout_id = null; + } + } + + updateDeadlineTimer() { + const engine = this.props.data; + --engine.deadline_timer; + if (engine.deadline_timer <= 0) { + engine.deadline_timer = 0; + this.clearScheduleTimeout(); + } + this.getPage().setTitle(ContentGame.gameTitle(engine)); + } + + reloadDeadlineTimer(networkGame) { + networkGame.querySchedule() + .then(dataSchedule => { + const schedule = dataSchedule.schedule; + const server_current = schedule.current_time; + const server_end = schedule.time_added + schedule.delay; + const server_remaining = server_end - server_current; + this.props.data.deadline_timer = server_remaining * schedule.time_unit; + if (!this.schedule_timeout_id) + this.schedule_timeout_id = setInterval(() => this.updateDeadlineTimer(), schedule.time_unit * 1000); + }) + .catch(() => { + if (this.props.data.hasOwnProperty('deadline_timer')) + delete this.props.data.deadline_timer; + this.clearScheduleTimeout(); + // this.getPage().error(`Error while updating deadline timer: ${error.toString()}`); + }); + } + + networkGameIsDisplayed(networkGame) { + return this.getPage().pageIsGame(networkGame.local); + } + + notifiedNetworkGame(networkGame, notification) { + if (this.networkGameIsDisplayed(networkGame)) { + const msg = `Game (${networkGame.local.game_id}) received notification ${notification.name}.`; + this.props.page.loadGame(networkGame.local, {info: msg}); + this.reloadDeadlineTimer(networkGame); + } + } + + notifiedPowersControllers(networkGame, notification) { + if (networkGame.local.isPlayerGame() && ( + !networkGame.channel.game_id_to_instances.hasOwnProperty(networkGame.local.game_id) + || !networkGame.channel.game_id_to_instances[networkGame.local.game_id].has(networkGame.local.role) + )) { + // This power game is now invalid. + this.props.page.disconnectGame(networkGame.local.game_id); + if (this.networkGameIsDisplayed(networkGame)) { + this.props.page.loadGames(null, + {error: `Player game ${networkGame.local.game_id}/${networkGame.local.role} was kicked. Deadline over?`}); + } + } else { + this.notifiedNetworkGame(networkGame, notification); + } + } + + notifiedGamePhaseUpdated(networkGame, notification) { + networkGame.getAllPossibleOrders() + .then(allPossibleOrders => { + networkGame.local.setPossibleOrders(allPossibleOrders); + if (this.networkGameIsDisplayed(networkGame)) { + this.getPage().loadGame( + networkGame.local, {info: `Game update (${notification.name}) to ${networkGame.local.phase}.`} + ); + this.__store_orders(null); + this.setState({orders: null, wait: null, messageHighlights: {}}); + this.reloadDeadlineTimer(networkGame); + } + }) + .catch(error => this.getPage().error('Error when updating possible orders: ' + error.toString())); + } + + notifiedLocalStateChange(networkGame) { + networkGame.getAllPossibleOrders() + .then(allPossibleOrders => { + networkGame.local.setPossibleOrders(allPossibleOrders); + if (this.networkGameIsDisplayed(networkGame)) { + this.getPage().loadGame( + networkGame.local, {info: `Possible orders re-loaded.`} + ); + this.reloadDeadlineTimer(networkGame); + } + }) + .catch(error => this.getPage().error('Error when updating possible orders: ' + error.toString())); + } + + notifiedNewGameMessage(networkGame, notification) { + let protagonist = notification.message.sender; + if (notification.message.recipient === 'GLOBAL') + protagonist = notification.message.recipient; + const messageHighlights = Object.assign({}, this.state.messageHighlights); + if (!messageHighlights.hasOwnProperty(protagonist)) + messageHighlights[protagonist] = 1; + else + ++messageHighlights[protagonist]; + this.setState({messageHighlights: messageHighlights}); + this.notifiedNetworkGame(networkGame, notification); + } + + bindCallbacks(networkGame) { + if (!networkGame.callbacksBound) { + networkGame.addOnClearedCenters(this.notifiedLocalStateChange); + networkGame.addOnClearedOrders(this.notifiedLocalStateChange); + networkGame.addOnClearedUnits(this.notifiedLocalStateChange); + networkGame.addOnPowersControllers(this.notifiedPowersControllers); + networkGame.addOnGameMessageReceived(this.notifiedNewGameMessage); + networkGame.addOnGameProcessed(this.notifiedGamePhaseUpdated); + networkGame.addOnGamePhaseUpdate(this.notifiedGamePhaseUpdated); + networkGame.addOnGameStatusUpdate(this.notifiedNetworkGame); + networkGame.addOnOmniscientUpdated(this.notifiedNetworkGame); + networkGame.addOnPowerOrdersUpdate(this.notifiedNetworkGame); + networkGame.addOnPowerOrdersFlag(this.notifiedNetworkGame); + networkGame.addOnPowerVoteUpdated(this.notifiedNetworkGame); + networkGame.addOnPowerWaitFlag(this.notifiedNetworkGame); + networkGame.addOnVoteCountUpdated(this.notifiedNetworkGame); + networkGame.addOnVoteUpdated(this.notifiedNetworkGame); + networkGame.callbacksBound = true; + networkGame.local.markAllMessagesRead(); + } + } + + onChangeCurrentPower(event) { + this.setState({power: event.target.value}); + } + + onChangeMainTab(tab) { + this.setState({tabMain: tab}); + } + + onChangeTabCurrentMessages(tab) { + this.setState({tabCurrentMessages: tab}); + } + + onChangeTabPastMessages(tab) { + this.setState({tabPastMessages: tab}); + } + + sendMessage(networkGame, recipient, body) { + const engine = networkGame.local; + const message = new Message({ + phase: engine.phase, + sender: engine.role, + recipient: recipient, + message: body + }); + const page = this.props.page; + networkGame.sendGameMessage({message: message}) + .then(() => { + page.loadGame(engine, {success: `Message sent: ${JSON.stringify(message)}`}); + }) + .catch(error => page.error(error.toString())); + } + + __store_orders(orders) { + // Save local orders into local storage. + const username = this.props.data.client.channel.username; + const gameID = this.props.data.game_id; + const gamePhase = this.props.data.phase; + if (!orders) { + return DipStorage.clearUserGameOrders(username, gameID); + } + for (let entry of Object.entries(orders)) { + const powerName = entry[0]; + let powerOrdersList = null; + if (entry[1]) { + powerOrdersList = Object.values(entry[1]).map(order => order.order); + } + DipStorage.clearUserGameOrders(username, gameID, powerName); + DipStorage.addUserGameOrders(username, gameID, gamePhase, powerName, powerOrdersList); + } + } + + reloadServerOrders() { + const serverOrders = ContentGame.getServerOrders(this.props.data); + this.__store_orders(serverOrders); + this.setState({orders: serverOrders}); + } + + setOrders() { + const serverOrders = ContentGame.getServerOrders(this.props.data); + const orders = this.__get_orders(this.props.data); + + for (let entry of Object.entries(orders)) { + const powerName = entry[0]; + const localPowerOrders = entry[1] ? Object.values(entry[1]).map(orderEntry => orderEntry.order) : null; + const serverPowerOrders = serverOrders[powerName] ? Object.values(serverOrders[powerName]).map(orderEntry => orderEntry.order) : null; + let same = false; + + if (serverPowerOrders === null) { + // No orders set on server. + if (localPowerOrders === null) + same = true; + // Otherwise, we have local orders set (even empty local orders). + } else if (serverPowerOrders.length === 0) { + // Empty orders set on server. + // If local orders are null or empty, then we assume + // it's the same thing as empty order set on server. + if (localPowerOrders === null || !localPowerOrders.length) + same = true; + // Otherwise, we have local non-empty orders set. + } else { + // Orders set on server. Identical to local orders only if we have exactly same orders on server and locally. + if (localPowerOrders && localPowerOrders.length === serverPowerOrders.length) { + localPowerOrders.sort(); + serverPowerOrders.sort(); + const length = localPowerOrders.length; + same = true; + for (let i = 0; i < length; ++i) { + if (localPowerOrders[i] !== serverPowerOrders[i]) { + same = false; + break; + } + } + } + } + + if (same) { + Diplog.warn(`Orders not changed for ${powerName}.`); + continue; + } + Diplog.info('Sending orders for ' + powerName + ': ' + JSON.stringify(localPowerOrders)); + this.props.data.client.setOrders({power_name: powerName, orders: localPowerOrders || []}) + .then(() => { + this.props.page.success('Orders sent.'); + }) + .catch(err => { + this.props.page.error(err.toString()); + }) + .then(() => { + this.reloadServerOrders(); + }); + } + } + + onProcessGame() { + this.props.data.client.process() + .then(() => this.props.page.success('Game processed.')) + .catch(err => { + this.props.page.error(err.toString()); + }); + } + + onRemoveOrder(powerName, order) { + const orders = this.__get_orders(this.props.data); + if (orders.hasOwnProperty(powerName) + && orders[powerName].hasOwnProperty(order.loc) + && orders[powerName][order.loc].order === order.order) { + delete orders[powerName][order.loc]; + if (!UTILS.javascript.count(orders[powerName])) + orders[powerName] = null; + this.__store_orders(orders); + this.setState({orders: orders}); + } + } + + onRemoveAllOrders() { + const orders = {}; + const controllablePowers = this.props.data.getControllablePowers(); + for (let powerName of controllablePowers) { + orders[powerName] = null; + } + this.__store_orders(orders); + this.setState({orders: orders}); + } + + onOrderBuilding(powerName, path) { + const pathToSave = path.slice(1); + this.props.page.success(`Building order ${pathToSave.join(' ')} ...`); + this.setState({orderBuildingPath: pathToSave}); + } + + onOrderBuilt(powerName, orderString) { + const state = Object.assign({}, this.state); + state.orderBuildingPath = []; + state.fancy_title = null; + state.fancy_function = null; + state.on_fancy_close = null; + if (!orderString) { + Diplog.warn('No order built.'); + this.setState(state); + return; + } + const engine = this.props.data; + const localOrder = new Order(orderString, true); + const allOrders = this.__get_orders(engine); + if (!allOrders.hasOwnProperty(powerName)) { + Diplog.warn(`Unknown power ${powerName}.`); + this.setState(state); + return; + } + + if (!allOrders[powerName]) + allOrders[powerName] = {}; + allOrders[powerName][localOrder.loc] = localOrder; + state.orders = allOrders; + this.props.page.success(`Built order: ${orderString}`); + this.__store_orders(allOrders); + this.setState(state); + } + + onSetNoOrders(powerName) { + const orders = this.__get_orders(this.props.data); + orders[powerName] = {}; + this.__store_orders(orders); + this.setState({orders: orders}); + } + + onChangeOrderType(form) { + this.setState({ + orderBuildingType: form.order_type, + orderBuildingPath: [], + fancy_title: null, + fancy_function: null, + on_fancy_close: null + }); + } + + vote(decision) { + const engine = this.props.data; + const networkGame = engine.client; + const controllablePowers = engine.getControllablePowers(); + const currentPowerName = this.state.power || (controllablePowers.length ? controllablePowers[0] : null); + if (!currentPowerName) + throw new Error(`Internal error: unable to detect current selected power name.`); + networkGame.vote({power_name: currentPowerName, vote: decision}) + .then(() => this.getPage().success(`Vote set to ${decision} for ${currentPowerName}`)) + .catch(error => { + Diplog.error(error.stack); + this.getPage().error(`Error while setting vote for ${currentPowerName}: ${error.toString()}`); + }); + } + + setWaitFlag(waitFlag) { + const engine = this.props.data; + const networkGame = engine.client; + const controllablePowers = engine.getControllablePowers(); + const currentPowerName = this.state.power || (controllablePowers.length ? controllablePowers[0] : null); + if (!currentPowerName) + throw new Error(`Internal error: unable to detect current selected power name.`); + networkGame.setWait(waitFlag, {power_name: currentPowerName}) + .then(() => this.getPage().success(`Wait flag set to ${waitFlag} for ${currentPowerName}`)) + .catch(error => { + Diplog.error(error.stack); + this.getPage().error(`Error while setting wait flag for ${currentPowerName}: ${error.toString()}`); + }); + } + + __change_past_phase(newPhaseIndex, subView) { + this.setState({ + historyPhaseIndex: newPhaseIndex, + historySubView: (subView ? subView : 0), + historyCurrentLoc: null, + historyCurrentOrders: null + }); + } + + onChangePastPhase(event) { + this.__change_past_phase(event.target.value); + } + + onChangePastPhaseIndex(increment) { + const selectObject = document.getElementById('select-past-phase'); + if (selectObject) { + if (!this.state.historyShowOrders) { + // We must change map sub-view before showed phase index. + const currentSubView = this.state.historySubView; + const newSubView = currentSubView + (increment ? 1 : -1); + if (newSubView === 0 || newSubView === 1) { + // Sub-view correctly updated. We don't yet change showed phase. + return this.setState({historySubView: newSubView}); + } + // Sub-view badly updated (either from 0 to -1, or from 1 to 2). We must change phase. + } + // Let's simply increase or decrease index of showed past phase. + const index = selectObject.selectedIndex; + const newIndex = index + (increment ? 1 : -1); + if (newIndex >= 0 && newIndex < selectObject.length) { + selectObject.selectedIndex = newIndex; + this.__change_past_phase(parseInt(selectObject.options[newIndex].value, 10), (increment ? 0 : 1)); + } + } + } + + onIncrementPastPhase(event) { + this.onChangePastPhaseIndex(true); + if (event && event.preventDefault) + event.preventDefault(); + } + + onDecrementPastPhase(event) { + this.onChangePastPhaseIndex(false); + if (event && event.preventDefault) + event.preventDefault(); + } + + displayFirstPastPhase() { + this.__change_past_phase(0, 0); + } + + displayLastPastPhase() { + this.__change_past_phase(-1, 1); + } + + onChangeShowPastOrders(event) { + this.setState({historyShowOrders: event.target.checked, historySubView: 0}); + } + + renderOrders(engine, currentPowerName) { + const serverOrders = ContentGame.getServerOrders(this.props.data); + const orders = this.__get_orders(engine); + const wait = this.__get_wait(engine); + + const render = []; + render.push(<PowerOrder key={currentPowerName} name={currentPowerName} wait={wait[currentPowerName]} + orders={orders[currentPowerName]} + serverCount={serverOrders[currentPowerName] ? UTILS.javascript.count(serverOrders[currentPowerName]) : -1} + onRemove={this.onRemoveOrder}/>); + return render; + } + + onClickMessage(message) { + if (!message.read) { + message.read = true; + let protagonist = message.sender; + if (message.recipient === 'GLOBAL') + protagonist = message.recipient; + this.getPage().loadGame(this.props.data); + if (this.state.messageHighlights.hasOwnProperty(protagonist) && this.state.messageHighlights[protagonist] > 0) { + const messageHighlights = Object.assign({}, this.state.messageHighlights); + --messageHighlights[protagonist]; + this.setState({messageHighlights: messageHighlights}); + } + } + } + + displayLocationOrders(loc, orders) { + this.setState({ + historyCurrentLoc: loc || null, + historyCurrentOrders: orders && orders.length ? orders : null + }); + } + + renderPastMessages(engine) { + const messageChannels = engine.getMessageChannels(); + let tabNames = null; + if (engine.isPlayerGame()) { + tabNames = []; + for (let powerName of Object.keys(engine.powers)) if (powerName !== engine.role) + tabNames.push(powerName); + tabNames.sort(); + tabNames.push('GLOBAL'); + } else { + tabNames = Object.keys(messageChannels); + } + const currentTabId = this.state.tabPastMessages || tabNames[0]; + + return ( + <div className={'panel-messages'} key={'panel-messages'}> + {/* Messages. */} + <Tabs menu={tabNames} titles={tabNames} onChange={this.onChangeTabPastMessages} active={currentTabId}> + {tabNames.map(protagonist => ( + <Tab key={protagonist} className={'game-messages'} display={currentTabId === protagonist}> + {(!messageChannels.hasOwnProperty(protagonist) || !messageChannels[protagonist].length ? + (<div className={'no-game-message'}>No + messages{engine.isPlayerGame() ? ` with ${protagonist}` : ''}.</div>) : + messageChannels[protagonist].map((message, index) => ( + <MessageView key={index} owner={engine.role} message={message} read={true}/> + )) + )} + </Tab> + ))} + </Tabs> + </div> + ); + } + + renderCurrentMessages(engine) { + const messageChannels = engine.getMessageChannels(); + let tabNames = null; + let highlights = null; + if (engine.isPlayerGame()) { + tabNames = []; + for (let powerName of Object.keys(engine.powers)) if (powerName !== engine.role) + tabNames.push(powerName); + tabNames.sort(); + tabNames.push('GLOBAL'); + highlights = this.state.messageHighlights; + } else { + tabNames = Object.keys(messageChannels); + let totalHighlights = 0; + for (let count of Object.values(this.state.messageHighlights)) + totalHighlights += count; + highlights = {messages: totalHighlights}; + } + const unreadMarked = new Set(); + const currentTabId = this.state.tabCurrentMessages || tabNames[0]; + + return ( + <div className={'panel-messages'} key={'panel-messages'}> + {/* Messages. */} + <Tabs menu={tabNames} titles={tabNames} onChange={this.onChangeTabCurrentMessages} active={currentTabId} + highlights={highlights}> + {tabNames.map(protagonist => ( + <Tab id={`panel-current-messages-${protagonist}`} key={protagonist} className={'game-messages'} + display={currentTabId === protagonist}> + {(!messageChannels.hasOwnProperty(protagonist) || !messageChannels[protagonist].length ? + (<div className={'no-game-message'}>No + messages{engine.isPlayerGame() ? ` with ${protagonist}` : ''}.</div>) : + (messageChannels[protagonist].map((message, index) => { + let id = null; + if (!message.read && !unreadMarked.has(protagonist)) { + if (engine.isOmniscientGame() || message.sender !== engine.role) { + unreadMarked.add(protagonist); + id = `${protagonist}-unread`; + } + } + return <MessageView key={index} + owner={engine.role} + message={message} + id={id} + onClick={this.onClickMessage}/>; + })) + )} + </Tab> + ))} + </Tabs> + {/* Link to go to first unread received message. */} + {unreadMarked.has(currentTabId) && ( + <Scrollchor className={'link-unread-message'} + to={`${currentTabId}-unread`} + target={`panel-current-messages-${currentTabId}`}> + Go to 1st unread message + </Scrollchor> + )} + {/* Send form. */} + {engine.isPlayerGame() && ( + <MessageForm sender={engine.role} recipient={currentTabId} onSubmit={form => + this.sendMessage(engine.client, currentTabId, form.message)}/>)} + </div> + ); + } + + renderPastMap(gameEngine, showOrders) { + return <Map key={'past-map'} + id={'past-map'} + game={gameEngine} + mapInfo={this.getMapInfo(gameEngine.map_name)} + onError={this.getPage().error} + onHover={showOrders ? this.displayLocationOrders : null} + showOrders={Boolean(showOrders)} + orders={(gameEngine.order_history.contains(gameEngine.phase) && gameEngine.order_history.get(gameEngine.phase)) || null} + />; + } + + renderCurrentMap(gameEngine, powerName, orderType, orderPath) { + const rawOrders = this.__get_orders(gameEngine); + const orders = {}; + for (let entry of Object.entries(rawOrders)) { + orders[entry[0]] = []; + if (entry[1]) { + for (let orderObject of Object.values(entry[1])) + orders[entry[0]].push(orderObject.order); + } + } + return <Map key={'current-map'} + id={'current-map'} + game={gameEngine} + mapInfo={this.getMapInfo(gameEngine.map_name)} + onError={this.getPage().error} + orderBuilding={ContentGame.getOrderBuilding(powerName, orderType, orderPath)} + onOrderBuilding={this.onOrderBuilding} + onOrderBuilt={this.onOrderBuilt} + showOrders={true} + orders={orders} + onSelectLocation={this.onSelectLocation} + onSelectVia={this.onSelectVia}/>; + } + + renderTabPhaseHistory(toDisplay, initialEngine) { + const pastPhases = initialEngine.state_history.values().map(state => state.name); + if (initialEngine.phase === 'COMPLETED') { + pastPhases.push('COMPLETED'); + } + let phaseIndex = 0; + if (initialEngine.displayed) { + if (this.state.historyPhaseIndex === null || this.state.historyPhaseIndex >= pastPhases.length) { + phaseIndex = pastPhases.length - 1; + } else { + if (this.state.historyPhaseIndex < 0) { + phaseIndex = pastPhases.length + this.state.historyPhaseIndex; + } else { + phaseIndex = this.state.historyPhaseIndex; + } + } + } + const engine = ( + phaseIndex === initialEngine.state_history.size() ? + initialEngine : initialEngine.cloneAt(initialEngine.state_history.keyFromIndex(phaseIndex)) + ); + let orders = {}; + let orderResult = null; + if (engine.order_history.contains(engine.phase)) + orders = engine.order_history.get(engine.phase); + if (engine.result_history.contains(engine.phase)) + orderResult = engine.result_history.get(engine.phase); + let countOrders = 0; + for (let powerOrders of Object.values(orders)) { + if (powerOrders) + countOrders += powerOrders.length; + } + const powerNames = Object.keys(orders); + powerNames.sort(); + + const getOrderResult = (order) => { + if (orderResult) { + const pieces = order.split(/ +/); + const unit = `${pieces[0]} ${pieces[1]}`; + if (orderResult.hasOwnProperty(unit)) { + const resultsToParse = orderResult[unit]; + if (!resultsToParse.length) + resultsToParse.push(''); + const results = []; + for (let r of resultsToParse) { + if (results.length) + results.push(', '); + results.push(<span key={results.length} className={r || 'success'}>{r || 'OK'}</span>); + } + return <span className={'order-result'}> ({results})</span>; + } + } + return ''; + }; + + const orderView = [ + (<form key={1} className={'form-inline mb-4'}> + <Button title={UTILS.html.UNICODE_LEFT_ARROW} onClick={this.onDecrementPastPhase} pickEvent={true} + disabled={phaseIndex === 0}/> + <div className={'form-group'}> + <select className={'form-control custom-select'} + id={'select-past-phase'} + value={phaseIndex} + onChange={this.onChangePastPhase}> + {pastPhases.map((phaseName, index) => <option key={index} value={index}>{phaseName}</option>)} + </select> + </div> + <Button title={UTILS.html.UNICODE_RIGHT_ARROW} onClick={this.onIncrementPastPhase} pickEvent={true} + disabled={phaseIndex === pastPhases.length - 1}/> + <div className={'form-group'}> + <input className={'form-check-input'} id={'show-orders'} type={'checkbox'} + checked={this.state.historyShowOrders} onChange={this.onChangeShowPastOrders}/> + <label className={'form-check-label'} htmlFor={'show-orders'}>Show orders</label> + </div> + </form>), + ((this.state.historyShowOrders && ( + (countOrders && ( + <div key={2} className={'past-orders container'}> + {powerNames.map(powerName => !orders[powerName] || !orders[powerName].length ? '' : ( + <div key={powerName} className={'row'}> + <div className={'past-power-name col-sm-2'}>{powerName}</div> + <div className={'past-power-orders col-sm-10'}> + {orders[powerName].map((order, index) => ( + <div key={index}>{order}{getOrderResult(order)}</div> + ))} + </div> + </div> + ))} + </div> + )) || <div key={2} className={'no-orders'}>No orders for this phase!</div> + )) || '') + ]; + const messageView = this.renderPastMessages(engine); + + let detailsView = null; + if (this.state.historyShowOrders && countOrders) { + detailsView = ( + <Row> + <div className={'col-sm-6'}>{orderView}</div> + <div className={'col-sm-6'}>{messageView}</div> + </Row> + ); + } else { + detailsView = orderView.slice(); + detailsView.push(messageView); + } + + return ( + <Tab id={'tab-phase-history'} display={toDisplay}> + <Row> + <div className={'col-xl'}> + {this.state.historyCurrentOrders && ( + <div className={'history-current-orders'}>{this.state.historyCurrentOrders.join(', ')}</div> + )} + {this.renderPastMap(engine, this.state.historyShowOrders || this.state.historySubView)} + </div> + <div className={'col-xl'}>{detailsView}</div> + </Row> + {toDisplay && <HotKey keys={['arrowleft']} onKeysCoincide={this.onDecrementPastPhase}/>} + {toDisplay && <HotKey keys={['arrowright']} onKeysCoincide={this.onIncrementPastPhase}/>} + {toDisplay && <HotKey keys={['home']} onKeysCoincide={this.displayFirstPastPhase}/>} + {toDisplay && <HotKey keys={['end']} onKeysCoincide={this.displayLastPastPhase}/>} + </Tab> + ); + } + + renderTabCurrentPhase(toDisplay, engine, powerName, orderType, orderPath) { + const powerNames = Object.keys(engine.powers); + powerNames.sort(); + const orderedPowers = powerNames.map(pn => engine.powers[pn]); + return ( + <Tab id={'tab-current-phase'} display={toDisplay}> + <Row> + <div className={'col-xl'}> + {this.renderCurrentMap(engine, powerName, orderType, orderPath)} + </div> + <div className={'col-xl'}> + {/* Orders. */} + <div className={'panel-orders mb-4'}> + <Bar className={'p-2'}> + <strong className={'mr-4'}>Orders:</strong> + <Button title={'reset'} onClick={this.reloadServerOrders}/> + <Button title={'delete all'} onClick={this.onRemoveAllOrders}/> + <Button color={'primary'} title={'update'} onClick={this.setOrders}/> + {(!this.props.data.isPlayerGame() && this.props.data.observer_level === STRINGS.MASTER_TYPE && + <Button color={'danger'} title={'process game'} + onClick={this.onProcessGame}/>) || ''} + </Bar> + <div className={'orders'}>{this.renderOrders(this.props.data, powerName)}</div> + <div className={'table-responsive'}> + <Table className={'table table-striped table-sm'} + caption={'Powers info'} + columns={TABLE_POWER_VIEW} + data={orderedPowers} + wrapper={PowerView.wrap}/> + </div> + </div> + {/* Messages. */} + {this.renderCurrentMessages(engine)} + </div> + </Row> + </Tab> + ); + } + + render() { + const engine = this.props.data; + const phaseType = engine.getPhaseType(); + const controllablePowers = engine.getControllablePowers(); + if (this.props.data.client) + this.bindCallbacks(this.props.data.client); + + if (engine.phase === 'FORMING') + return <main> + <div className={'forming'}>Game not yet started!</div> + </main>; + + const tabNames = []; + const tabTitles = []; + let hasTabPhaseHistory = false; + let hasTabCurrentPhase = false; + if (engine.state_history.size()) { + hasTabPhaseHistory = true; + tabNames.push('phase_history'); + tabTitles.push('Phase history'); + } + if (controllablePowers.length && phaseType) { + hasTabCurrentPhase = true; + tabNames.push('current_phase'); + tabTitles.push('Current phase'); + } + if (!tabNames.length) { + // This should never happen, but let's display this message. + return <main> + <div className={'no-data'}>No data in this game!</div> + </main>; + } + const mainTab = this.state.tabMain && tabNames.includes(this.state.tabMain) ? this.state.tabMain : tabNames[tabNames.length - 1]; + + const currentPowerName = this.state.power || (controllablePowers.length && controllablePowers[0]); + let currentPower = null; + let orderTypeToLocs = null; + let allowedPowerOrderTypes = null; + let orderBuildingType = null; + let buildCount = null; + if (hasTabCurrentPhase) { + currentPower = engine.getPower(currentPowerName); + orderTypeToLocs = engine.getOrderTypeToLocs(currentPowerName); + allowedPowerOrderTypes = Object.keys(orderTypeToLocs); + // canOrder = allowedPowerOrderTypes.length + if (allowedPowerOrderTypes.length) { + POSSIBLE_ORDERS.sortOrderTypes(allowedPowerOrderTypes, phaseType); + if (this.state.orderBuildingType && allowedPowerOrderTypes.includes(this.state.orderBuildingType)) + orderBuildingType = this.state.orderBuildingType; + else + orderBuildingType = allowedPowerOrderTypes[0]; + } + buildCount = engine.getBuildsCount(currentPowerName); + } + + return ( + <main> + {(hasTabCurrentPhase && ( + <div className={'row align-items-center mb-3'}> + <div className={'col-sm-2'}> + {(controllablePowers.length === 1 && + <div className={'power-name'}>{controllablePowers[0]}</div>) || ( + <select className={'form-control custom-select'} id={'current-power'} + value={currentPowerName} onChange={this.onChangeCurrentPower}> + {controllablePowers.map( + powerName => <option key={powerName} value={powerName}>{powerName}</option>)} + </select> + )} + </div> + <div className={'col-sm-10'}> + <PowerActionsForm orderType={orderBuildingType} + orderTypes={allowedPowerOrderTypes} + onChange={this.onChangeOrderType} + onNoOrders={() => this.onSetNoOrders(currentPowerName)} + onSetWaitFlag={() => this.setWaitFlag(!currentPower.wait)} + onVote={this.vote} + role={engine.role} + power={currentPower}/> + </div> + </div> + )) || ''} + {(hasTabCurrentPhase && ( + <div> + {(allowedPowerOrderTypes.length && ( + <span> + <strong>Orderable locations</strong>: {orderTypeToLocs[orderBuildingType].join(', ')} + </span> + )) + || (<strong> 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> + )) || ''} + <Tabs menu={tabNames} titles={tabTitles} onChange={this.onChangeMainTab} active={mainTab}> + {/* Tab Phase history. */} + {(hasTabPhaseHistory && this.renderTabPhaseHistory(mainTab === 'phase_history', engine)) || ''} + {/* Tab Current phase. */} + {(hasTabCurrentPhase && this.renderTabCurrentPhase( + mainTab === 'current_phase', + engine, + currentPowerName, + orderBuildingType, + this.state.orderBuildingPath + )) || ''} + </Tabs> + {this.state.fancy_title && ( + <FancyBox title={this.state.fancy_title} onClose={this.state.on_fancy_close}> + {this.state.fancy_function()} + </FancyBox>)} + </main> + ); + } + + componentDidMount() { + super.componentDidMount(); + if (this.props.data.client) + this.reloadDeadlineTimer(this.props.data.client); + this.props.data.displayed = true; + // Try to prevent scrolling when pressing keys Home and End. + document.onkeydown = (event) => { + if (['home', 'end'].includes(event.key.toLowerCase())) { + // Try to prevent scrolling. + if (event.hasOwnProperty('cancelBubble')) + event.cancelBubble = true; + if (event.stopPropagation) + event.stopPropagation(); + if (event.preventDefault) + event.preventDefault(); + } + }; + } + + componentDidUpdate() { + this.props.data.displayed = true; + } + + componentWillUnmount() { + this.clearScheduleTimeout(); + this.props.data.displayed = false; + document.onkeydown = null; + } + +} diff --git a/diplomacy/web/src/gui/diplomacy/contents/content_games.jsx b/diplomacy/web/src/gui/diplomacy/contents/content_games.jsx new file mode 100644 index 0000000..6a62d71 --- /dev/null +++ b/diplomacy/web/src/gui/diplomacy/contents/content_games.jsx @@ -0,0 +1,140 @@ +// ============================================================================== +// Copyright (C) 2019 - Philip Paquette, Steven Bocco +// +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU Affero General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) any +// later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +// FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +// details. +// +// You should have received a copy of the GNU Affero General Public License along +// with this program. If not, see <https://www.gnu.org/licenses/>. +// ============================================================================== +import React from "react"; +import {Content} from "../../core/content"; +import {Tab, Tabs} from "../../core/tabs"; +import {Table} from "../../core/table"; +import {FindForm} from "../forms/find_form"; +import {CreateForm} from "../forms/create_form"; +import {InlineGameView} from "../utils/inline_game_view"; +import {STRINGS} from "../../../diplomacy/utils/strings"; + +const TABLE_LOCAL_GAMES = { + game_id: ['Game ID', 0], + deadline: ['Deadline', 1], + rights: ['Rights', 2], + rules: ['Rules', 3], + players: ['Players/Expected', 4], + status: ['Status', 5], + phase: ['Phase', 6], + join: ['Join', 7], + my_games: ['My Games', 8], +}; + +export class ContentGames extends Content { + + constructor(props) { + super(props); + this.state = {tab: null}; + this.changeTab = this.changeTab.bind(this); + this.onFind = this.onFind.bind(this); + this.onCreate = this.onCreate.bind(this); + this.wrapGameData = this.wrapGameData.bind(this); + } + + static builder(page, data) { + return { + title: 'Games', + navigation: [ + ['load a game from disk', page.loadGameFromDisk], + ['logout', page.logout] + ], + component: <ContentGames page={page} data={data}/> + }; + } + + onFind(form) { + for (let field of ['game_id', 'status', 'include_protected', 'for_omniscience']) + if (!form[field]) + form[field] = null; + this.getPage().channel.listGames(form) + .then((data) => { + this.getPage().success('Found ' + data.length + ' data.'); + this.getPage().addGamesFound(data); + }) + .catch((error) => { + this.getPage().error('Error when looking for distant games: ' + error); + }); + } + + onCreate(form) { + for (let key of Object.keys(form)) { + if (form[key] === '') + form[key] = null; + } + if (form.n_controls !== null) + form.n_controls = parseInt(form.n_controls, 10); + if (form.deadline !== null) + form.deadline = parseInt(form.deadline, 10); + form.rules = ['POWER_CHOICE']; + for (let rule of STRINGS.PUBLIC_RULES) { + const rule_id = `rule_${rule.toLowerCase()}`; + if (form.hasOwnProperty(rule_id)) { + if (form[rule_id]) + form.rules.push(rule); + delete form[rule_id]; + } + } + let networkGame = null; + this.getPage().channel.createGame(form) + .then((game) => { + this.getPage().addToMyGames(game.local); + networkGame = game; + return networkGame.getAllPossibleOrders(); + }) + .then(allPossibleOrders => { + networkGame.local.setPossibleOrders(allPossibleOrders); + this.getPage().loadGame(networkGame.local, {success: 'Game created.'}); + }) + .catch((error) => { + this.getPage().error('Error when creating a game: ' + error); + }); + } + + changeTab(tabIndex) { + this.setState({tab: tabIndex}); + } + + wrapGameData(gameData) { + return new InlineGameView(this.getPage(), gameData); + } + + render() { + const myGames = this.getPage().getMyGames(); + const tab = this.state.tab ? this.state.tab : (myGames.length ? 'my-games' : 'find'); + return ( + <main> + <Tabs menu={['create', 'find', 'my-games']} titles={['Create', 'Find', 'My Games']} + onChange={this.changeTab} active={tab}> + <Tab id="tab-games-create" display={tab === 'create'}> + <CreateForm onSubmit={this.onCreate}/> + </Tab> + <Tab id="tab-games-find" display={tab === 'find'}> + <FindForm onSubmit={this.onFind}/> + <Table className={"table table-striped"} caption={"Games"} columns={TABLE_LOCAL_GAMES} + data={this.getPage().getGamesFound()} wrapper={this.wrapGameData}/> + </Tab> + <Tab id={'tab-my-games'} display={tab === 'my-games'}> + <Table className={"table table-striped"} caption={"My games"} columns={TABLE_LOCAL_GAMES} + data={myGames} wrapper={this.wrapGameData}/> + </Tab> + </Tabs> + </main> + ); + } + +} diff --git a/diplomacy/web/src/gui/diplomacy/forms/connection_form.jsx b/diplomacy/web/src/gui/diplomacy/forms/connection_form.jsx new file mode 100644 index 0000000..49ba381 --- /dev/null +++ b/diplomacy/web/src/gui/diplomacy/forms/connection_form.jsx @@ -0,0 +1,123 @@ +// ============================================================================== +// Copyright (C) 2019 - Philip Paquette, Steven Bocco +// +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU Affero General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) any +// later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +// FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +// details. +// +// You should have received a copy of the GNU Affero General Public License along +// with this program. If not, see <https://www.gnu.org/licenses/>. +// ============================================================================== +import React from 'react'; +import {Forms} from "../../core/forms"; +import {UTILS} from "../../../diplomacy/utils/utils"; +import PropTypes from "prop-types"; +import {DipStorage} from "../utils/dipStorage"; + +export class ConnectionForm extends React.Component { + constructor(props) { + super(props); + // Load fields values from local storage. + const initialState = this.initState(); + const savedState = DipStorage.getConnectionForm(); + if (savedState) { + if (savedState.hostname) + initialState.hostname = savedState.hostname; + if (savedState.port) + initialState.port = savedState.port; + if (savedState.username) + initialState.username = savedState.username; + if (savedState.showServerFields) + initialState.showServerFields = savedState.showServerFields; + } + this.state = initialState; + this.updateServerFieldsView = this.updateServerFieldsView.bind(this); + this.onChange = this.onChange.bind(this); + } + + initState() { + return { + hostname: window.location.hostname, + port: (window.location.protocol.toLowerCase() === 'https:') ? 8433 : 8432, + username: '', + password: '', + showServerFields: false + }; + } + + updateServerFieldsView() { + DipStorage.setConnectionshowServerFields(!this.state.showServerFields); + this.setState({showServerFields: !this.state.showServerFields}); + } + + onChange(newState) { + const initialState = this.initState(); + if (newState.hostname !== initialState.hostname) + DipStorage.setConnectionHostname(newState.hostname); + else + DipStorage.setConnectionHostname(null); + if (newState.port !== initialState.port) + DipStorage.setConnectionPort(newState.port); + else + DipStorage.setConnectionPort(null); + if (newState.username !== initialState.username) + DipStorage.setConnectionUsername(newState.username); + else + DipStorage.setConnectionUsername(null); + if (this.props.onChange) + this.props.onChange(newState); + } + + render() { + const onChange = Forms.createOnChangeCallback(this, this.onChange); + const onSubmit = Forms.createOnSubmitCallback(this, this.props.onSubmit); + return ( + <form> + {Forms.createRow( + Forms.createColLabel('username', 'username:'), + <input className={'form-control'} type={'text'} id={'username'} + value={Forms.getValue(this.state, 'username')} onChange={onChange}/> + )} + {Forms.createRow( + Forms.createColLabel('password', 'password:'), + <input className={'form-control'} type={'password'} id={'password'} + value={Forms.getValue(this.state, 'password')} onChange={onChange}/> + )} + <div> + <div className={this.state.showServerFields ? 'mb-2' : 'mb-4'}> + <span className={'button-server'} onClick={this.updateServerFieldsView}> + server settings {this.state.showServerFields ? UTILS.html.UNICODE_BOTTOM_ARROW : UTILS.html.UNICODE_TOP_ARROW} + </span> + </div> + {this.state.showServerFields && ( + <div className={'mb-4'}> + {Forms.createRow( + <label className={'col'} htmlFor={'hostname'}>hostname:</label>, + <input className={'form-control'} type={'text'} id={'hostname'} + value={Forms.getValue(this.state, 'hostname')} onChange={onChange}/> + )} + {Forms.createRow( + <label className={'col'} htmlFor={'port'}>port:</label>, + <input className={'form-control'} type={'number'} id={'port'} + value={Forms.getValue(this.state, 'port')} + onChange={onChange}/> + )} + </div> + )} + </div> + {Forms.createRow('', Forms.createSubmit('connect', true, onSubmit))} + </form> + ); + } +} + +ConnectionForm.propTypes = { + onChange: PropTypes.func, + onSubmit: PropTypes.func +}; diff --git a/diplomacy/web/src/gui/diplomacy/forms/create_form.jsx b/diplomacy/web/src/gui/diplomacy/forms/create_form.jsx new file mode 100644 index 0000000..48c733e --- /dev/null +++ b/diplomacy/web/src/gui/diplomacy/forms/create_form.jsx @@ -0,0 +1,95 @@ +// ============================================================================== +// Copyright (C) 2019 - Philip Paquette, Steven Bocco +// +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU Affero General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) any +// later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +// FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +// details. +// +// You should have received a copy of the GNU Affero General Public License along +// with this program. If not, see <https://www.gnu.org/licenses/>. +// ============================================================================== +import React from 'react'; +import {Forms} from "../../core/forms"; +import {STRINGS} from "../../../diplomacy/utils/strings"; +import PropTypes from "prop-types"; + +export class CreateForm extends React.Component { + constructor(props) { + super(props); + this.state = this.initState(); + } + + initState() { + const state = { + game_id: '', + power_name: '', + n_controls: 7, + deadline: 300, + registration_password: '' + }; + for (let rule of STRINGS.PUBLIC_RULES) + state[`rule_${rule.toLowerCase()}`] = false; + return state; + } + + render() { + const onChange = Forms.createOnChangeCallback(this, this.props.onChange); + const onSubmit = Forms.createOnSubmitCallback(this, this.props.onSubmit); + return ( + <form> + {Forms.createRow( + Forms.createColLabel('game_id', 'Game ID (optional)'), + <input id={'game_id'} className={'form-control'} type={'text'} + value={Forms.getValue(this.state, 'game_id')} onChange={onChange}/> + )} + {Forms.createRow( + Forms.createColLabel('power_name', 'power:'), + <select id={'power_name'} className={'form-control custom-select'} + value={Forms.getValue(this.state, 'power_name')} onChange={onChange}> + {Forms.createSelectOptions(STRINGS.ALL_POWER_NAMES, true)} + </select> + )} + {Forms.createRow( + Forms.createColLabel('n_controls', 'number of required players:'), + <input id={'n_controls'} className={'form-control'} type={'number'} + value={Forms.getValue(this.state, 'n_controls')} onChange={onChange}/> + )} + {Forms.createRow( + Forms.createColLabel('deadline', 'deadline (in seconds)'), + <input id={'deadline'} className={'form-control'} type={'number'} + value={Forms.getValue(this.state, 'deadline')} + onChange={onChange}/> + )} + {Forms.createRow( + Forms.createColLabel('registration_password', 'registration password'), + <input id={'registration_password'} className={'form-control'} type={'password'} + value={Forms.getValue(this.state, 'registration_password')} onChange={onChange}/> + )} + <div><strong>RULES:</strong></div> + <div className={'mb-4'}> + {STRINGS.PUBLIC_RULES.map((rule, index) => ( + <div key={index} className={'form-check-inline'}> + {Forms.createCheckbox( + `rule_${rule.toLowerCase()}`, + rule, + Forms.getValue(this.state, `rule_${rule.toLowerCase()}`), + onChange)} + </div> + ))} + </div> + {Forms.createRow('', Forms.createSubmit('create a game', true, onSubmit))} + </form> + ); + } +} + +CreateForm.propTypes = { + onChange: PropTypes.func, + onSubmit: PropTypes.func +}; diff --git a/diplomacy/web/src/gui/diplomacy/forms/find_form.jsx b/diplomacy/web/src/gui/diplomacy/forms/find_form.jsx new file mode 100644 index 0000000..c73d2b1 --- /dev/null +++ b/diplomacy/web/src/gui/diplomacy/forms/find_form.jsx @@ -0,0 +1,70 @@ +// ============================================================================== +// Copyright (C) 2019 - Philip Paquette, Steven Bocco +// +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU Affero General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) any +// later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +// FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +// details. +// +// You should have received a copy of the GNU Affero General Public License along +// with this program. If not, see <https://www.gnu.org/licenses/>. +// ============================================================================== +import React from 'react'; +import {Forms} from "../../core/forms"; +import {STRINGS} from "../../../diplomacy/utils/strings"; +import PropTypes from "prop-types"; + +export class FindForm extends React.Component { + constructor(props) { + super(props); + this.state = this.initState(); + } + + initState() { + return { + game_id: '', + status: '', + include_protected: false, + for_omniscience: false + }; + } + + render() { + const onChange = Forms.createOnChangeCallback(this, this.props.onChange); + const onSubmit = Forms.createOnSubmitCallback(this, this.props.onSubmit); + return ( + <form> + {Forms.createRow( + Forms.createColLabel('game_id', 'game id (should contain):'), + <input className={'form-control'} id={'game_id'} type={'text'} + value={Forms.getValue(this.state, 'game_id')} + onChange={onChange}/> + )} + {Forms.createRow( + Forms.createColLabel('status', 'status:'), + (<select className={'form-control custom-select'} + id={'status'} value={Forms.getValue(this.state, 'status')} onChange={onChange}> + {Forms.createSelectOptions(STRINGS.ALL_GAME_STATUSES, true)} + </select>) + )} + <div className={'form-check'}> + {Forms.createCheckbox('include_protected', 'include protected games.', Forms.getValue(this.state, 'include_protected'), onChange)} + </div> + <div className={'form-check mb-4'}> + {Forms.createCheckbox('for_omniscience', 'for omniscience.', Forms.getValue(this.state, 'for_omniscience'), onChange)} + </div> + {Forms.createRow('', Forms.createSubmit('find games', true, onSubmit))} + </form> + ); + } +} + +FindForm.propTypes = { + onChange: PropTypes.func, + onSubmit: PropTypes.func +}; diff --git a/diplomacy/web/src/gui/diplomacy/forms/join_form.jsx b/diplomacy/web/src/gui/diplomacy/forms/join_form.jsx new file mode 100644 index 0000000..0447280 --- /dev/null +++ b/diplomacy/web/src/gui/diplomacy/forms/join_form.jsx @@ -0,0 +1,77 @@ +// ============================================================================== +// Copyright (C) 2019 - Philip Paquette, Steven Bocco +// +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU Affero General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) any +// later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +// FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +// details. +// +// You should have received a copy of the GNU Affero General Public License along +// with this program. If not, see <https://www.gnu.org/licenses/>. +// ============================================================================== +import React from 'react'; +import {Forms} from "../../core/forms"; +import {STRINGS} from "../../../diplomacy/utils/strings"; +import PropTypes from "prop-types"; + +export class JoinForm extends React.Component { + constructor(props) { + super(props); + this.state = this.initState(); + } + + initState() { + return { + [this.getPowerNameID()]: this.getDefaultPowerName(), + [this.getPasswordID()]: '' + }; + } + + getPowerNameID() { + return `power_name_${this.props.game_id}`; + } + + getPasswordID() { + return `registration_password_${this.props.game_id}`; + } + + getDefaultPowerName() { + return (this.props.powers && this.props.powers.length && this.props.powers[0]) || ''; + } + + render() { + const onChange = Forms.createOnChangeCallback(this, this.props.onChange); + const onSubmit = Forms.createOnSubmitCallback(this, this.props.onSubmit); + return ( + <form className={'form-inline'}> + <div className={'form-group'}> + {Forms.createLabel(this.getPowerNameID(), 'Power:')} + <select id={this.getPowerNameID()} className={'from-control custom-select ml-2'} + value={Forms.getValue(this.state, this.getPowerNameID())} onChange={onChange}> + {Forms.createSelectOptions(STRINGS.ALL_POWER_NAMES, true)} + </select> + </div> + <div className={'form-group mx-2'}> + {Forms.createLabel(this.getPasswordID(), '', 'sr-only')} + <input id={this.getPasswordID()} type={'password'} className={'form-control'} + placeholder={'registration password'} + value={Forms.getValue(this.state, this.getPasswordID())} + onChange={onChange}/> + </div> + {Forms.createSubmit('join', false, onSubmit)} + </form> + ); + } +} + +JoinForm.propTypes = { + game_id: PropTypes.string.isRequired, + powers: PropTypes.arrayOf(PropTypes.string), + onChange: PropTypes.func, + onSubmit: PropTypes.func +}; diff --git a/diplomacy/web/src/gui/diplomacy/forms/message_form.jsx b/diplomacy/web/src/gui/diplomacy/forms/message_form.jsx new file mode 100644 index 0000000..a7c377a --- /dev/null +++ b/diplomacy/web/src/gui/diplomacy/forms/message_form.jsx @@ -0,0 +1,53 @@ +// ============================================================================== +// Copyright (C) 2019 - Philip Paquette, Steven Bocco +// +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU Affero General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) any +// later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +// FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +// details. +// +// You should have received a copy of the GNU Affero General Public License along +// with this program. If not, see <https://www.gnu.org/licenses/>. +// ============================================================================== +import React from 'react'; +import {Forms} from "../../core/forms"; +import {UTILS} from "../../../diplomacy/utils/utils"; +import PropTypes from "prop-types"; + +export class MessageForm extends React.Component { + constructor(props) { + super(props); + this.state = this.initState(); + } + + initState() { + return {message: ''}; + } + + render() { + const onChange = Forms.createOnChangeCallback(this, this.props.onChange); + const onSubmit = Forms.createOnSubmitCallback(this, this.props.onSubmit, this.initState()); + return ( + <form> + <div className={'form-group'}> + {Forms.createLabel('message', '', 'sr-only')} + <textarea id={'message'} className={'form-control'} + value={Forms.getValue(this.state, 'message')} onChange={onChange}/> + </div> + {Forms.createSubmit(`send (${this.props.sender} ${UTILS.html.UNICODE_SMALL_RIGHT_ARROW} ${this.props.recipient})`, true, onSubmit)} + </form> + ); + } +} + +MessageForm.propTypes = { + sender: PropTypes.string, + recipient: PropTypes.string, + onChange: PropTypes.func, + onSubmit: PropTypes.func +}; diff --git a/diplomacy/web/src/gui/diplomacy/forms/power_actions_form.jsx b/diplomacy/web/src/gui/diplomacy/forms/power_actions_form.jsx new file mode 100644 index 0000000..33bd763 --- /dev/null +++ b/diplomacy/web/src/gui/diplomacy/forms/power_actions_form.jsx @@ -0,0 +1,120 @@ +// ============================================================================== +// Copyright (C) 2019 - Philip Paquette, Steven Bocco +// +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU Affero General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) any +// later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +// FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +// details. +// +// You should have received a copy of the GNU Affero General Public License along +// with this program. If not, see <https://www.gnu.org/licenses/>. +// ============================================================================== +import React from 'react'; +import {Forms} from "../../core/forms"; +import {ORDER_BUILDER} from "../utils/order_building"; +import {STRINGS} from "../../../diplomacy/utils/strings"; +import PropTypes from "prop-types"; +import {Power} from "../../../diplomacy/engine/power"; + +const HotKey = require('react-shortcut'); + +export class PowerActionsForm extends React.Component { + constructor(props) { + super(props); + this.state = this.initState(); + } + + initState() { + return {order_type: this.props.orderType}; + } + + render() { + const onChange = Forms.createOnChangeCallback(this, this.props.onChange); + const onReset = Forms.createOnResetCallback(this, this.props.onChange, this.initState()); + const onSetOrderType = (letter) => { + this.setState({order_type: letter}, () => { + if (this.props.onChange) + this.props.onChange(this.state); + }); + }; + let title = ''; + let titleClass = 'mr-4'; + const header = []; + const votes = []; + if (this.props.orderTypes.length) { + title = 'Create order:'; + header.push(<strong key={'title'} className={titleClass}>{title}</strong>); + header.push(...this.props.orderTypes.map((orderLetter, index) => ( + <div key={index} className={'form-check-inline'}> + {Forms.createRadio('order_type', orderLetter, ORDER_BUILDER[orderLetter].name, this.props.orderType, onChange)} + </div> + ))); + header.push(Forms.createReset('reset', false, onReset)); + } else if (this.props.power.order_is_set) { + title = 'Unorderable power (already locked on server).'; + titleClass += ' neutral'; + header.push(<strong key={'title'} className={titleClass}>{title}</strong>); + } else { + title = 'No orders available for this power.'; + header.push(<strong key={'title'} className={titleClass}>{title}</strong>); + } + if (!this.props.power.order_is_set) { + header.push(Forms.createButton('pass', this.props.onNoOrders)); + } + + if (this.props.role !== STRINGS.OMNISCIENT_TYPE) { + votes.push(<strong key={0} className={'ml-4 mr-2'}>Vote for draw:</strong>); + switch (this.props.power.vote) { + case 'yes': + votes.push(Forms.createButton('no', () => this.props.onVote('no'), 'danger')); + votes.push(Forms.createButton('neutral', () => this.props.onVote('neutral'), 'info')); + break; + case 'no': + votes.push(Forms.createButton('yes', () => this.props.onVote('yes'), 'success')); + votes.push(Forms.createButton('neutral', () => this.props.onVote('neutral'), 'info')); + break; + case 'neutral': + votes.push(Forms.createButton('yes', () => this.props.onVote('yes'), 'success')); + votes.push(Forms.createButton('no', () => this.props.onVote('no'), 'danger')); + break; + default: + votes.push(Forms.createButton('yes', () => this.props.onVote('yes'), 'success')); + votes.push(Forms.createButton('no', () => this.props.onVote('no'), 'danger')); + votes.push(Forms.createButton('neutral', () => this.props.onVote('neutral'), 'info')); + break; + } + } + return ( + <form className={'form-inline power-actions-form'}> + {header} + {Forms.createButton( + (this.props.power.wait ? 'no wait' : 'wait'), + this.props.onSetWaitFlag, + (this.props.power.wait ? 'success' : 'danger') + )} + {votes} + <HotKey keys={['escape']} onKeysCoincide={onReset}/> + {this.props.orderTypes.map((letter, index) => ( + <HotKey key={index} keys={[letter.toLowerCase()]} onKeysCoincide={() => onSetOrderType(letter)}/> + ))} + </form> + ); + } +} + +PowerActionsForm.propTypes = { + orderType: PropTypes.oneOf(Object.keys(ORDER_BUILDER)), + orderTypes: PropTypes.arrayOf(PropTypes.oneOf(Object.keys(ORDER_BUILDER))), + power: PropTypes.instanceOf(Power), + role: PropTypes.string, + onChange: PropTypes.func, + onSubmit: PropTypes.func, + onNoOrders: PropTypes.func, // onNoOrders() + onVote: PropTypes.func, // onVote(voteString) + onSetWaitFlag: PropTypes.func, // onSetWaitFlag(), +}; diff --git a/diplomacy/web/src/gui/diplomacy/forms/select_location_form.jsx b/diplomacy/web/src/gui/diplomacy/forms/select_location_form.jsx new file mode 100644 index 0000000..3c55e49 --- /dev/null +++ b/diplomacy/web/src/gui/diplomacy/forms/select_location_form.jsx @@ -0,0 +1,36 @@ +// ============================================================================== +// Copyright (C) 2019 - Philip Paquette, Steven Bocco +// +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU Affero General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) any +// later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +// FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +// details. +// +// You should have received a copy of the GNU Affero General Public License along +// with this program. If not, see <https://www.gnu.org/licenses/>. +// ============================================================================== +import React from "react"; +import PropTypes from "prop-types"; +import {Button} from "../../core/widgets"; + +export class SelectLocationForm extends React.Component { + render() { + return ( + <div> + {this.props.locations.map((location, index) => ( + <Button key={index} title={location} large={true} onClick={() => this.props.onSelect(location)}/> + ))} + </div> + ); + } +} + +SelectLocationForm.propTypes = { + locations: PropTypes.arrayOf(PropTypes.string).isRequired, + onSelect: PropTypes.func.isRequired // onSelect(location) +}; diff --git a/diplomacy/web/src/gui/diplomacy/forms/select_via_form.jsx b/diplomacy/web/src/gui/diplomacy/forms/select_via_form.jsx new file mode 100644 index 0000000..cc62fe2 --- /dev/null +++ b/diplomacy/web/src/gui/diplomacy/forms/select_via_form.jsx @@ -0,0 +1,35 @@ +// ============================================================================== +// Copyright (C) 2019 - Philip Paquette, Steven Bocco +// +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU Affero General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) any +// later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +// FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +// details. +// +// You should have received a copy of the GNU Affero General Public License along +// with this program. If not, see <https://www.gnu.org/licenses/>. +// ============================================================================== +import React from "react"; +import PropTypes from "prop-types"; +import {Button} from "../../core/widgets"; + +export class SelectViaForm extends React.Component { + render() { + return ( + <div> + <Button title={'regular move (M)'} large={true} onClick={() => this.props.onSelect('M')}/> + <Button title={'move via (V)'} large={true} onClick={() => this.props.onSelect('V')}/> + </div> + ); + } +} + +SelectViaForm.propTypes = { + onSelect: PropTypes.func.isRequired +}; + diff --git a/diplomacy/web/src/gui/diplomacy/map/dom_order_builder.js b/diplomacy/web/src/gui/diplomacy/map/dom_order_builder.js new file mode 100644 index 0000000..8b7072e --- /dev/null +++ b/diplomacy/web/src/gui/diplomacy/map/dom_order_builder.js @@ -0,0 +1,278 @@ +// ============================================================================== +// Copyright (C) 2019 - Philip Paquette, Steven Bocco +// +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU Affero General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) any +// later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +// FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +// details. +// +// You should have received a copy of the GNU Affero General Public License along +// with this program. If not, see <https://www.gnu.org/licenses/>. +// ============================================================================== +import {UTILS} from "../../../diplomacy/utils/utils"; +import $ from "jquery"; +import {extendOrderBuilding} from "../utils/order_building"; +import {Diplog} from "../../../diplomacy/utils/diplog"; + +function parseLocation(txt) { + if (txt.length > 2 && txt[1] === ' ' && ['A', 'F'].includes(txt[0])) + return txt.substr(2); + return txt; +} + +export class DOMOrderBuilder { + + constructor(svgElement, onOrderBuilding, onOrderBuilt, onSelectLocation, onSelectVia, onError) { + this.svg = svgElement; + this.cbOrderBuilding = onOrderBuilding; + this.cbOrderBuilt = onOrderBuilt; + this.cbSelectLocation = onSelectLocation; + this.cbSelectVia = onSelectVia; + this.cbError = onError; + + this.game = null; + this.mapData = null; + this.orderBuilding = null; + + this.provinceColors = {}; + this.clickedID = null; + this.clickedNeighbors = []; + + this.onProvinceClick = this.onProvinceClick.bind(this); + this.onLabelClick = this.onLabelClick.bind(this); + this.onUnitClick = this.onUnitClick.bind(this); + } + + saveProvinceColors() { + // Get province colors. + const elements = this.svg.getElementsByTagName('path'); + for (let element of elements) { + this.provinceColors[element.id] = element.getAttribute('class'); + } + } + + provinceNameToMapID(name) { + return `_${name.toLowerCase()}___${this.svg.parentNode.id}`; + } + + mapID(id) { + return `${id}___${this.svg.parentNode.id}`; + } + + onOrderBuilding(svgPath, powerName, orderPath) { + this.cbOrderBuilding(powerName, orderPath); + } + + onOrderBuilt(svgPath, powerName, orderString) { + this.cbOrderBuilt(powerName, orderString); + } + + onError(svgPath, error) { + this.cbError(error.toString()); + } + + handleSvgPath(svgPath) { + const orderBuilding = this.orderBuilding; + if (!orderBuilding.builder) + return this.onError(svgPath, 'No orderable locations.'); + + const province = this.mapData.getProvince(svgPath.id); + if (!province) + return; + + const stepLength = orderBuilding.builder.steps.length; + if (orderBuilding.path.length >= stepLength) + throw new Error(`Order building: current steps count (${orderBuilding.path.length}) should be less than` + + ` expected steps count (${stepLength}) (${orderBuilding.path.join(', ')}).`); + + const lengthAfterClick = orderBuilding.path.length + 1; + let validLocations = []; + const testedPath = [orderBuilding.type].concat(orderBuilding.path); + const value = UTILS.javascript.getTreeValue(this.game.ordersTree, testedPath); + if (value !== null) { + const checker = orderBuilding.builder.steps[lengthAfterClick - 1]; + try { + const possibleLocations = checker(province, orderBuilding.power); + for (let possibleLocation of possibleLocations) { + possibleLocation = possibleLocation.toUpperCase(); + if (value.includes(possibleLocation)) + validLocations.push(possibleLocation); + } + } catch (error) { + return this.onError(svgPath, error); + } + } + if (!validLocations.length) + return this.onError(svgPath, 'Disallowed.'); + + if (validLocations.length > 1 && orderBuilding.type === 'S' && orderBuilding.path.length >= 2) { + // We are building a support order and we have a multiple choice for a location. + // Let's check if next location to choose is a coast. To have a coast: + // - all possible locations must start with same 3 characters. + // - we expect at least province name in possible locations (e.g. 'SPA' for 'SPA/NC'). + // If we have a coast, we will remove province name from possible locations. + let isACoast = true; + let validLocationsNoProvinceName = []; + for (let i = 0; i < validLocations.length; ++i) { + let location = validLocations[i]; + if (i > 0) { + // Compare 3 first letters with previous location. + if (validLocations[i - 1].substring(0, 3).toUpperCase() !== validLocations[i].substring(0, 3).toUpperCase()) { + // No same prefix with previous location. We does not have a coast. + isACoast = false; + break; + } + } + if (location.length !== 3) + validLocationsNoProvinceName.push(location); + } + if (validLocations.length === validLocationsNoProvinceName.length) { + // We have not found province name. + isACoast = false; + } + if (isACoast) { + // We want to choose location in a coastal province. Let's remove province name. + validLocations = validLocationsNoProvinceName; + } + } + + if (validLocations.length > 1) { + if (this.cbSelectLocation) { + return this.cbSelectLocation(validLocations, orderBuilding.power, orderBuilding.type, orderBuilding.path); + } else { + Diplog.warn(`Forced to select first valid location.`); + validLocations = [validLocations[0]]; + } + } + let orderBuildingType = orderBuilding.type; + if (lengthAfterClick === stepLength && orderBuildingType === 'M') { + const moveOrderPath = ['M'].concat(orderBuilding.path, validLocations[0]); + const moveTypes = UTILS.javascript.getTreeValue(this.game.ordersTree, moveOrderPath); + if (moveTypes !== null) { + if (moveTypes.length === 2) { + // This move can be done either regularly or VIA a fleet. Let user choose. + return this.cbSelectVia(validLocations[0], orderBuilding.power, orderBuilding.path); + } else { + orderBuildingType = moveTypes[0]; + } + } + } + this.clickedID = svgPath.id; + + this.cleanBuildingView(); + if (lengthAfterClick < stepLength) + this.renderBuildingView(validLocations[0]); + extendOrderBuilding( + orderBuilding.power, orderBuildingType, orderBuilding.path, validLocations[0], + this.cbOrderBuilding, this.cbOrderBuilt, this.cbError + ); + + } + + getPathFromProvince(province) { + let path = this.svg.getElementById(this.provinceNameToMapID(province.name)); + if (!path) { + for (let alias of province.aliases) { + path = this.svg.getElementById(this.provinceNameToMapID(alias)); + if (path) + break; + } + } + return path; + } + + onProvinceClick(event) { + this.handleSvgPath(event.target); + } + + onLabelClick(event) { + const province = this.mapData.getProvince(event.target.textContent); + if (province) { + const path = this.getPathFromProvince(province); + if (path) + this.handleSvgPath(path); + } + } + + onUnitClick(event) { + const province = this.mapData.getProvince(event.target.getAttribute('diplomacyUnit')); + if (province) { + let path = this.getPathFromProvince(province); + if (!path && province.isCoast()) + path = this.svg.getElementById(this.provinceNameToMapID(province.parent.name)); + if (path) { + this.handleSvgPath(path); + } + } + } + + cleanBuildingView() { + if (this.clickedID) { + const path = this.svg.getElementById(this.clickedID); + if (path) + path.setAttribute('class', this.provinceColors[this.clickedID]); + } + for (let neighborName of this.clickedNeighbors) { + const province = this.mapData.getProvince(neighborName); + if (!province) + continue; + const path = this.getPathFromProvince(province); + if (path) + path.setAttribute('class', this.provinceColors[path.id]); + } + this.clickedNeighbors = []; + } + + renderBuildingView(extraLocation) { + if (this.clickedID) { + const path = this.svg.getElementById(this.clickedID); + if (path) + path.setAttribute('class', 'provinceRed'); + } + const selectedPath = [this.orderBuilding.type].concat(this.orderBuilding.path); + if (extraLocation) + selectedPath.push(extraLocation); + const possibleNeighbors = UTILS.javascript.getTreeValue(this.game.ordersTree, selectedPath); + if (!possibleNeighbors) + return; + this.clickedNeighbors = possibleNeighbors.map(neighbor => parseLocation(neighbor)); + if (this.clickedNeighbors.length) { + for (let neighbor of this.clickedNeighbors) { + let neighborProvince = this.mapData.getProvince(neighbor); + if (!neighborProvince) + throw new Error('Unknown neighbor province ' + neighbor); + let path = this.getPathFromProvince(neighborProvince); + if (!path && neighborProvince.isCoast()) + path = this.getPathFromProvince(neighborProvince.parent); + if (!path) + throw new Error(`Unable to find SVG path related to province ${neighborProvince.name}.`); + path.setAttribute('class', neighborProvince.isWater() ? 'provinceBlue' : 'provinceGreen'); + } + } + } + + update(game, mapData, orderBuilding) { + this.game = game; + this.mapData = mapData; + this.orderBuilding = orderBuilding; + this.saveProvinceColors(); + // If there is a building path, then we are building, so we don't clean anything. + this.cleanBuildingView(); + if (this.orderBuilding.path.length) + this.renderBuildingView(); + // I don't yet know why I should place this here. Maybe because unit are re-rendered manually at every reloading ? + $(`#${this.svg.parentNode.id} svg use[diplomacyUnit]`).click(this.onUnitClick); + } + + init(game, mapData, orderBuilding) { + $(`#${this.svg.parentNode.id} svg path`).click(this.onProvinceClick); + $(`#${this.mapID('BriefLabelLayer')} text`).click(this.onLabelClick); + this.update(game, mapData, orderBuilding); + } + +} diff --git a/diplomacy/web/src/gui/diplomacy/map/dom_past_map.js b/diplomacy/web/src/gui/diplomacy/map/dom_past_map.js new file mode 100644 index 0000000..58fd6c8 --- /dev/null +++ b/diplomacy/web/src/gui/diplomacy/map/dom_past_map.js @@ -0,0 +1,112 @@ +// ============================================================================== +// Copyright (C) 2019 - Philip Paquette, Steven Bocco +// +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU Affero General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) any +// later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +// FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +// details. +// +// You should have received a copy of the GNU Affero General Public License along +// with this program. If not, see <https://www.gnu.org/licenses/>. +// ============================================================================== +import $ from "jquery"; + +export class DOMPastMap { + + constructor(svgElement, onHover) { + this.svg = svgElement; + this.cbHover = onHover; + this.game = null; + this.orders = null; + this.mapData = null; + this.onProvinceHover = this.onProvinceHover.bind(this); + this.onLabelHover = this.onLabelHover.bind(this); + this.onUnitHover = this.onUnitHover.bind(this); + } + + provinceNameToMapID(name) { + return `_${name.toLowerCase()}___${this.svg.parentNode.id}`; + } + + mapID(id) { + return `${id}___${this.svg.parentNode.id}`; + } + + onHover(name) { + const orders = []; + if (this.orders) { + for (let powerOrders of Object.values(this.orders)) { + for (let order of powerOrders) { + const pieces = order.split(/ +/); + if (pieces[1].slice(0, 3) === name.toUpperCase().slice(0, 3)) + orders.push(order); + } + } + } + return orders; + } + + handleSvgPath(svgPath) { + const province = this.mapData.getProvince(svgPath.id); + if (province) { + this.cbHover(province.name, this.onHover(province.name)); + } + } + + getPathFromProvince(province) { + let path = this.svg.getElementById(this.provinceNameToMapID(province.name)); + if (!path) { + for (let alias of province.aliases) { + path = this.svg.getElementById(this.provinceNameToMapID(alias)); + if (path) + break; + } + } + return path; + } + + onProvinceHover(event) { + this.handleSvgPath(event.target); + } + + onLabelHover(event) { + const province = this.mapData.getProvince(event.target.textContent); + if (province) { + const path = this.getPathFromProvince(province); + if (path) + this.handleSvgPath(path); + } + } + + onUnitHover(event) { + const province = this.mapData.getProvince(event.target.getAttribute('diplomacyUnit')); + if (province) { + let path = this.getPathFromProvince(province); + if (!path && province.isCoast()) + path = this.svg.getElementById(this.provinceNameToMapID(province.parent.name)); + if (path) { + this.handleSvgPath(path); + } + } + } + + update(game, mapData, orders) { + this.game = game; + this.mapData = mapData; + this.orders = orders; + // I don't yet know why I should place this here. Maybe because unit are re-rendered manually at every reloading ? + $(`#${this.svg.parentNode.id} svg use[diplomacyUnit]`).hover(this.onUnitHover); + } + + init(game, mapData, orders) { + $(`#${this.svg.parentNode.id} svg path`).hover(this.onProvinceHover).mouseleave(() => this.cbHover(null, null)); + $(`#${this.mapID('BriefLabelLayer')} text`).hover(this.onLabelHover); + this.update(game, mapData, orders); + } + +} diff --git a/diplomacy/web/src/gui/diplomacy/map/map.jsx b/diplomacy/web/src/gui/diplomacy/map/map.jsx new file mode 100644 index 0000000..2a2949f --- /dev/null +++ b/diplomacy/web/src/gui/diplomacy/map/map.jsx @@ -0,0 +1,94 @@ +// ============================================================================== +// Copyright (C) 2019 - Philip Paquette, Steven Bocco +// +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU Affero General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) any +// later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +// FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +// details. +// +// You should have received a copy of the GNU Affero General Public License along +// with this program. If not, see <https://www.gnu.org/licenses/>. +// ============================================================================== +import React from "react"; +import SVG from 'react-inlinesvg'; +import mapSVG from '../../../standard.svg'; +import {Renderer} from "./renderer"; +import {MapData} from "../utils/map_data"; +import {DOMOrderBuilder} from "./dom_order_builder"; +import PropTypes from 'prop-types'; +import {DOMPastMap} from "./dom_past_map"; + +export class Map extends React.Component { + // id: ID of div wrapping SVG map. + // mapInfo: dict + // game: game engine + // onError: callback(error) + // showOrders: bool + + // orderBuilding: dict + // onOrderBuilding: callback(powerName, orderBuildingPath) + // onOrderBuilt: callback(powerName, orderString) + + constructor(props) { + super(props); + this.renderer = null; + this.domOrderBuilder = null; + this.domPastMap = null; + this.initSVG = this.initSVG.bind(this); + } + + initSVG() { + const svg = document.getElementById(this.props.id).getElementsByTagName('svg')[0]; + + const game = this.props.game; + const mapData = new MapData(this.props.mapInfo, game); + this.renderer = new Renderer(svg, game, mapData); + this.renderer.render(this.props.showOrders, this.props.orders); + if (this.props.orderBuilding) { + this.domOrderBuilder = new DOMOrderBuilder( + svg, + this.props.onOrderBuilding, this.props.onOrderBuilt, this.props.onSelectLocation, this.props.onSelectVia, + this.props.onError + ); + this.domOrderBuilder.init(game, mapData, this.props.orderBuilding); + } else if (this.props.onHover) { + this.domPastMap = new DOMPastMap(svg, this.props.onHover); + this.domPastMap.init(game, mapData, this.props.orders); + } + } + + render() { + if (this.renderer) { + const game = this.props.game; + const mapData = new MapData(this.props.mapInfo, game); + this.renderer.update(game, mapData, this.props.showOrders, this.props.orders); + if (this.domOrderBuilder) + this.domOrderBuilder.update(game, mapData, this.props.orderBuilding); + else if (this.domPastMap) + this.domPastMap.update(game, mapData, this.props.orders); + } + const divFactory = ((props, children) => <div id={this.props.id} {...props}>{children}</div>); + return <SVG wrapper={divFactory} uniquifyIDs={true} uniqueHash={this.props.id} src={mapSVG} + onLoad={this.initSVG} onError={err => this.props.onError(err.message)}>Game map</SVG>; + } +} + +Map.propTypes = { + id: PropTypes.string, + showOrders: PropTypes.bool, + orders: PropTypes.objectOf(PropTypes.arrayOf(PropTypes.string)), + onSelectLocation: PropTypes.func, + onSelectVia: PropTypes.func, + game: PropTypes.object, + mapInfo: PropTypes.object, + orderBuilding: PropTypes.object, + onOrderBuilding: PropTypes.func, + onOrderBuilt: PropTypes.func, + onError: PropTypes.func, + onHover: PropTypes.func, +}; diff --git a/diplomacy/web/src/gui/diplomacy/map/renderer.js b/diplomacy/web/src/gui/diplomacy/map/renderer.js new file mode 100644 index 0000000..e2586af --- /dev/null +++ b/diplomacy/web/src/gui/diplomacy/map/renderer.js @@ -0,0 +1,615 @@ +// ============================================================================== +// Copyright (C) 2019 - Philip Paquette, Steven Bocco +// +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU Affero General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) any +// later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +// FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +// details. +// +// You should have received a copy of the GNU Affero General Public License along +// with this program. If not, see <https://www.gnu.org/licenses/>. +// ============================================================================== +import $ from "jquery"; + +const ARMY = 'Army'; +const FLEET = 'Fleet'; +// SVG tag names. +const PREFIX_TAG = 'jdipNS'.toLowerCase(); +const TAG_ORDERDRAWING = 'jdipNS:ORDERDRAWING'.toLowerCase(); +const TAG_POWERCOLORS = 'jdipNS:POWERCOLORS'.toLowerCase(); +const TAG_POWERCOLOR = 'jdipNS:POWERCOLOR'.toLowerCase(); +const TAG_SYMBOLSIZE = 'jdipNS:SYMBOLSIZE'.toLowerCase(); +const TAG_PROVINCE_DATA = 'jdipNS:PROVINCE_DATA'.toLowerCase(); +const TAG_PROVINCE = 'jdipNS:PROVINCE'.toLowerCase(); +const TAG_UNIT = 'jdipNS:UNIT'.toLowerCase(); +const TAG_DISLODGED_UNIT = 'jdipNS:DISLODGED_UNIT'.toLowerCase(); +const TAG_SUPPLY_CENTER = 'jdipNS:SUPPLY_CENTER'.toLowerCase(); +const TAG_DISPLAY = 'jdipNS:DISPLAY'.toLowerCase(); + +function attr(node, name) { + return node.attributes[name].value; +} + +function offset(floatString, offset) { + return "" + (parseFloat(floatString) + offset); +} + +export class Renderer { + constructor(svgDomElement, game, mapData) { + this.svg = svgDomElement; + this.game = game; + this.mapData = mapData; + this.metadata = { + color: {}, + symbol_size: {}, + orders: {}, + coord: {} + }; + this.initialInfluences = {}; + this.__load_metadata(); + this.__save_initial_influences(); + } + + __hashed_id(id) { + return `${id}___${this.svg.parentNode.id}`; + } + + __svg_element_from_id(id) { + const hashedID = this.__hashed_id(id); + const element = this.svg.getElementById(hashedID); + if (!element) + throw new Error(`Unable to find ID ${id} (looked for hashed ID ${hashedID})`); + return element; + } + + __load_metadata() { + // Order drawings. + const order_drawings = this.svg.getElementsByTagName(TAG_ORDERDRAWING); + if (!order_drawings.length) + throw new Error('Unable to find order drawings (tag ' + TAG_ORDERDRAWING + ') in SVG map.'); + for (let order_drawing of order_drawings) { + for (let child_node of order_drawing.childNodes) { + if (child_node.nodeName === TAG_POWERCOLORS) { + // Power colors. + for (let power_color of child_node.childNodes) { + if (power_color.nodeName === TAG_POWERCOLOR) { + this.metadata.color[attr(power_color, 'power').toUpperCase()] = attr(power_color, 'color'); + } + } + } else if (child_node.nodeName === TAG_SYMBOLSIZE) { + // Symbol size. + this.metadata.symbol_size[attr(child_node, 'name')] = [attr(child_node, 'height'), attr(child_node, 'width')]; + } else if (child_node.nodeName.startsWith(PREFIX_TAG)) { + // Order type. + const order_type = child_node.nodeName.replace(PREFIX_TAG + ':', ''); + this.metadata.orders[order_type] = {}; + for (let attribute of child_node.attributes) { + if (!attribute.name.includes(':')) { + this.metadata.orders[order_type][attribute.name] = attribute.value; + } + } + } + } + } + // Object coordinates. + const all_province_data = this.svg.getElementsByTagName(TAG_PROVINCE_DATA); + if (!all_province_data.length) + throw new Error('Unable to find province data in SVG map (tag ' + TAG_PROVINCE_DATA + ').'); + for (let province_data of all_province_data) { + for (let child_node of province_data.childNodes) { + // Province. + if (child_node.nodeName === TAG_PROVINCE) { + const province = attr(child_node, 'name').toUpperCase().replace('-', '/'); + this.metadata.coord[province] = {}; + for (let coord_node of child_node.childNodes) { + if (coord_node.nodeName === TAG_UNIT) { + this.metadata.coord[province].unit = [attr(coord_node, 'x'), attr(coord_node, 'y')]; + } else if (coord_node.nodeName === TAG_DISLODGED_UNIT) { + this.metadata.coord[province].disl = [attr(coord_node, 'x'), attr(coord_node, 'y')]; + } else if (coord_node.nodeName === TAG_SUPPLY_CENTER) { + this.metadata.coord[province].sc = [attr(coord_node, 'x'), attr(coord_node, 'y')]; + } + } + } + } + } + // Deleting. + this.svg.removeChild(this.svg.getElementsByTagName(TAG_DISPLAY)[0]); + this.svg.removeChild(this.svg.getElementsByTagName(TAG_ORDERDRAWING)[0]); + this.svg.removeChild(this.svg.getElementsByTagName(TAG_PROVINCE_DATA)[0]); + + // (this code was previously in render()) + // Removing mouse layer. + this.svg.removeChild(this.__svg_element_from_id('MouseLayer')); + } + + __save_initial_influences() { + const mapLayer = this.__svg_element_from_id('MapLayer'); + if (!mapLayer) + throw new Error('Unable to find map layer.'); + for (let element of mapLayer.childNodes) { + if (element.tagName === 'path') { + this.initialInfluences[element.id] = element.getAttribute('class'); + } + } + } + + __restore_initial_influences() { + for (let id of Object.keys(this.initialInfluences)) { + const className = this.initialInfluences[id]; + this.svg.getElementById(id).setAttribute('class', className); + } + } + + __set_current_phase() { + const current_phase = (this.game.phase[0] === '?' || this.game.phase === 'COMPLETED') ? 'FINAL' : this.game.phase; + const phase_display = this.__svg_element_from_id('CurrentPhase'); + if (phase_display) { + phase_display.childNodes[0].nodeValue = current_phase; + } + } + + __set_note(note1, note2) { + note1 = note1 || ''; + note2 = note2 || ''; + const display_note1 = this.__svg_element_from_id('CurrentNote'); + const display_note2 = this.__svg_element_from_id('CurrentNote2'); + if (display_note1) + display_note1.childNodes[0].nodeValue = note1; + if (display_note2) + display_note2.childNodes[0].nodeValue = note2; + } + + __add_unit(unit, power_name, is_dislogged) { + const split_unit = unit.split(/ +/); + const unit_type = split_unit[0]; + const loc = split_unit[1]; + const dislogged_type = is_dislogged ? 'disl' : 'unit'; + const symbol = unit_type === 'F' ? FLEET : ARMY; + const loc_x = offset(this.metadata.coord[loc][dislogged_type][0], -11.5); + const loc_y = offset(this.metadata.coord[loc][dislogged_type][1], -10.0); + // Helpful link about creating SVG elements: https://stackoverflow.com/a/25949237 + const node = document.createElementNS("http://www.w3.org/2000/svg", 'use'); + node.setAttributeNS('http://www.w3.org/1999/xlink', 'href', '#' + this.__hashed_id((is_dislogged ? 'Dislodged' : '') + symbol)); + node.setAttribute('x', loc_x); + node.setAttribute('y', loc_y); + node.setAttribute('height', this.metadata.symbol_size[symbol][0]); + node.setAttribute('width', this.metadata.symbol_size[symbol][1]); + node.setAttribute('class', 'unit' + power_name.toLowerCase()); + node.setAttribute('diplomacyUnit', loc); + const parent_node = this.__svg_element_from_id(is_dislogged ? 'DislodgedUnitLayer' : 'UnitLayer'); + if (parent_node) + parent_node.appendChild(node); + } + + __add_supply_center(loc, power_name) { + const symbol = 'SupplyCenter'; + const loc_x = offset(this.metadata.coord[loc]['sc'][0], -8.5); + const loc_y = offset(this.metadata.coord[loc]['sc'][1], -11.0); + const node = document.createElementNS("http://www.w3.org/2000/svg", 'use'); + node.setAttributeNS('http://www.w3.org/1999/xlink', 'href', '#' + this.__hashed_id(symbol)); + node.setAttribute('x', loc_x); + node.setAttribute('y', loc_y); + node.setAttribute('height', this.metadata.symbol_size[symbol][0]); + node.setAttribute('width', this.metadata.symbol_size[symbol][1]); + node.setAttribute('class', power_name ? ('sc' + power_name.toLowerCase()) : 'scnopower'); + const parent_node = this.__svg_element_from_id('SupplyCenterLayer'); + if (parent_node) + parent_node.appendChild(node); + } + + __set_influence(loc, power_name) { + loc = loc.toUpperCase().substr(0, 3); + if (!['LAND', 'COAST'].includes(this.mapData.getProvince(loc).type)) + return; + const path = this.__svg_element_from_id('_' + loc.toLowerCase()); + if (!path || path.nodeName !== 'path') { + throw new Error(`Unable to find SVG path for loc ${loc}, got ${path ? path.nodeName : '(nothing)'}`); + } + path.setAttribute('class', power_name ? power_name.toLowerCase() : 'nopower'); + } + + issueHoldOrder(loc, power_name) { + const polygon_coord = []; + const loc_x = offset(this.metadata['coord'][loc]['unit'][0], 8.5); + const loc_y = offset(this.metadata['coord'][loc]['unit'][1], 9.5); + for (let ofs of [ + [13.8, -33.3], [33.3, -13.8], [33.3, 13.8], [13.8, 33.3], [-13.8, 33.3], + [-33.3, 13.8], [-33.3, -13.8], [-13.8, -33.3]] + ) { + polygon_coord.push(offset(loc_x, ofs[0]) + ',' + offset(loc_y, ofs[1])); + } + const g_node = document.createElementNS("http://www.w3.org/2000/svg", 'g'); + const poly_1 = document.createElementNS("http://www.w3.org/2000/svg", 'polygon'); + const poly_2 = document.createElementNS("http://www.w3.org/2000/svg", 'polygon'); + poly_1.setAttribute('stroke-width', '10'); + poly_1.setAttribute('class', 'varwidthshadow'); + poly_1.setAttribute('points', polygon_coord.join(' ')); + poly_2.setAttribute('stroke-width', '6'); + poly_2.setAttribute('class', 'varwidthorder'); + poly_2.setAttribute('points', polygon_coord.join(' ')); + poly_2.setAttribute('stroke', this.metadata['color'][power_name]); + g_node.appendChild(poly_1); + g_node.appendChild(poly_2); + const orderLayer = this.__svg_element_from_id('Layer1'); + if (!orderLayer) + throw new Error(`Unable to find svg order layer.`); + orderLayer.appendChild(g_node); + } + + issueMoveOrder(src_loc, dest_loc, power_name) { + let src_loc_x = 0; + let src_loc_y = 0; + const phaseType = this.game.getPhaseType(); + if (phaseType === 'R') { + src_loc_x = offset(this.metadata.coord[src_loc]['unit'][0], -2.5); + src_loc_y = offset(this.metadata.coord[src_loc]['unit'][1], -2.5); + } else { + src_loc_x = offset(this.metadata.coord[src_loc]['unit'][0], 10); + src_loc_y = offset(this.metadata.coord[src_loc]['unit'][1], 10); + } + let dest_loc_x = offset(this.metadata.coord[dest_loc]['unit'][0], 10); + let dest_loc_y = offset(this.metadata.coord[dest_loc]['unit'][1], 10); + + // Adjusting destination + const delta_x = parseFloat(dest_loc_x) - parseFloat(src_loc_x); + const delta_y = parseFloat(dest_loc_y) - parseFloat(src_loc_y); + const vector_length = Math.sqrt(delta_x * delta_x + delta_y * delta_y); + dest_loc_x = '' + Math.round((parseFloat(src_loc_x) + (vector_length - 30.) / vector_length * delta_x) * 100.) / 100.; + dest_loc_y = '' + Math.round((parseFloat(src_loc_y) + (vector_length - 30.) / vector_length * delta_y) * 100.) / 100.; + + // Creating nodes. + const g_node = document.createElementNS("http://www.w3.org/2000/svg", 'g'); + const line_with_shadow = document.createElementNS("http://www.w3.org/2000/svg", 'line'); + const line_with_arrow = document.createElementNS("http://www.w3.org/2000/svg", 'line'); + line_with_shadow.setAttribute('x1', src_loc_x); + line_with_shadow.setAttribute('y1', src_loc_y); + line_with_shadow.setAttribute('x2', dest_loc_x); + line_with_shadow.setAttribute('y2', dest_loc_y); + line_with_shadow.setAttribute('class', 'varwidthshadow'); + line_with_shadow.setAttribute('stroke-width', '10'); + line_with_arrow.setAttribute('x1', src_loc_x); + line_with_arrow.setAttribute('y1', src_loc_y); + line_with_arrow.setAttribute('x2', dest_loc_x); + line_with_arrow.setAttribute('y2', dest_loc_y); + line_with_arrow.setAttribute('class', 'varwidthorder'); + line_with_arrow.setAttribute('marker-end', 'url(#' + this.__hashed_id('arrow') + ')'); + line_with_arrow.setAttribute('stroke', this.metadata.color[power_name]); + line_with_arrow.setAttribute('stroke-width', '6'); + g_node.appendChild(line_with_shadow); + g_node.appendChild(line_with_arrow); + const orderLayer = this.__svg_element_from_id('Layer1'); + if (!orderLayer) + throw new Error(`Unable to find svg order layer.`); + orderLayer.appendChild(g_node); + } + + issueSupportMoveOrder(loc, src_loc, dest_loc, power_name) { + const loc_x = offset(this.metadata['coord'][loc]['unit'][0], 10); + const loc_y = offset(this.metadata['coord'][loc]['unit'][1], 10); + const src_loc_x = offset(this.metadata['coord'][src_loc]['unit'][0], 10); + const src_loc_y = offset(this.metadata['coord'][src_loc]['unit'][1], 10); + let dest_loc_x = offset(this.metadata['coord'][dest_loc]['unit'][0], 10); + let dest_loc_y = offset(this.metadata['coord'][dest_loc]['unit'][1], 10); + + // Adjusting destination + const delta_x = parseFloat(dest_loc_x) - parseFloat(src_loc_x); + const delta_y = parseFloat(dest_loc_y) - parseFloat(src_loc_y); + const vector_length = Math.sqrt(delta_x * delta_x + delta_y * delta_y); + dest_loc_x = '' + Math.round((parseFloat(src_loc_x) + (vector_length - 30.) / vector_length * delta_x) * 100.) / 100.; + dest_loc_y = '' + Math.round((parseFloat(src_loc_y) + (vector_length - 30.) / vector_length * delta_y) * 100.) / 100.; + + const g_node = document.createElementNS("http://www.w3.org/2000/svg", 'g'); + const path_with_shadow = document.createElementNS("http://www.w3.org/2000/svg", 'path'); + const path_with_arrow = document.createElementNS("http://www.w3.org/2000/svg", 'path'); + path_with_shadow.setAttribute('class', 'shadowdash'); + path_with_shadow.setAttribute('d', `M ${loc_x},${loc_y} C ${src_loc_x},${src_loc_y} ${src_loc_x},${src_loc_y} ${dest_loc_x},${dest_loc_y}`); + path_with_arrow.setAttribute('class', 'supportorder'); + path_with_arrow.setAttribute('marker-end', 'url(#' + this.__hashed_id('arrow') + ')'); + path_with_arrow.setAttribute('stroke', this.metadata['color'][power_name]); + path_with_arrow.setAttribute('d', `M ${loc_x},${loc_y} C ${src_loc_x},${src_loc_y} ${src_loc_x},${src_loc_y} ${dest_loc_x},${dest_loc_y}`); + g_node.appendChild(path_with_shadow); + g_node.appendChild(path_with_arrow); + const orderLayer = this.__svg_element_from_id('Layer2'); + if (!orderLayer) + throw new Error(`Unable to find svg order layer.`); + orderLayer.appendChild(g_node); + } + + issueSupportHoldOrder(loc, dest_loc, power_name) { + const loc_x = offset(this.metadata['coord'][loc]['unit'][0], 10); + const loc_y = offset(this.metadata['coord'][loc]['unit'][1], 10); + let dest_loc_x = offset(this.metadata['coord'][dest_loc]['unit'][0], 10); + let dest_loc_y = offset(this.metadata['coord'][dest_loc]['unit'][1], 10); + + const delta_x = parseFloat(dest_loc_x) - parseFloat(loc_x); + const delta_y = parseFloat(dest_loc_y) - parseFloat(loc_y); + const vector_length = Math.sqrt(delta_x * delta_x + delta_y * delta_y); + dest_loc_x = '' + Math.round((parseFloat(loc_x) + (vector_length - 35.) / vector_length * delta_x) * 100.) / 100.; + dest_loc_y = '' + Math.round((parseFloat(loc_y) + (vector_length - 35.) / vector_length * delta_y) * 100.) / 100.; + + const polygon_coord = []; + const poly_loc_x = offset(this.metadata['coord'][dest_loc]['unit'][0], 8.5); + const poly_loc_y = offset(this.metadata['coord'][dest_loc]['unit'][1], 9.5); + for (let ofs of [ + [15.9, -38.3], [38.3, -15.9], [38.3, 15.9], [15.9, 38.3], [-15.9, 38.3], [-38.3, 15.9], + [-38.3, -15.9], [-15.9, -38.3] + ]) { + polygon_coord.push(offset(poly_loc_x, ofs[0]) + ',' + offset(poly_loc_y, ofs[1])); + } + const g_node = document.createElementNS("http://www.w3.org/2000/svg", 'g'); + const shadow_line = document.createElementNS("http://www.w3.org/2000/svg", 'line'); + const support_line = document.createElementNS("http://www.w3.org/2000/svg", 'line'); + const shadow_poly = document.createElementNS("http://www.w3.org/2000/svg", 'polygon'); + const support_poly = document.createElementNS("http://www.w3.org/2000/svg", 'polygon'); + shadow_line.setAttribute('x1', loc_x); + shadow_line.setAttribute('y1', loc_y); + shadow_line.setAttribute('x2', dest_loc_x); + shadow_line.setAttribute('y2', dest_loc_y); + shadow_line.setAttribute('class', 'shadowdash'); + support_line.setAttribute('x1', loc_x); + support_line.setAttribute('y1', loc_y); + support_line.setAttribute('x2', dest_loc_x); + support_line.setAttribute('y2', dest_loc_y); + support_line.setAttribute('class', 'supportorder'); + support_line.setAttribute('stroke', this.metadata['color'][power_name]); + shadow_poly.setAttribute('class', 'shadowdash'); + shadow_poly.setAttribute('points', polygon_coord.join(' ')); + support_poly.setAttribute('class', 'supportorder'); + support_poly.setAttribute('points', polygon_coord.join(' ')); + support_poly.setAttribute('stroke', this.metadata['color'][power_name]); + g_node.appendChild(shadow_line); + g_node.appendChild(support_line); + g_node.appendChild(shadow_poly); + g_node.appendChild(support_poly); + const orderLayer = this.__svg_element_from_id('Layer2'); + if (!orderLayer) + throw new Error(`Unable to find svg order layer.`); + orderLayer.appendChild(g_node); + } + + issueConvoyOrder(loc, src_loc, dest_loc, power_name) { + const loc_x = offset(this.metadata['coord'][loc]['unit'][0], 10); + const loc_y = offset(this.metadata['coord'][loc]['unit'][1], 10); + const src_loc_x = offset(this.metadata['coord'][src_loc]['unit'][0], 10); + const src_loc_y = offset(this.metadata['coord'][src_loc]['unit'][1], 10); + let dest_loc_x = offset(this.metadata['coord'][dest_loc]['unit'][0], 10); + let dest_loc_y = offset(this.metadata['coord'][dest_loc]['unit'][1], 10); + + const src_delta_x = parseFloat(src_loc_x) - parseFloat(loc_x); + const src_delta_y = parseFloat(src_loc_y) - parseFloat(loc_y); + const src_vector_length = Math.sqrt(src_delta_x * src_delta_x + src_delta_y * src_delta_y); + const src_loc_x_1 = '' + Math.round((parseFloat(loc_x) + (src_vector_length - 30.) / src_vector_length * src_delta_x) * 100.) / 100.; + const src_loc_y_1 = '' + Math.round((parseFloat(loc_y) + (src_vector_length - 30.) / src_vector_length * src_delta_y) * 100.) / 100.; + + let dest_delta_x = parseFloat(src_loc_x) - parseFloat(dest_loc_x); + let dest_delta_y = parseFloat(src_loc_y) - parseFloat(dest_loc_y); + let dest_vector_length = Math.sqrt(dest_delta_x * dest_delta_x + dest_delta_y * dest_delta_y); + const src_loc_x_2 = '' + Math.round((parseFloat(dest_loc_x) + (dest_vector_length - 30.) / dest_vector_length * dest_delta_x) * 100.) / 100.; + const src_loc_y_2 = '' + Math.round((parseFloat(dest_loc_y) + (dest_vector_length - 30.) / dest_vector_length * dest_delta_y) * 100.) / 100.; + + dest_delta_x = parseFloat(dest_loc_x) - parseFloat(src_loc_x); + dest_delta_y = parseFloat(dest_loc_y) - parseFloat(src_loc_y); + dest_vector_length = Math.sqrt(dest_delta_x * dest_delta_x + dest_delta_y * dest_delta_y); + dest_loc_x = '' + Math.round((parseFloat(src_loc_x) + (dest_vector_length - 30.) / dest_vector_length * dest_delta_x) * 100.) / 100.; + dest_loc_y = '' + Math.round((parseFloat(src_loc_y) + (dest_vector_length - 30.) / dest_vector_length * dest_delta_y) * 100.) / 100.; + + const triangle_coord = []; + const triangle_loc_x = offset(this.metadata['coord'][src_loc]['unit'][0], 10); + const triangle_loc_y = offset(this.metadata['coord'][src_loc]['unit'][1], 10); + for (let ofs of [[0, -38.3], [33.2, 19.1], [-33.2, 19.1]]) { + triangle_coord.push(offset(triangle_loc_x, ofs[0]) + ',' + offset(triangle_loc_y, ofs[1])); + } + + const g_node = document.createElementNS("http://www.w3.org/2000/svg", 'g'); + const src_shadow_line = document.createElementNS("http://www.w3.org/2000/svg", 'line'); + const dest_shadow_line = document.createElementNS("http://www.w3.org/2000/svg", 'line'); + const src_convoy_line = document.createElementNS("http://www.w3.org/2000/svg", 'line'); + const dest_convoy_line = document.createElementNS("http://www.w3.org/2000/svg", 'line'); + const shadow_poly = document.createElementNS("http://www.w3.org/2000/svg", 'polygon'); + const convoy_poly = document.createElementNS("http://www.w3.org/2000/svg", 'polygon'); + src_shadow_line.setAttribute('x1', loc_x); + src_shadow_line.setAttribute('y1', loc_y); + src_shadow_line.setAttribute('x2', src_loc_x_1); + src_shadow_line.setAttribute('y2', src_loc_y_1); + src_shadow_line.setAttribute('class', 'shadowdash'); + + dest_shadow_line.setAttribute('x1', src_loc_x_2); + dest_shadow_line.setAttribute('y1', src_loc_y_2); + dest_shadow_line.setAttribute('x2', dest_loc_x); + dest_shadow_line.setAttribute('y2', dest_loc_y); + dest_shadow_line.setAttribute('class', 'shadowdash'); + + src_convoy_line.setAttribute('x1', loc_x); + src_convoy_line.setAttribute('y1', loc_y); + src_convoy_line.setAttribute('x2', src_loc_x_1); + src_convoy_line.setAttribute('y2', src_loc_y_1); + src_convoy_line.setAttribute('class', 'convoyorder'); + src_convoy_line.setAttribute('stroke', this.metadata['color'][power_name]); + + dest_convoy_line.setAttribute('x1', src_loc_x_2); + dest_convoy_line.setAttribute('y1', src_loc_y_2); + dest_convoy_line.setAttribute('x2', dest_loc_x); + dest_convoy_line.setAttribute('y2', dest_loc_y); + dest_convoy_line.setAttribute('class', 'convoyorder'); + dest_convoy_line.setAttribute('marker-end', 'url(#' + this.__hashed_id('arrow') + ')'); + + dest_convoy_line.setAttribute('stroke', this.metadata['color'][power_name]); + + shadow_poly.setAttribute('class', 'shadowdash'); + shadow_poly.setAttribute('points', triangle_coord.join(' ')); + + convoy_poly.setAttribute('class', 'convoyorder'); + convoy_poly.setAttribute('points', triangle_coord.join(' ')); + convoy_poly.setAttribute('stroke', this.metadata['color'][power_name]); + + g_node.appendChild(src_shadow_line); + g_node.appendChild(dest_shadow_line); + g_node.appendChild(src_convoy_line); + g_node.appendChild(dest_convoy_line); + g_node.appendChild(shadow_poly); + g_node.appendChild(convoy_poly); + + const orderLayer = this.__svg_element_from_id('Layer2'); + if (!orderLayer) + throw new Error(`Unable to find svg order layer.`); + orderLayer.appendChild(g_node); + } + + issueBuildOrder(unit_type, loc, power_name) { + const loc_x = offset(this.metadata['coord'][loc]['unit'][0], -11.5); + const loc_y = offset(this.metadata['coord'][loc]['unit'][1], -10.); + const build_loc_x = offset(this.metadata['coord'][loc]['unit'][0], -20.5); + const build_loc_y = offset(this.metadata['coord'][loc]['unit'][1], -20.5); + const symbol = unit_type === 'A' ? ARMY : FLEET; + const build_symbol = 'BuildUnit'; + const g_node = document.createElementNS("http://www.w3.org/2000/svg", 'g'); + const symbol_node = document.createElementNS("http://www.w3.org/2000/svg", 'use'); + const build_node = document.createElementNS("http://www.w3.org/2000/svg", 'use'); + symbol_node.setAttribute('x', loc_x); + symbol_node.setAttribute('y', loc_y); + symbol_node.setAttribute('height', this.metadata['symbol_size'][symbol][0]); + symbol_node.setAttribute('width', this.metadata['symbol_size'][symbol][1]); + symbol_node.setAttributeNS('http://www.w3.org/1999/xlink', 'href', '#' + this.__hashed_id(symbol)); + symbol_node.setAttribute('class', `unit${power_name.toLowerCase()}`); + build_node.setAttribute('x', build_loc_x); + build_node.setAttribute('y', build_loc_y); + build_node.setAttribute('height', this.metadata['symbol_size'][build_symbol][0]); + build_node.setAttribute('width', this.metadata['symbol_size'][build_symbol][1]); + build_node.setAttributeNS('http://www.w3.org/1999/xlink', 'href', '#' + this.__hashed_id(build_symbol)); + g_node.appendChild(build_node); + g_node.appendChild(symbol_node); + const orderLayer = this.__svg_element_from_id('HighestOrderLayer'); + if (!orderLayer) + throw new Error(`Unable to find svg order layer.`); + orderLayer.appendChild(g_node); + } + + issueDisbandOrder(loc) { + const phaseType = this.game.getPhaseType(); + let loc_x = 0; + let loc_y = 0; + if (phaseType === 'R') { + loc_x = offset(this.metadata['coord'][loc]['unit'][0], -29.); + loc_y = offset(this.metadata['coord'][loc]['unit'][1], -27.5); + } else { + loc_x = offset(this.metadata['coord'][loc]['unit'][0], -16.5); + loc_y = offset(this.metadata['coord'][loc]['unit'][1], -15.); + } + const symbol = 'RemoveUnit'; + const g_node = document.createElementNS("http://www.w3.org/2000/svg", 'g'); + const symbol_node = document.createElementNS("http://www.w3.org/2000/svg", 'use'); + symbol_node.setAttribute('x', loc_x); + symbol_node.setAttribute('y', loc_y); + symbol_node.setAttribute('height', this.metadata['symbol_size'][symbol][0]); + symbol_node.setAttribute('width', this.metadata['symbol_size'][symbol][1]); + symbol_node.setAttributeNS('http://www.w3.org/1999/xlink', 'href', '#' + this.__hashed_id(symbol)); + g_node.appendChild(symbol_node); + const orderLayer = this.__svg_element_from_id('HighestOrderLayer'); + if (!orderLayer) + throw new Error(`Unable to find svg order layer.`); + orderLayer.appendChild(g_node); + } + + clear() { + this.__set_note('', ''); + $(`#${this.__hashed_id('DislodgedUnitLayer')} use`).remove(); + $(`#${this.__hashed_id('UnitLayer')} use`).remove(); + $(`#${this.__hashed_id('SupplyCenterLayer')} use`).remove(); + $(`#${this.__hashed_id('Layer1')} g`).remove(); + $(`#${this.__hashed_id('Layer2')} g`).remove(); + $(`#${this.__hashed_id('HighestOrderLayer')} g`).remove(); + this.__restore_initial_influences(); + } + + render(includeOrders, orders) { + // Setting phase and note. + const nb_centers = []; + for (let power of Object.values(this.game.powers)) { + if (!power.isEliminated()) + nb_centers.push([power.name.substr(0, 3), power.centers.length]); + } + // Sort nb_centers by descending number of centers. + nb_centers.sort((a, b) => { + return -(a[1] - b[1]) || a[0].localeCompare(b[0]); + }); + const nb_centers_per_power = nb_centers.map((couple) => (couple[0] + ': ' + couple[1])).join(' '); + this.__set_current_phase(); + this.__set_note(nb_centers_per_power, this.game.note); + + // Adding units, supply centers, influence and orders. + const scs = new Set(this.mapData.supplyCenters); + for (let power of Object.values(this.game.powers)) { + for (let unit of power.units) + this.__add_unit(unit, power.name, false); + for (let unit of Object.keys(power.retreats)) + this.__add_unit(unit, power.name, true); + for (let center of power.centers) { + this.__add_supply_center(center, power.name); + this.__set_influence(center, power.name); + scs.delete(center); + } + if (!power.isEliminated()) { + for (let loc of power.influence) { + if (!this.mapData.supplyCenters.has(loc)) + this.__set_influence(loc, power.name); + } + } + + if (includeOrders) { + const powerOrders = (orders && orders.hasOwnProperty(power.name) && orders[power.name]) || []; + for (let order of powerOrders) { + const tokens = order.split(/ +/); + if (!tokens || tokens.length < 3) + continue; + const unit_loc = tokens[1]; + if (tokens[2] === 'H') + this.issueHoldOrder(unit_loc, power.name); + else if (tokens[2] === '-') { + const destLoc = tokens[tokens.length - (tokens[tokens.length - 1] === 'VIA' ? 2 : 1)]; + this.issueMoveOrder(unit_loc, destLoc, power.name); + } else if (tokens[2] === 'S') { + const destLoc = tokens[tokens.length - 1]; + if (tokens.includes('-')) { + const srcLoc = tokens[4]; + this.issueSupportMoveOrder(unit_loc, srcLoc, destLoc, power.name); + } else { + this.issueSupportHoldOrder(unit_loc, destLoc, power.name); + } + } else if (tokens[2] === 'C') { + const srcLoc = tokens[4]; + const destLoc = tokens[tokens.length - 1]; + if ((srcLoc !== destLoc) && (tokens.includes('-'))) { + this.issueConvoyOrder(unit_loc, srcLoc, destLoc, power.name); + } + } else if (tokens[2] === 'B') { + this.issueBuildOrder(tokens[0], unit_loc, power.name); + } else if (tokens[2] === 'D') { + this.issueDisbandOrder(unit_loc); + } else if (tokens[2] === 'R') { + const srcLoc = tokens[1]; + const destLoc = tokens[3]; + this.issueMoveOrder(srcLoc, destLoc, power.name); + } else { + throw new Error(`Unknown error to render (${order}).`); + } + } + } + } + // Adding remaining supply centers. + for (let remainingCenter of scs) + this.__add_supply_center(remainingCenter, null); + } + + update(game, mapData, showOrders, orders) { + this.game = game; + this.mapData = mapData; + this.clear(); + this.render(showOrders, orders); + } +} diff --git a/diplomacy/web/src/gui/diplomacy/utils/dipStorage.jsx b/diplomacy/web/src/gui/diplomacy/utils/dipStorage.jsx new file mode 100644 index 0000000..db5baad --- /dev/null +++ b/diplomacy/web/src/gui/diplomacy/utils/dipStorage.jsx @@ -0,0 +1,140 @@ +// ============================================================================== +// Copyright (C) 2019 - Philip Paquette, Steven Bocco +// +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU Affero General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) any +// later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +// FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +// details. +// +// You should have received a copy of the GNU Affero General Public License along +// with this program. If not, see <https://www.gnu.org/licenses/>. +// ============================================================================== +/* DipStorage scheme: +global +- connection + - username + - hostname + - port + - showServerFields +users +- (username) + - games + - (game_id) + - phase: string + - local_orders: {power_name => [orders]} +*/ + +let STORAGE = null; + +export class DipStorage { + static load() { + if (!STORAGE) { + const global = window.localStorage.global; + const users = window.localStorage.users; + STORAGE = { + global: (global && JSON.parse(global)) || { + connection: { + username: null, + hostname: null, + port: null, + showServerFields: null + } + }, + users: (users && JSON.parse(users)) || {} + }; + } + } + + static save() { + if (STORAGE) { + window.localStorage.global = JSON.stringify(STORAGE.global); + window.localStorage.users = JSON.stringify(STORAGE.users); + } + } + + static getConnectionForm() { + DipStorage.load(); + return Object.assign({}, STORAGE.global.connection); + } + + static getUserGames(username) { + DipStorage.load(); + if (STORAGE.users[username]) + return Object.keys(STORAGE.users[username].games); + return null; + } + + static getUserGameOrders(username, gameID, gamePhase) { + DipStorage.load(); + if (STORAGE.users[username] && STORAGE.users[username].games[gameID] + && STORAGE.users[username].games[gameID].phase === gamePhase) + return Object.assign({}, STORAGE.users[username].games[gameID].local_orders); + return null; + } + + static setConnectionUsername(username) { + DipStorage.load(); + STORAGE.global.connection.username = username; + DipStorage.save(); + } + + static setConnectionHostname(hostname) { + DipStorage.load(); + STORAGE.global.connection.hostname = hostname; + DipStorage.save(); + } + + static setConnectionPort(port) { + DipStorage.load(); + STORAGE.global.connection.port = port; + DipStorage.save(); + } + + static setConnectionshowServerFields(showServerFields) { + DipStorage.load(); + STORAGE.global.connection.showServerFields = showServerFields; + DipStorage.save(); + } + + static addUserGame(username, gameID) { + DipStorage.load(); + if (!STORAGE.users[username]) + STORAGE.users[username] = {games: {}}; + if (!STORAGE.users[username].games[gameID]) + STORAGE.users[username].games[gameID] = {phase: null, local_orders: {}}; + DipStorage.save(); + } + + static addUserGameOrders(username, gameID, gamePhase, powerName, orders) { + DipStorage.addUserGame(username, gameID); + if (STORAGE.users[username].games[gameID].phase !== gamePhase) + STORAGE.users[username].games[gameID] = {phase: null, local_orders: {}}; + STORAGE.users[username].games[gameID].phase = gamePhase; + STORAGE.users[username].games[gameID].local_orders[powerName] = orders; + DipStorage.save(); + } + + static removeUserGame(username, gameID) { + DipStorage.load(); + if (STORAGE.users[username] && STORAGE.users[username].games[gameID]) { + delete STORAGE.users[username].games[gameID]; + DipStorage.save(); + } + } + + static clearUserGameOrders(username, gameID, powerName) { + DipStorage.addUserGame(username, gameID); + if (powerName) { + if (STORAGE.users[username].games[gameID].local_orders[powerName]) + delete STORAGE.users[username].games[gameID].local_orders[powerName]; + } else { + STORAGE.users[username].games[gameID] = {phase: null, local_orders: {}}; + } + DipStorage.save(); + } +} diff --git a/diplomacy/web/src/gui/diplomacy/utils/inline_game_view.jsx b/diplomacy/web/src/gui/diplomacy/utils/inline_game_view.jsx new file mode 100644 index 0000000..0ada4c9 --- /dev/null +++ b/diplomacy/web/src/gui/diplomacy/utils/inline_game_view.jsx @@ -0,0 +1,129 @@ +// ============================================================================== +// Copyright (C) 2019 - Philip Paquette, Steven Bocco +// +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU Affero General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) any +// later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +// FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +// details. +// +// You should have received a copy of the GNU Affero General Public License along +// with this program. If not, see <https://www.gnu.org/licenses/>. +// ============================================================================== +import React from "react"; +import {Button} from "../../core/widgets"; +import {JoinForm} from "../forms/join_form"; +import {STRINGS} from "../../../diplomacy/utils/strings"; + +export class InlineGameView { + constructor(page, gameData) { + this.page = page; + this.game = gameData; + this.get = this.get.bind(this); + this.joinGame = this.joinGame.bind(this); + this.showGame = this.showGame.bind(this); + } + + joinGame(formData) { + const form = { + power_name: formData[`power_name_${this.game.game_id}`], + registration_password: formData[`registration_password_${this.game.game_id}`] + }; + if (!form.power_name) + form.power_name = null; + if (!form.registration_password) + form.registration_password = null; + form.game_id = this.game.game_id; + this.page.channel.joinGame(form) + .then((networkGame) => { + this.game = networkGame.local; + this.page.addToMyGames(this.game); + return networkGame.getAllPossibleOrders(); + }) + .then(allPossibleOrders => { + this.game.setPossibleOrders(allPossibleOrders); + this.page.loadGame(this.game, {success: 'Game joined.'}); + }) + .catch((error) => { + this.page.error('Error when joining game ' + this.game.game_id + ': ' + error); + }); + } + + showGame() { + this.page.loadGame(this.game); + } + + getJoinUI() { + if (this.game.role) { + // Game already joined. + return ( + <div className={'games-form'}> + <Button key={'button-show-' + this.game.game_id} title={'show'} onClick={this.showGame}/> + <Button key={'button-leave-' + this.game.game_id} title={'leave'} + onClick={() => this.page.leaveGame(this.game.game_id)}/> + </div> + ); + } else { + // Game not yet joined. + return <JoinForm key={this.game.game_id} game_id={this.game.game_id} powers={this.game.controlled_powers} + onSubmit={this.joinGame}/>; + } + } + + getMyGamesButton() { + if (this.page.hasMyGame(this.game.game_id)) { + if (!this.game.client) { + // Game in My Games and not joined. We can remove it. + return <Button key={`my-game-remove`} title={'Remove from My Games'} + onClick={() => this.page.removeFromMyGames(this.game.game_id)}/>; + } + } else { + // Game not in My Games, we can add it. + return <Button key={`my-game-add`} title={'Add to My Games'} + onClick={() => this.page.addToMyGames(this.game)}/>; + } + return ''; + } + + get(name) { + if (name === 'players') { + return `${this.game.n_players} / ${this.game.n_controls}`; + } + if (name === 'rights') { + const elements = []; + if (this.game.observer_level) { + let levelName = ''; + if (this.game.observer_level === STRINGS.MASTER_TYPE) + levelName = 'master'; + else if (this.game.observer_level === STRINGS.OMNISCIENT_TYPE) + levelName = 'omniscient'; + else + levelName = 'observer'; + elements.push((<p key={0}><strong>Observer right:</strong><br/>{levelName}</p>)); + } + if (this.game.controlled_powers && this.game.controlled_powers.length) { + const powers = this.game.controlled_powers.slice(); + powers.sort(); + elements.push(( + <div key={1}><strong>Currently handled power{powers.length === 1 ? '' : 's'}</strong></div>)); + for (let power of powers) + elements.push((<div key={power}>{power}</div>)); + } + return elements.length ? (<div>{elements}</div>) : ''; + } + if (name === 'rules') { + if (this.game.rules) + return <div>{this.game.rules.map(rule => <div key={rule}>{rule}</div>)}</div>; + return ''; + } + if (name === 'join') + return this.getJoinUI(); + if (name === 'my_games') + return this.getMyGamesButton(); + return this.game[name]; + } +} diff --git a/diplomacy/web/src/gui/diplomacy/utils/map_data.js b/diplomacy/web/src/gui/diplomacy/utils/map_data.js new file mode 100644 index 0000000..73d5338 --- /dev/null +++ b/diplomacy/web/src/gui/diplomacy/utils/map_data.js @@ -0,0 +1,98 @@ +// ============================================================================== +// Copyright (C) 2019 - Philip Paquette, Steven Bocco +// +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU Affero General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) any +// later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +// FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +// details. +// +// You should have received a copy of the GNU Affero General Public License along +// with this program. If not, see <https://www.gnu.org/licenses/>. +// ============================================================================== +import {Province} from "./province"; + +export class MapData { + constructor(mapInfo, game) { + // mapInfo: {powers: [], supply_centers: [], aliases: {alias: name}, loc_type: {loc => type}, loc_abut: {loc => [abuts]}} + // game: a NetworkGame object. + this.game = game; + this.powers = new Set(mapInfo.powers); + this.supplyCenters = new Set(mapInfo.supply_centers); + this.aliases = Object.assign({}, mapInfo.aliases); + this.provinces = {}; + for (let entry of Object.entries(mapInfo.loc_type)) { + const provinceName = entry[0]; + const provinceType = entry[1]; + this.provinces[provinceName] = new Province(provinceName, provinceType, this.supplyCenters.has(provinceName)); + } + for (let entry of Object.entries(mapInfo.loc_abut)) { + this.getProvince(entry[0]).setNeighbors(entry[1].map(name => this.getProvince(name))); + } + for (let province of Object.values(this.provinces)) { + province.setCoasts(this.provinces); + } + for (let power of Object.values(this.game.powers)) { + for (let center of power.centers) { + this.getProvince(center).setController(power.name, 'C'); + } + for (let loc of power.influence) { + this.getProvince(loc).setController(power.name, 'I'); + } + for (let unit of power.units) { + this.__add_unit(unit, power.name); + } + for (let unit of Object.keys(power.retreats)) { + this.__add_retreat(unit, power.name); + } + } + for (let entry of Object.entries(this.aliases)) { + const alias = entry[0]; + const provinceName = entry[1]; + const province = this.getProvince(provinceName); + if (province) + province.aliases.push(alias); + } + } + + __add_unit(unit, power_name) { + const splitUnit = unit.split(/ +/); + const unitType = splitUnit[0]; + const location = splitUnit[1]; + const province = this.getProvince(location); + province.setController(power_name, 'U'); + province.unit = unitType; + } + + __add_retreat(unit, power_name) { + const splitUnit = unit.split(/ +/); + const location = splitUnit[1]; + const province = this.getProvince(location); + province.retreatController = power_name; + province.retreatUnit = unit; + } + + getProvince(abbr) { + if (abbr === '') + return null; + if (abbr[0] === '_') + abbr = abbr.substr(1, 3); + if (!abbr) + return null; + if (!this.provinces.hasOwnProperty(abbr)) { + const firstLetter = abbr[0]; + if (firstLetter === firstLetter.toLowerCase()) { + abbr = abbr.toUpperCase(); + } else { + abbr = abbr.toLowerCase(); + } + } + if (!this.provinces.hasOwnProperty(abbr)) + abbr = this.aliases[abbr]; + return this.provinces[abbr]; + } +} diff --git a/diplomacy/web/src/gui/diplomacy/utils/order.js b/diplomacy/web/src/gui/diplomacy/utils/order.js new file mode 100644 index 0000000..e314b9f --- /dev/null +++ b/diplomacy/web/src/gui/diplomacy/utils/order.js @@ -0,0 +1,24 @@ +// ============================================================================== +// Copyright (C) 2019 - Philip Paquette, Steven Bocco +// +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU Affero General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) any +// later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +// FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +// details. +// +// You should have received a copy of the GNU Affero General Public License along +// with this program. If not, see <https://www.gnu.org/licenses/>. +// ============================================================================== +export class Order { + constructor(orderString, isLocal) { + const pieces = orderString.split(/ +/); + this.loc = pieces[1]; + this.order = orderString; + this.local = Boolean(isLocal); + } +} diff --git a/diplomacy/web/src/gui/diplomacy/utils/order_building.js b/diplomacy/web/src/gui/diplomacy/utils/order_building.js new file mode 100644 index 0000000..3758898 --- /dev/null +++ b/diplomacy/web/src/gui/diplomacy/utils/order_building.js @@ -0,0 +1,211 @@ +// ============================================================================== +// Copyright (C) 2019 - Philip Paquette, Steven Bocco +// +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU Affero General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) any +// later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +// FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +// details. +// +// You should have received a copy of the GNU Affero General Public License along +// with this program. If not, see <https://www.gnu.org/licenses/>. +// ============================================================================== +/*eslint no-unused-vars: ["error", { "args": "none" }]*/ + +function assertLength(expected, given) { + if (expected !== given) + throw new Error(`Length error: expected ${expected}, given ${given}.`); +} + +export class ProvinceCheck { + + static retreated(province, powerName) { + const retreatProvince = province.getRetreated(powerName); + if (!retreatProvince) + throw new Error(`No retreated location at province ${province.name}.`); + // No confusion possible, we select the only occupied location at this province. + return [retreatProvince.retreatUnit]; + } + + static present(province, powerName) { + let unit = null; + let presenceProvince = province.getOccupied(powerName); + if (presenceProvince) { + unit = `${presenceProvince.unit} ${presenceProvince.name}`; + } else { + presenceProvince = province.getRetreated(powerName); + if (!presenceProvince) + throw new Error(`No unit or retreat at province ${province.name}.`); + unit = presenceProvince.retreatUnit; + } + return [unit]; + } + + static occupied(province, powerName) { + const occupiedProvince = province.getOccupied(powerName); + if (!occupiedProvince) + throw new Error(`No occupied location at province ${province.name}.`); + // No confusion possible, we select the only occupied location at this province. + const unit = occupiedProvince.unit; + const name = occupiedProvince.name.toUpperCase(); + return [`${unit} ${name}`]; + } + + static occupiedByAny(province, unusedPowerName) { + return ProvinceCheck.occupied(province, null); + } + + static any(province, unusedPowerName) { + // There may be many locations available for a province (e.g. many coasts). + return province.getLocationNames(); + } + + static buildOrder(path) { + switch (path[0]) { + case 'H': + return ProvinceCheck.holdToString(path); + case 'M': + return ProvinceCheck.moveToString(path); + case 'V': + return ProvinceCheck.moveViaToString(path); + case 'S': + return ProvinceCheck.supportToString(path); + case 'C': + return ProvinceCheck.convoyToString(path); + case 'R': + return ProvinceCheck.retreatToString(path); + case 'D': + return ProvinceCheck.disbandToString(path); + case 'A': + return ProvinceCheck.buildArmyToString(path); + case 'F': + return ProvinceCheck.buildFleetToString(path); + default: + throw new Error('Unable to build order from path ' + JSON.stringify(path)); + } + } + + static holdToString(path) { + assertLength(2, path.length); + return `${path[1]} ${path[0]}`; + } + + static moveToString(path) { + assertLength(3, path.length); + return `${path[1]} - ${path[2]}`; + } + + static moveViaToString(path) { + return ProvinceCheck.moveToString(path) + ' VIA'; + } + + static supportToString(path) { + assertLength(4, path.length); + let order = `${path[1]} ${path[0]} ${path[2]}`; + if (path[2].substr(2) !== path[3]) + order += ` - ${path[3]}`; + return order; + } + + static convoyToString(path) { + assertLength(4, path.length); + return `${path[1]} ${path[0]} ${path[2]} - ${path[3]}`; + } + + static retreatToString(path) { + assertLength(3, path.length); + return `${path[1]} ${path[0]} ${path[2]}`; + } + + static disbandToString(path) { + assertLength(2, path.length); + return `${path[1]} ${path[0]}`; + } + + static buildArmyToString(path) { + assertLength(2, path.length); + return `${path[0]} ${path[1]} B`; + } + + static buildFleetToString(path) { + assertLength(2, path.length); + return `${path[0]} ${path[1]} B`; + } + +} + +export const ORDER_BUILDER = { + H: { + name: 'hold (H)', + steps: [ProvinceCheck.occupied] + }, + M: { + name: 'move (M)', + steps: [ProvinceCheck.occupied, ProvinceCheck.any] + }, + V: { + name: 'move VIA (V)', + steps: [ProvinceCheck.occupied, ProvinceCheck.any] + }, + S: { + name: 'support (S)', + steps: [ProvinceCheck.occupied, ProvinceCheck.occupiedByAny, ProvinceCheck.any] + }, + C: { + name: 'convoy (C)', + steps: [ProvinceCheck.occupied, ProvinceCheck.occupiedByAny, ProvinceCheck.any] + }, + R: { + name: 'retreat (R)', + steps: [ProvinceCheck.retreated, ProvinceCheck.any] + }, + D: { + name: 'disband (D)', + steps: [ProvinceCheck.present] + }, + A: { + name: 'build army (A)', + steps: [ProvinceCheck.any] + }, + F: { + name: 'build fleet (F)', + steps: [ProvinceCheck.any] + }, +}; + +export const POSSIBLE_ORDERS = { + // Allowed orders for movement phase step. + M: ['H', 'M', 'V', 'S', 'C'], + // Allowed orders for retreat phase step. + R: ['R', 'D'], + // Allowed orders for adjustment phase step. + A: ['D', 'A', 'F'], + sorting: { + M: {M: 0, V: 1, S: 2, C: 3, H: 4}, + R: {R: 0, D: 1}, + A: {A: 0, F: 1, D: 2} + }, + sortOrderTypes: function (arr, phaseType) { + arr.sort((a, b) => POSSIBLE_ORDERS.sorting[phaseType][a] - POSSIBLE_ORDERS.sorting[phaseType][b]); + } +}; + +export function extendOrderBuilding(powerName, orderType, currentOrderPath, location, onBuilding, onBuilt, onError) { + const selectedPath = [orderType].concat(currentOrderPath, location); + if (selectedPath.length - 1 < ORDER_BUILDER[orderType].steps.length) { + // Checker OK, update. + onBuilding(powerName, selectedPath); + } else { + try { + // Order created. + const orderString = ProvinceCheck.buildOrder(selectedPath); + onBuilt(powerName, orderString); + } catch (error) { + onError(error.toString()); + } + } +} diff --git a/diplomacy/web/src/gui/diplomacy/utils/power_view.jsx b/diplomacy/web/src/gui/diplomacy/utils/power_view.jsx new file mode 100644 index 0000000..1796b1b --- /dev/null +++ b/diplomacy/web/src/gui/diplomacy/utils/power_view.jsx @@ -0,0 +1,59 @@ +// ============================================================================== +// Copyright (C) 2019 - Philip Paquette, Steven Bocco +// +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU Affero General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) any +// later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +// FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +// details. +// +// You should have received a copy of the GNU Affero General Public License along +// with this program. If not, see <https://www.gnu.org/licenses/>. +// ============================================================================== +import {STRINGS} from "../../../diplomacy/utils/strings"; +import React from "react"; + +function getName(power) { + if (power.isEliminated()) + return <em><s>{power.name.toLowerCase()}</s> (eliminated)</em>; + return power.name; +} + +function getController(power) { + const controller = power.getController(); + return <span className={controller === STRINGS.DUMMY ? 'dummy' : 'controller'}>{controller}</span>; +} + +function getOrderFlag(power) { + const value = ['no', 'empty', 'yes'][power.order_is_set]; + return <span className={value}>{value}</span>; +} + +function getWaitFlag(power) { + return <span className={power.wait ? 'wait' : 'no-wait'}>{power.wait ? 'yes' : 'no'}</span>; +} + +const GETTERS = { + name: getName, + controller: getController, + order_is_set: getOrderFlag, + wait: getWaitFlag +}; + +export class PowerView { + constructor(power) { + this.power = power; + } + + static wrap(power) { + return new PowerView(power); + } + + get(key) { + return GETTERS[key](this.power); + } +} diff --git a/diplomacy/web/src/gui/diplomacy/utils/province.js b/diplomacy/web/src/gui/diplomacy/utils/province.js new file mode 100644 index 0000000..fc48ac7 --- /dev/null +++ b/diplomacy/web/src/gui/diplomacy/utils/province.js @@ -0,0 +1,117 @@ +// ============================================================================== +// Copyright (C) 2019 - Philip Paquette, Steven Bocco +// +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU Affero General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) any +// later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +// FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +// details. +// +// You should have received a copy of the GNU Affero General Public License along +// with this program. If not, see <https://www.gnu.org/licenses/>. +// ============================================================================== +const ProvinceType = { + WATER: 'WATER', + COAST: 'COAST', + PORT: 'PORT', + LAND: 'LAND' +}; + +export class Province { + constructor(name, type, isSupplyCenter) { + this.name = name; + this.type = type; + this.coasts = {}; + this.parent = null; + this.neighbors = {}; + this.isSupplyCenter = isSupplyCenter; + this.controller = null; // null or power name. + this.controlType = null; // null, C (center), I (influence) or U (unit). + this.unit = null; // null, A or F + this.retreatController = null; + this.retreatUnit = null; // null or `{unit type} {loc}` + this.aliases = []; + } + + compareControlType(a, b) { + const controlTypeLevels = {C: 0, I: 1, U: 2}; + return controlTypeLevels[a] - controlTypeLevels[b]; + } + + __set_controller(controller, controlType) { + this.controller = controller; + this.controlType = controlType; + for (let coast of Object.values(this.coasts)) + coast.setController(controller, controlType); + } + + setController(controller, controlType) { + if (!['C', 'I', 'U'].includes(controlType)) + throw new Error(`Invalid province control type (${controlType}), expected 'C', 'I' or 'U'.`); + if (this.controller) { + const controlTypeComparison = this.compareControlType(controlType, this.controlType); + if (controlTypeComparison === 0) + throw new Error(`Found 2 powers trying to control same province (${this.name}) with same ` + + `control type (${controlType} VS ${this.controlType}).`); + if (controlTypeComparison > 0) + this.__set_controller(controller, controlType); + } else + this.__set_controller(controller, controlType); + } + + setCoasts(provinces) { + const name = this.name.toUpperCase(); + for (let entry of Object.entries(provinces)) { + const pieces = entry[0].split(/[^A-Za-z0-9]+/); + if (pieces.length > 1 && pieces[0].toUpperCase() === name) { + this.coasts[entry[0]] = entry[1]; + entry[1].parent = this; + } + } + } + + setNeighbors(neighborProvinces) { + for (let province of neighborProvinces) + this.neighbors[province.name] = province; + } + + getLocationNames() { + const arr = Object.keys(this.coasts); + arr.splice(0, 0, this.name); + return arr; + } + + getOccupied(powerName) { + if (!this.controller) + return null; + if (powerName && this.controller !== powerName) + return null; + if (this.unit) + return this; + for (let coast of Object.values(this.coasts)) + if (coast.unit) + return coast; + return null; + } + + getRetreated(powerName) { + if (this.retreatController === powerName) + return this; + for (let coast of Object.values(this.coasts)) + if (coast.retreatController === powerName) + return coast; + return null; + } + + isCoast() { + return this.type === ProvinceType.COAST; + } + + isWater() { + return this.type === ProvinceType.WATER; + } +} diff --git a/diplomacy/web/src/gui/diplomacy/widgets/message_view.jsx b/diplomacy/web/src/gui/diplomacy/widgets/message_view.jsx new file mode 100644 index 0000000..045a108 --- /dev/null +++ b/diplomacy/web/src/gui/diplomacy/widgets/message_view.jsx @@ -0,0 +1,57 @@ +// ============================================================================== +// Copyright (C) 2019 - Philip Paquette, Steven Bocco +// +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU Affero General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) any +// later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +// FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +// details. +// +// You should have received a copy of the GNU Affero General Public License along +// with this program. If not, see <https://www.gnu.org/licenses/>. +// ============================================================================== +import React from "react"; +import {UTILS} from "../../../diplomacy/utils/utils"; +import PropTypes from 'prop-types'; + +export class MessageView extends React.Component { + // message + render() { + const message = this.props.message; + const owner = this.props.owner; + const id = this.props.id ? {id: this.props.id} : {}; + const messagesLines = message.message.replace('\r\n', '\n').replace('\r', '\n').split('\n'); + let onClick = null; + const classNames = ['game-message']; + if (owner === message.sender) + classNames.push('message-sender'); + else { + classNames.push('message-recipient'); + if (message.read || this.props.read) + classNames.push('message-read'); + onClick = this.props.onClick ? {onClick: () => this.props.onClick(message)} : {}; + } + return ( + <div className={'game-message-wrapper'} {...id}> + <div className={classNames.join(' ')} {...onClick}> + <div className={'message-header'}> + {message.sender} {UTILS.html.UNICODE_SMALL_RIGHT_ARROW} {message.recipient} + </div> + <div className={'message-content'}>{messagesLines.map((line, lineIndex) => <div key={lineIndex}>{line}</div>)}</div> + </div> + </div> + ); + } +} + +MessageView.propTypes = { + message: PropTypes.object, + owner: PropTypes.string, + onClick: PropTypes.func, + id: PropTypes.string, + read: PropTypes.bool +}; diff --git a/diplomacy/web/src/gui/diplomacy/widgets/power_order.jsx b/diplomacy/web/src/gui/diplomacy/widgets/power_order.jsx new file mode 100644 index 0000000..28a5421 --- /dev/null +++ b/diplomacy/web/src/gui/diplomacy/widgets/power_order.jsx @@ -0,0 +1,79 @@ +// ============================================================================== +// Copyright (C) 2019 - Philip Paquette, Steven Bocco +// +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU Affero General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) any +// later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +// FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +// details. +// +// You should have received a copy of the GNU Affero General Public License along +// with this program. If not, see <https://www.gnu.org/licenses/>. +// ============================================================================== +import React from "react"; +import {Button} from "../../core/widgets"; +import PropTypes from 'prop-types'; + +export class PowerOrder extends React.Component { + render() { + const orderEntries = this.props.orders ? Object.entries(this.props.orders) : null; + let display = null; + if (orderEntries) { + if (orderEntries.length) { + orderEntries.sort((a, b) => a[1].order.localeCompare(b[1].order)); + display = ( + <div className={'container order-list'}> + {orderEntries.map((entry, index) => ( + <div + className={`row order-entry entry-${1 + index % 2} ` + (entry[1].local ? 'local' : 'server')} + key={index}> + <div className={'col align-self-center order'}> + <span className={'order-string'}>{entry[1].order}</span> + {entry[1].local ? '' : <span className={'order-mark'}> [S]</span>} + </div> + <div className={'col remove-button'}> + <Button title={'-'} onClick={() => this.props.onRemove(this.props.name, entry[1])}/> + </div> + </div> + ))} + </div> + ); + } else if (this.props.serverCount === 0) { + display = (<div className={'empty-orders'}>Empty orders set</div>); + } else { + display = (<div className={'empty-orders'}>Local empty orders set</div>); + } + } else { + if (this.props.serverCount < 0) { + display = <div className={'no-orders'}>No orders!</div>; + } else if (this.props.serverCount === 0) { + display = <div className={'empty-orders'}>Empty orders set</div>; + } else { + display = (<div className={'empty-orders'}>Local empty orders set</div>); + } + } + return ( + <div className={'power-orders'}> + <div className={'title'}> + <span className={'name'}>{this.props.name}</span> + <span className={this.props.wait ? 'wait' : 'no-wait'}> + {(this.props.wait ? ' ' : ' not') + ' waiting'} + </span> + </div> + {display} + </div> + ); + } +} + +PowerOrder.propTypes = { + wait: PropTypes.bool, + name: PropTypes.string, + orders: PropTypes.object, + serverCount: PropTypes.number, + onRemove: PropTypes.func, +}; |