From 3805e131bbaf8a4fdf56217919d50e2b39b20c75 Mon Sep 17 00:00:00 2001 From: Taka Date: Thu, 10 Sep 2020 03:59:31 +0000 Subject: [PATCH] Added integration with the ASL Scenario Archive. --- .pylintrc | 3 +- vasl_templates/main_window.py | 12 +- vasl_templates/tools/check_connect_roar.py | 62 + vasl_templates/webapp/__init__.py | 26 +- .../webapp/config/logging.yaml.example | 2 +- .../webapp/data/asl-scenario-archive.json | 46 + .../data/default-template-pack/players.j2 | 55 +- vasl_templates/webapp/downloads.py | 155 +++ vasl_templates/webapp/main.py | 18 + vasl_templates/webapp/roar.py | 134 -- vasl_templates/webapp/scenarios.py | 405 ++++++ .../webapp/static/css/scenario-card.css | 87 ++ .../static/css/scenario-search-dialog.css | 71 + .../css/select-roar-scenario-dialog.css | 5 +- .../webapp/static/css/tabs-scenario.css | 41 +- vasl_templates/webapp/static/css/tabs.css | 2 +- .../static/imageZoom/jquery.imageZoom.css | 48 + .../static/imageZoom/jquery.imageZoom.min.js | 1 + .../static/imageZoom/jquery.imageZoom.png | Bin 0 -> 1755 bytes .../static/images/asl-scenario-archive.png | Bin 0 -> 13201 bytes .../webapp/static/images/connect-roar.png | Bin 0 -> 4778 bytes .../images/{gripper.png => gripper-horz.png} | Bin .../webapp/static/images/gripper-vert.png | Bin 0 -> 355 bytes .../webapp/static/images/icons/aslsk.png | Bin 0 -> 4965 bytes .../webapp/static/images/icons/deluxe.png | Bin 0 -> 3262 bytes .../webapp/static/images/icons/night.png | Bin 0 -> 2496 bytes .../webapp/static/images/icons/oba.png | Bin 0 -> 13852 bytes .../webapp/static/images/map-preview.png | Bin 0 -> 2467 bytes vasl_templates/webapp/static/lfa.js | 4 +- vasl_templates/webapp/static/main.js | 92 +- vasl_templates/webapp/static/roar.js | 390 ++---- vasl_templates/webapp/static/scenarios.js | 1201 +++++++++++++++++ vasl_templates/webapp/static/snippets.js | 27 +- vasl_templates/webapp/static/timer.js | 27 + vasl_templates/webapp/static/utils.js | 56 +- .../webapp/templates/check-roar.html | 120 -- vasl_templates/webapp/templates/index.html | 11 + .../webapp/templates/scenario-card.html | 153 +++ .../templates/scenario-info-dialog.html | 3 + .../webapp/templates/scenario-nat-report.html | 132 ++ .../templates/scenario-search-dialog.html | 34 + .../webapp/templates/tabs-scenario.html | 39 +- .../tests/fixtures/asl-scenario-archive.json | 137 ++ .../national-capabilities.json | 4 + .../default-template-pack/nationalities.json | 9 + .../data/default-template-pack/players.j2 | 4 +- .../tests/fixtures/roar-scenario-index.json | 51 +- vasl_templates/webapp/tests/remote.py | 17 +- .../webapp/tests/test_default_scenario.py | 1 + vasl_templates/webapp/tests/test_ob.py | 7 +- vasl_templates/webapp/tests/test_players.py | 11 +- vasl_templates/webapp/tests/test_roar.py | 148 -- .../webapp/tests/test_scenario_persistence.py | 12 +- .../webapp/tests/test_scenario_search.py | 756 +++++++++++ vasl_templates/webapp/tests/test_snippets.py | 8 +- .../webapp/tests/test_template_packs.py | 6 +- vasl_templates/webapp/tests/test_utils.py | 37 + vasl_templates/webapp/tests/test_vassal.py | 5 +- vasl_templates/webapp/tests/utils.py | 24 +- vasl_templates/webapp/utils.py | 59 + 60 files changed, 3868 insertions(+), 890 deletions(-) create mode 100755 vasl_templates/tools/check_connect_roar.py create mode 100644 vasl_templates/webapp/data/asl-scenario-archive.json create mode 100644 vasl_templates/webapp/downloads.py delete mode 100644 vasl_templates/webapp/roar.py create mode 100644 vasl_templates/webapp/scenarios.py create mode 100644 vasl_templates/webapp/static/css/scenario-card.css create mode 100644 vasl_templates/webapp/static/css/scenario-search-dialog.css create mode 100644 vasl_templates/webapp/static/imageZoom/jquery.imageZoom.css create mode 100644 vasl_templates/webapp/static/imageZoom/jquery.imageZoom.min.js create mode 100644 vasl_templates/webapp/static/imageZoom/jquery.imageZoom.png create mode 100644 vasl_templates/webapp/static/images/asl-scenario-archive.png create mode 100644 vasl_templates/webapp/static/images/connect-roar.png rename vasl_templates/webapp/static/images/{gripper.png => gripper-horz.png} (100%) create mode 100755 vasl_templates/webapp/static/images/gripper-vert.png create mode 100644 vasl_templates/webapp/static/images/icons/aslsk.png create mode 100644 vasl_templates/webapp/static/images/icons/deluxe.png create mode 100644 vasl_templates/webapp/static/images/icons/night.png create mode 100644 vasl_templates/webapp/static/images/icons/oba.png create mode 100644 vasl_templates/webapp/static/images/map-preview.png create mode 100644 vasl_templates/webapp/static/scenarios.js create mode 100644 vasl_templates/webapp/static/timer.js delete mode 100644 vasl_templates/webapp/templates/check-roar.html create mode 100644 vasl_templates/webapp/templates/scenario-card.html create mode 100644 vasl_templates/webapp/templates/scenario-info-dialog.html create mode 100644 vasl_templates/webapp/templates/scenario-nat-report.html create mode 100644 vasl_templates/webapp/templates/scenario-search-dialog.html create mode 100644 vasl_templates/webapp/tests/fixtures/asl-scenario-archive.json delete mode 100644 vasl_templates/webapp/tests/test_roar.py create mode 100644 vasl_templates/webapp/tests/test_scenario_search.py create mode 100644 vasl_templates/webapp/tests/test_utils.py diff --git a/.pylintrc b/.pylintrc index 21e1e15..dd63b3a 100644 --- a/.pylintrc +++ b/.pylintrc @@ -143,7 +143,8 @@ disable=print-statement, too-many-lines, duplicate-code, # can't get it to shut up about @pytest.mark.skipif's :-/ no-else-return, - len-as-condition + len-as-condition, + consider-using-enumerate # Enable the message, report, category or checker with the given id(s). You can # either give multiple identifier separated by comma (,) or put this option diff --git a/vasl_templates/main_window.py b/vasl_templates/main_window.py index 2b52150..ab121f0 100644 --- a/vasl_templates/main_window.py +++ b/vasl_templates/main_window.py @@ -13,7 +13,7 @@ from PyQt5.QtWebChannel import QWebChannel from PyQt5.QtGui import QDesktopServices, QIcon from PyQt5.QtCore import Qt, QUrl, QMargins, pyqtSlot, QVariant -from vasl_templates.webapp.config.constants import APP_NAME, IS_FROZEN +from vasl_templates.webapp.config.constants import APP_NAME, APP_VERSION, IS_FROZEN from vasl_templates.main import app_settings from vasl_templates.web_channel import WebChannelHandler from vasl_templates.utils import catch_exceptions @@ -29,6 +29,8 @@ class AppWebPage( QWebEnginePage ): """Called when a link is clicked.""" if url.host() in ("localhost","127.0.0.1"): return True + if not is_mainframe: + return True # nb: we get here if we're in a child frame (e.g. Google Maps) QDesktopServices.openUrl( url ) return False @@ -85,8 +87,8 @@ class MainWindow( QWidget ): if val : self.restoreGeometry( val ) else : - self.resize( 1050, 650 ) - self.setMinimumSize( 1050, 620 ) + self.resize( 1000, 650 ) + self.setMinimumSize( 1000, 620 ) # initialize the layout layout = QVBoxLayout( self ) @@ -105,6 +107,10 @@ class MainWindow( QWidget ): # initialize the web page # nb: we create an off-the-record profile to stop the view from using cached JS files :-/ profile = QWebEngineProfile( None, self._view ) + version = APP_NAME.lower().replace( " ", "-" ) + "/" + APP_VERSION[1:] + profile.setHttpUserAgent( + re.sub( r"QtWebEngine/\S+", version, profile.httpUserAgent() ) + ) page = AppWebPage( profile, self._view ) self._view.setPage( page ) diff --git a/vasl_templates/tools/check_connect_roar.py b/vasl_templates/tools/check_connect_roar.py new file mode 100755 index 0000000..cb0ad52 --- /dev/null +++ b/vasl_templates/tools/check_connect_roar.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 +""" Check how scenarios at the ASL Scenario Archive are connected to those at ROAR. """ + +import sys +import json + +from vasl_templates.webapp.scenarios import _match_roar_scenario, \ + _asa_scenarios, _build_asa_scenario_index, _roar_scenarios, _build_roar_scenario_index + +# --------------------------------------------------------------------- + +def asa_string( s ): + """Return an ASL Scenario Archive scenario as a string.""" + return "[{}] {} ({})".format( + s["scenario_id"], s.get("title"), s.get("sc_id") + ) + +def roar_string( s ): + """Return ROAR scenario as a string.""" + return "[{}] {} ({})".format( + s["roar_id"], s.get("name"), s.get("scenario_id") + ) + +# --------------------------------------------------------------------- + +# load the ASL Scenario Archive scenarios +fname = sys.argv[1] +asa_data = json.load( open( fname, "r" ) ) +_build_asa_scenario_index( _asa_scenarios, asa_data, None ) + +# load the ROAR scenarios +fname = sys.argv[2] +roar_data = json.load( open( fname, "r" ) ) +_build_roar_scenario_index( _roar_scenarios, roar_data, None ) + +# try to connect each ASA scenario to ROAR +exact_matches, multiple_matches, unmatched = [], [], [] +for scenario in asa_data["scenarios"]: + matches = _match_roar_scenario( scenario ) + if not matches: + unmatched.append( scenario ) + elif len(matches) == 1: + exact_matches.append( scenario ) + else: + multiple_matches.append( [ scenario, matches ] ) + +# output the results +print( "ASL Scenario Archive scenarios: {}".format( len(asa_data["scenarios"]) ) ) +print() +print( "Exact matches: {}".format( len(exact_matches) ) ) +print() +print( "Multiple matches: {}".format( len(multiple_matches) ) ) +if multiple_matches: + for scenario,matches in multiple_matches: + print( " {}:".format( asa_string(scenario) ) ) + for match in matches: + print( " - {}".format( roar_string( match ) ) ) +print() +print( "Unmatched: {}".format( len(unmatched) ) ) +if unmatched: + for scenario in unmatched: + print( " {}".format( asa_string(scenario) ) ) diff --git a/vasl_templates/webapp/__init__.py b/vasl_templates/webapp/__init__.py index 5951b09..73c7ea4 100644 --- a/vasl_templates/webapp/__init__.py +++ b/vasl_templates/webapp/__init__.py @@ -3,6 +3,7 @@ import sys import os import signal +import threading import configparser import logging import logging.config @@ -17,6 +18,16 @@ from vasl_templates.webapp.config.constants import BASE_DIR def _on_startup(): """Do startup initialization.""" + # start downloading files + # NOTE: We used to do this in the mainline code of __init__, so that we didn't have to wait + # for the first request before starting the download (useful if we are running as a standalone server). + # However, this means that the downloads start whenever we import this module e.g. for a stand-alone + # command-line tool :-/ + from vasl_templates.webapp.downloads import DownloadedFile + threading.Thread( daemon=True, + target = DownloadedFile.download_files + ).start() + # load the default template_pack from vasl_templates.webapp.snippets import load_default_template_pack load_default_template_pack() @@ -67,6 +78,12 @@ def _on_sigint( signum, stack ): #pylint: disable=unused-argument # initialize Flask app = Flask( __name__ ) +# set config defaults +# NOTE: These are defined here since they are used by both the back- and front-ends. +app.config[ "ASA_SCENARIO_URL" ] = "https://aslscenarioarchive.com/scenario.php?id={ID}" +app.config[ "ASA_PUBLICATION_URL" ] = "https://aslscenarioarchive.com/viewPub.php?id={ID}" +app.config[ "ASA_PUBLISHER_URL" ] = "https://aslscenarioarchive.com/viewPublisher.php?id={ID}" + # load the application configuration config_dir = os.path.join( BASE_DIR, "config" ) _fname = os.path.join( config_dir, "app.cfg" ) @@ -98,7 +115,8 @@ import vasl_templates.webapp.files #pylint: disable=cyclic-import import vasl_templates.webapp.vassal #pylint: disable=cyclic-import import vasl_templates.webapp.vo_notes #pylint: disable=cyclic-import import vasl_templates.webapp.nat_caps #pylint: disable=cyclic-import -import vasl_templates.webapp.roar #pylint: disable=cyclic-import +import vasl_templates.webapp.scenarios #pylint: disable=cyclic-import +import vasl_templates.webapp.downloads #pylint: disable=cyclic-import import vasl_templates.webapp.lfa #pylint: disable=cyclic-import if app.config.get( "ENABLE_REMOTE_TEST_CONTROL" ): print( "*** WARNING: Remote test control enabled! ***" ) @@ -107,11 +125,5 @@ if app.config.get( "ENABLE_REMOTE_TEST_CONTROL" ): # install our signal handler (must be done in the main thread) signal.signal( signal.SIGINT, _on_sigint ) -# initialize ROAR integration -# NOTE: We do this here, rather in _on_startup(), so that we can start downloading the latest scenario index file -# without having to wait for the first request (e.g. if we are running as a standalone server, not the desktop app). -from vasl_templates.webapp.roar import init_roar -init_roar() - # register startup initialization app.before_first_request( _on_startup ) diff --git a/vasl_templates/webapp/config/logging.yaml.example b/vasl_templates/webapp/config/logging.yaml.example index 7fb1746..c9b5968 100644 --- a/vasl_templates/webapp/config/logging.yaml.example +++ b/vasl_templates/webapp/config/logging.yaml.example @@ -42,6 +42,6 @@ loggers: webdriver: level: "WARNING" handlers: [ "file" ] - roar: + downloads: level: "WARNING" handlers: [ "file" ] diff --git a/vasl_templates/webapp/data/asl-scenario-archive.json b/vasl_templates/webapp/data/asl-scenario-archive.json new file mode 100644 index 0000000..d615528 --- /dev/null +++ b/vasl_templates/webapp/data/asl-scenario-archive.json @@ -0,0 +1,46 @@ +{ + +"_comment_": "This section maps theaters from those at the ASL Scenario Archive to ours.", +"theater-mappings": { + "MTO": "ETO", + "Normandy": "ETO", + "KW": "Korea" +}, + +"_comment_": "This section maps player nationalities from those at the ASL Scenario Archive (must be lower-case) to our nationality ID's.", +"nat-mappings": { + "australian": "anzac", + "belgians": "belgian", + "canada": "british~canadian", + "canadian": "british~canadian", + "china cmd": "chinese~gmd", + "commonwealth": "anzac", + "filipinos": "filipino", + "finland": "finnish", + "free french": "free-french", + "germany": "german", + "gurkha": "british", + "gurkhas": "british", + "ina": "indonesian", + "japan": "japanese", + "kpa": "kfw-kpa", + "nkpa": "kfw-kpa", + "north korea": "kfw-kpa", + "philippine": "filipino", + "poland": "polish", + "republic of korea": "kfw-rok", + "rok": "kfw-rok", + "rumanian": "romanian", + "russia": "russian", + "russians": "russian", + "slovak": "slovakian", + "siamese": "thai", + "soviet": "russian", + "ss": "german", + "u.s.": "american", + "usmc": "american", + "yugoslav": "yugoslavian", + "vichy": "french" +} + +} diff --git a/vasl_templates/webapp/data/default-template-pack/players.j2 b/vasl_templates/webapp/data/default-template-pack/players.j2 index 634d53d..d175f19 100644 --- a/vasl_templates/webapp/data/default-template-pack/players.j2 +++ b/vasl_templates/webapp/data/default-template-pack/players.j2 @@ -2,26 +2,51 @@ - + - +
- {# Some versions of Java require tags to have the width and height specified!?! #} - {%if PLAYER_FLAG_1%} {%endif%}{{PLAYER_1_NAME}}:
- {%if PLAYER_FLAG_2%} {%endif%}{{PLAYER_2_NAME}}: + + + + {# Some versions of Java require tags to have the width and height specified!?! #} + + +
+
{%if PLAYER_FLAG_1%} {%endif%} + {{PLAYER_1_NAME}}: +   ELR: {{PLAYER_1_ELR}} +   SAN: {{PLAYER_1_SAN}} + {%if PLAYER_1_DESCRIPTION%} {%endif%} +{%if PLAYER_1_DESCRIPTION%} +
{{PLAYER_1_DESCRIPTION}} +{%endif%} - - ELR: {{PLAYER_1_ELR}}
- ELR: {{PLAYER_2_ELR}} - -
- SAN: {{PLAYER_1_SAN}}
- SAN: {{PLAYER_2_SAN}} +
{%if PLAYER_FLAG_2%} {%endif%} + {{PLAYER_2_NAME}}: +   ELR: {{PLAYER_2_ELR}} +   SAN: {{PLAYER_2_SAN}} + {%if PLAYER_2_DESCRIPTION%} {%endif%} +{%if PLAYER_2_DESCRIPTION%} +
{{PLAYER_2_DESCRIPTION}} +{%endif%}
diff --git a/vasl_templates/webapp/downloads.py b/vasl_templates/webapp/downloads.py new file mode 100644 index 0000000..473981b --- /dev/null +++ b/vasl_templates/webapp/downloads.py @@ -0,0 +1,155 @@ +""" Manage downloading files. + +This module manages downloading files on a schedule e.g. the ASL Scenario Archive and ROAR scenario indexes. +""" + +import os +import threading +import json +import urllib.request +import time +import datetime +import tempfile +import logging + +from vasl_templates.webapp import app +from vasl_templates.webapp.utils import parse_int + +_registry = set() +_logger = logging.getLogger( "downloads" ) + +# --------------------------------------------------------------------- + +class DownloadedFile: + """Manage a downloaded file.""" + + def __init__( self, key, ttl, fname, url, on_data, extra_args=None ): + + # initialize + self.key = key + self.ttl = ttl + self.fname = fname + self.url = url + self.on_data = on_data + self.error_msg = None + + # initialize + self._lock = threading.Lock() + self._data = None + + # install any extra member variables + if extra_args: + for k,v in extra_args.items(): + setattr( self, k, v ) + + # register this instance + _registry.add( self ) + + # check if we have a cached copy of the file + self._cache_fname = os.path.join( tempfile.gettempdir(), "vasl-templates."+fname ) + if os.path.isfile( self._cache_fname ): + # yup - load it + _logger.info( "Using cached %s file: %s", key, self._cache_fname ) + self._set_data( self._cache_fname ) + else: + # nope - start with an empty data set + _logger.debug( "No cached %s file: %s", key, self._cache_fname ) + + def _set_data( self, data ): + """Install a new data set.""" + with self: + try: + # install the new data + if len(data) < 1024 and os.path.isfile( data ): + with open( data, "r", encoding="utf-8" ) as fp: + data = fp.read() + self._data = json.loads( data ) + # notify the owner + if self.on_data: + self.on_data( self, self._data, _logger ) + except Exception as ex: #pylint: disable=broad-except + # NOTE: It would be nice to report this to the user in the UI, but because downloading + # happens in a background thread, the web page will probably have already finished rendering, + # and without the ability to push notifications, it's too late to tell the user. + _logger.error( "Can't install %s data: %s", self.key, ex ) + + def __enter__( self ): + """Gain access to the underlying data. + + Since the file is downloaded in a background thread, access to the underlying data + must be protected by a lock. + """ + self._lock.acquire() + return self._data + + def __exit__( self, exc_type, exc_val, exc_tb ): + """Relinquish access to the underlying data.""" + self._lock.release() + + @staticmethod + def download_files(): + """Download fresh copies of each file.""" + #pylint: disable=protected-access + + # process each DownloadedFile + for df in _registry: + + # check if we should simulate slow downloads + delay = parse_int( app.config.get( "DOWNLOADED_FILES_DELAY" ) ) + if delay: + _logger.debug( "Simulating a slow download for the %s file: delay=%s", df.key, delay ) + time.sleep( delay ) + + # get the download URL + url = app.config.get( "{}_DOWNLOAD_URL".format( df.key.upper() ), df.url ) + if os.path.isfile( url ): + # read the data directly from a file (for debugging porpoises) + _logger.info( "Loading the %s data directly from a file: %s", df.key, url ) + df._set_data( url ) + continue + + # check if we have a cached copy of the file + ttl = parse_int( app.config.get( "{}_DOWNLOAD_CACHE_TTL".format( df.key ), df.ttl ), 24 ) + if ttl <= 0: + _logger.info( "Download of the %s file has been disabled.", df.key ) + continue + ttl *= 60*60 + if os.path.isfile( df._cache_fname ): + # yup - check how long ago it was downloaded + mtime = os.path.getmtime( df._cache_fname ) + age = int( time.time() - mtime ) + _logger.debug( "Checking the cached %s file: age=%s, ttl=%s (mtime=%s)", + df.key, + datetime.timedelta( seconds=age ), + datetime.timedelta( seconds=ttl ), + time.strftime( "%Y-%m-%d %H:%M:%S", time.localtime(mtime) ) + ) + if age < ttl: + continue + + # download the file + _logger.info( "Downloading the %s file: %s", df.key, url ) + try: + fp = urllib.request.urlopen( url ) + data = fp.read().decode( "utf-8" ) + except Exception as ex: #pylint: disable=broad-except + msg = str( getattr(ex,"reason",None) or ex ) + _logger.error( "Can't download the %s file: %s", df.key, msg ) + df.error_msg = msg + continue + _logger.info( "Downloaded the %s file OK: %d bytes", df.key, len(data) ) + + # install the new data + df._set_data( data ) + # NOTE: We only need to worry about thread-safety because a fresh copy of the file is downloaded + # while the old one is in use, but because downloads are only done once at startup, once we get here, + # we could delete the lock and allow unfettered access to the underlying data (since it's all + # going to be read-only). + # For simplicty, we leave the lock in place. It will slow things down a bit, since we will be + # serializing access to the data (unnecessarily, because it's all read-only) but none of the code + # is performance-critical and we can probably live it. + + # save a cached copy of the data + _logger.debug( "Saving a cached copy of the %s file: %s", df.key, df._cache_fname ) + with open( df._cache_fname, "w", encoding="utf-8" ) as fp: + fp.write( data ) diff --git a/vasl_templates/webapp/main.py b/vasl_templates/webapp/main.py index 3d09865..8ce22bf 100644 --- a/vasl_templates/webapp/main.py +++ b/vasl_templates/webapp/main.py @@ -60,6 +60,7 @@ def get_startup_msgs(): # --------------------------------------------------------------------- _APP_CONFIG_DEFAULTS = { # Bodhgaya, India (APR/19) + "THEATERS": [ "ETO", "DTO", "PTO", "Korea", "Burma", "other" ], # NOTE: We use HTTP for static images, since VASSAL is already insanely slow loading images (done in serial?), # so I don't even want to think about what it might be doing during a TLS handshake... :-/ "ONLINE_IMAGES_URL_BASE": "http://vasl-templates.org/services/static-images", @@ -89,6 +90,8 @@ def get_app_config(): key: app.config.get( key, default ) for key,default in _APP_CONFIG_DEFAULTS.items() } + if isinstance( vals["THEATERS"], str ): + vals["THEATERS"] = vals["THEATERS"].split() for key in ["APP_NAME","APP_VERSION","APP_DESCRIPTION","APP_HOME_URL"]: vals[ key ] = getattr( vasl_templates.webapp.config.constants, key ) @@ -128,6 +131,21 @@ def get_app_config(): extn_info[ key ] = extn[1][ key ] vals["VASL_EXTENSIONS"][ extn[1]["extensionId"] ] = extn_info + # include the ASL Scenario Archive config data + for key in ["ASA_SCENARIO_URL","ASA_PUBLICATION_URL","ASA_PUBLISHER_URL"]: + vals[ key ] = app.config[ key ] + for key in ["BALANCE_GRAPH_THRESHOLD"]: + vals[ key ] = app.config.get( key ) + fname = os.path.join( DATA_DIR, "asl-scenario-archive.json" ) + if os.path.isfile( fname ): + with open( fname, "r" ) as fp: + try: + vals[ "SCENARIOS_CONFIG" ] = json.load( fp ) + except json.decoder.JSONDecodeError as ex: + msg = "Couldn't load the ASL Scenario Archive config." + logging.error( "%s", msg ) + startup_msg_store.error( msg ) + return jsonify( vals ) # --------------------------------------------------------------------- diff --git a/vasl_templates/webapp/roar.py b/vasl_templates/webapp/roar.py deleted file mode 100644 index 3c9fe6e..0000000 --- a/vasl_templates/webapp/roar.py +++ /dev/null @@ -1,134 +0,0 @@ -"""Provide integration with ROAR.""" -# Bodhgaya, India (APR/19) - -import os.path -import threading -import json -import time -import datetime -import tempfile -import logging -import urllib.request - -from flask import render_template, jsonify - -from vasl_templates.webapp import app - -_roar_scenario_index = {} -_roar_scenario_index_lock = threading.Lock() - -_logger = logging.getLogger( "roar" ) - -ROAR_SCENARIO_INDEX_URL = "https://vasl-templates.org/services/roar/scenario-index.json" -CACHE_TTL = 6 * 60*60 - -# --------------------------------------------------------------------- - -def init_roar(): - """Initialize ROAR integration.""" - - # initialize - from vasl_templates.webapp.main import startup_msg_store - msg_store = startup_msg_store - - # initialize - download = True - cache_fname = os.path.join( tempfile.gettempdir(), "vasl-templates.roar-scenario-index.json" ) - enable_cache = not app.config.get( "DISABLE_ROAR_SCENARIO_INDEX_CACHE" ) - if not enable_cache: - cache_fname = None - - # check if we have a cached copy of the scenario index - if enable_cache and os.path.isfile( cache_fname ): - # yup - load it, so that we have something until we finish downloading a fresh copy - _logger.info( "Loading cached ROAR scenario index: %s", cache_fname ) - with open( cache_fname, "r" ) as fp: - _load_roar_scenario_index( fp.read(), "cached", msg_store ) - # check if we should download a fresh copy - mtime = os.path.getmtime( cache_fname ) - age = int( time.time() - mtime ) - _logger.debug( "Cached scenario index age: %s (ttl=%s) (mtime=%s)", - datetime.timedelta(seconds=age), datetime.timedelta(seconds=CACHE_TTL), - time.strftime( "%Y-%m-%d %H:%M:%S", time.localtime(mtime) ) - ) - if age < CACHE_TTL: - download = False - - # check if we should download the ROAR scenario index - if download: - if app.config.get("DISABLE_ROAR_SCENARIO_INDEX_DOWNLOAD"): - _logger.critical( "Downloading the ROAR scenario index has been disabled." ) - else: - # yup - make it so (nb: we do it in a background thread to avoid blocking the startup process) - # NOTE: This is the only place we do this, so if it fails, the program needs to be restarted to try again. - # This is not great, but we can live it (e.g. we will generally be using the cached copy). - threading.Thread( target = _download_roar_scenario_index, - args = ( cache_fname, msg_store ) - ).start() - -def _download_roar_scenario_index( save_fname, msg_store ): - """Download the ROAR scenario index.""" - - # download the ROAR scenario index - url = app.config.get( "ROAR_SCENARIO_INDEX_URL", ROAR_SCENARIO_INDEX_URL ) - _logger.info( "Downloading ROAR scenario index: %s", url ) - try: - fp = urllib.request.urlopen( url ) - data = fp.read().decode( "utf-8" ) - except Exception as ex: #pylint: disable=broad-except - # NOTE: We catch all exceptions, since we don't want an error here to stop us from running :-/ - msg = "Can't download the ROAR scenario index: {}".format( getattr(ex,"reason",str(ex)) ) - _logger.warning( "%s", msg ) - if msg_store: - msg_store.warning( msg ) - return - if not _load_roar_scenario_index( data, "downloaded", msg_store ): - # NOTE: If we fail to load the scenario index (e.g. because of invalid JSON), we exit here - # and won't overwrite the cached copy of the file with the bad data. - return - - # save a copy of the data - if save_fname: - _logger.debug( "Saving a copy of the ROAR scenario index: %s", save_fname ) - with open( save_fname, "w" ) as fp: - fp.write( data ) - -def _load_roar_scenario_index( data, data_type, msg_store ): - """Load the ROAR scenario index.""" - - # load the ROAR scenario index - try: - scenario_index = json.loads( data ) - except Exception as ex: #pylint: disable=broad-except - # NOTE: We catch all exceptions, since we don't want an error here to stop us from running :-/ - msg = "Can't load the {} ROAR scenario index: {}".format( data_type, ex ) - _logger.error( "%s", msg ) - if msg_store: - msg_store.error( msg ) - return False - _logger.debug( "Loaded %s ROAR scenario index OK: #scenarios=%d", data_type, len(scenario_index) ) - _logger.debug( "- Last updated: %s", scenario_index.get( "_lastUpdated_", "n/a" ) ) - _logger.debug( "- # playings: %s", str( scenario_index.get( "_nPlayings_", "n/a" ) ) ) - _logger.debug( "- Generated at: %s", scenario_index.get( "_generatedAt_", "n/a" ) ) - - # install the new ROAR scenario index - with _roar_scenario_index_lock: - global _roar_scenario_index - _roar_scenario_index = scenario_index - - return True - -# --------------------------------------------------------------------- - -@app.route( "/roar/scenario-index" ) -def get_roar_scenario_index(): - """Return the ROAR scenario index.""" - with _roar_scenario_index_lock: - return jsonify( _roar_scenario_index ) - -# --------------------------------------------------------------------- - -@app.route( "/roar/check" ) -def check_roar(): - """Check the ROAR data (for testing porpoises only).""" - return render_template( "check-roar.html" ) diff --git a/vasl_templates/webapp/scenarios.py b/vasl_templates/webapp/scenarios.py new file mode 100644 index 0000000..4140278 --- /dev/null +++ b/vasl_templates/webapp/scenarios.py @@ -0,0 +1,405 @@ +"""Provide access to the scenarios.""" + +# NOTE: Disable "DownloadedFile has no 'index' member" warnings. +#pylint: disable=no-member + +import re + +from flask import request, render_template, jsonify, abort + +from vasl_templates.webapp import app +from vasl_templates.webapp.downloads import DownloadedFile +from vasl_templates.webapp.utils import get_month_name, make_formatted_day_of_month, friendly_fractions, parse_int + +# --------------------------------------------------------------------- + +def _build_asa_scenario_index( df, new_data, logger ): + """Build the ASL Scenario Archive index.""" + df.index = { + scenario["scenario_id"]: scenario + for scenario in new_data["scenarios"] + } + if logger: + logger.debug( "Loaded the ASL Secenario Archive index: #scenarios=%d", len(df.index) ) + logger.debug( "- Generated at: %s", new_data.get( "_generatedAt_", "n/a" ) ) + +_asa_scenarios = DownloadedFile( "ASA", 1*24, + "asl-scenario-archive.json", + "https://vasl-templates.org/services/asl-scenario-archive/scenario-index.json", + _build_asa_scenario_index, + extra_args = { "index": None } +) + +# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +def _build_roar_scenario_index( df, new_data, logger ): + """Build the ROAR scenario index.""" + df.index, df.title_matching, df.id_matching = {}, {}, {} + for roar_id,scenario in new_data.items(): + if roar_id.startswith( "_" ): + continue + scenario[ "roar_id" ] = roar_id + df.index[ roar_id ] = scenario + _update_roar_matching_index( df.title_matching, scenario.get("name"), roar_id ) + _update_roar_matching_index( df.id_matching, scenario.get("scenario_id"), roar_id ) + if logger: + logger.debug( "Loaded the ROAR scenario index: #scenarios=%d", len(df.index) ) + logger.debug( "- Generated at: %s", new_data.get( "_generatedAt_", "n/a" ) ) + logger.debug( "- Last updated: %s", new_data.get( "_lastUpdated_", "n/a" ) ) + logger.debug( "- # playings: %s", str( new_data.get( "_nPlayings_", "n/a" ) ) ) + +def _update_roar_matching_index( index, val, roar_id ): + """Update the index that will be used for matching ROAR scenarios.""" + if not val: + return + key = _make_roar_matching_key( val ) + if key not in index: + index[ key ] = set() + index[ key ].add( roar_id ) + +def _make_roar_matching_key( val ): + """Generate a key value that will be used to match ROAR scenarios.""" + if not val: + return val + return re.sub( "[^a-z0-9]", "", val.lower() ) + +_roar_scenarios = DownloadedFile( "ROAR", 1*24, + "roar-scenario-index.json", + "https://vasl-templates.org/services/roar/scenario-index.json", + _build_roar_scenario_index, + extra_args = { "index": None } +) + +# --------------------------------------------------------------------- + +@app.route( "/scenario-index" ) +def get_scenario_index(): + """Return the scenario index.""" + + def add_field( entry, key, val ): #pylint: disable=missing-docstring + if val: + entry[ key ] = val + def make_entry( scenario ): + """Make an entry for the scenario index.""" + entry = { "scenario_id": scenario["scenario_id"] } + add_field( entry, "scenario_name", _make_scenario_name( scenario ) ) + add_field( entry, "scenario_display_id", scenario.get( "sc_id" ) ) + add_field( entry, "scenario_location", scenario.get( "scen_location" ) ) + add_field( entry, "scenario_date", _parse_date( scenario.get( "scen_date" ) ) ) + add_field( entry, "publication_name", scenario.get( "pub_name" ) ) + add_field( entry, "publication_id", scenario.get( "pub_id" ) ) + add_field( entry, "publication_date", _parse_date( scenario.get( "published_date" ) ) ) + add_field( entry, "publisher_name", scenario.get( "publisher_name" ) ) + add_field( entry, "publisher_id", scenario.get( "publisher_id" ) ) + return entry + + # generate the scenario index + with _asa_scenarios: + if _asa_scenarios.index is None: + return _make_not_available_response( + "The scenario index is not available.", _asa_scenarios.error_msg + ) + return jsonify( [ + make_entry( scenario ) + for scenario in _asa_scenarios.index.values() + ] ) + +@app.route( "/roar/scenario-index" ) +def get_roar_scenario_index(): + """Return the ROAR scenario index.""" + with _roar_scenarios: + if _roar_scenarios.index is None: + return _make_not_available_response( + "The ROAR scenarios are not available.", _roar_scenarios.error_msg + ) + return jsonify( _roar_scenarios.index ) + +def _make_not_available_response( msg, msg2 ): + """Generate a "not available" response.""" + resp = { "error": msg } + if msg2: + resp[ "message" ] = msg2 + return jsonify( resp ) + +# --------------------------------------------------------------------- + +@app.route( "/scenario/" ) +def get_scenario( scenario_id ): + """Return a scenario.""" + + # get the parameters + roar_override = request.args.get( "roar" ) + + # get the basic scenario information + scenario, args = _do_get_scenario( scenario_id ) + args[ "scenario_date_iso" ] = _parse_date_iso( scenario.get( "scen_date" ) ) + args[ "defender_name" ] = scenario.get( "defender" ) + args[ "attacker_name" ] = scenario.get( "attacker" ) + args = { k.lower(): v for k,v in args.items() } + + def get_win_score( key ): + """Get a player's win percentage.""" + nWins = parse_int( playings.get( key+"_wins" ), -1 ) + if nWins < 0: + return None + score = 100 * nWins / nGames + return int( score + 0.5 ) + + # get the ASL Scenario Archive playings + playings = scenario.get( "playings", [ {} ] )[ 0 ] + nGames = parse_int( playings.get( "totalGames" ), 0 ) + if playings and nGames > 0: + # NOTE: The player names are display names, only shown in the balance graphs, + # so it doesn't matter if we know about them or not. + args[ "balance" ] = [ { + "name": scenario.get( "defender" ), + "wins": playings.get( "defender_wins" ), + "percentage": get_win_score( "defender" ) + }, { + "name": scenario.get( "attacker" ), + "wins": playings.get( "attacker_wins" ), + "percentage": get_win_score( "attacker" ) + } ] + + # try to match the scenario with one in ROAR + roar_id = None + if roar_override == "auto-match": + matches = _match_roar_scenario( scenario ) + if matches: + roar_id = matches[0][ "roar_id" ] + else: + roar_id = roar_override + if roar_id: + args[ "roar" ] = _get_roar_info( roar_id ) + + return jsonify( args ) + +def _do_get_scenario( scenario_id ): + """Return the basic details for the specified scenario.""" + scenario = _get_scenario( scenario_id ) + url_template = app.config[ "ASA_SCENARIO_URL" ] + scenario_url = url_template.replace( "{ID}", scenario_id ) + return scenario, { + "SCENARIO_ID": scenario_id, + "SCENARIO_URL": scenario_url, + "SCENARIO_NAME": _make_scenario_name( scenario ), + "SCENARIO_DISPLAY_ID": scenario.get( "sc_id" ), + "SCENARIO_LOCATION": scenario.get( "scen_location" ), + "SCENARIO_DATE": _parse_date( scenario.get( "scen_date" ) ), + "THEATER": scenario.get( "theatre" ), + "DEFENDER_DESC": scenario.get( "def_desc" ), + "ATTACKER_DESC": scenario.get( "att_desc" ), + } + +def _match_roar_scenario( scenario ): + """Try to match the scenario with a ROAR scenario.""" + + def get_result_count( scenario ): + """Get the number of playings for a ROAR scenario.""" + results = scenario.get( "results", [] ) + return sum( r[1] for r in results ) + + with _roar_scenarios: + # try to match by scenario title + title = scenario.get( "title" ) + if not title: + return None + matches = _roar_scenarios.title_matching.get( _make_roar_matching_key( title ) ) + if not matches: + return [] + elif len( matches ) == 1: + # there was exactly one match - return it + roar_id = next( iter( matches ) ) + return [ _roar_scenarios.index[ roar_id ] ] + else: + # we found multiple scenarios with the same title, filter by ID + matches2 = _roar_scenarios.id_matching.get( _make_roar_matching_key( scenario.get("sc_id") ), set() ) + if matches2: + matches = matches.intersection( matches2 ) + matches = [ _roar_scenarios.index[m] for m in matches ] + matches.sort( key=get_result_count, reverse=True ) + return matches + +def _get_roar_info( roar_id ): + """Get the information for the specified ROAR scenario.""" + + def get_balance( player_no ): + """Get a player's balance stats.""" + # NOTE: The player names are display names, only shown in the balance graphs, + # so it doesn't matter if we know about them or not. + balance = { + "name": playings[ player_no ][0], + "wins": playings[ player_no ][1] + } + if nGames > 0: + balance[ "percentage" ] = int( 100 * playings[player_no][1] / nGames + 0.5 ) + return balance + + with _roar_scenarios: + + # find the ROAR scenario + index = _roar_scenarios.index or {} + scenario = index.get( roar_id ) + if not scenario: + abort( 404 ) + + # return the scenario details + results = { + "scenario_id": roar_id, + "scenario_display_id": scenario.get( "scenario_id" ), + "name": scenario.get( "name" ), + "url": scenario.get( "url" ) + } + playings = scenario.get( "results" ) + if playings: + nGames = playings[0][1] + playings[1][1] + results[ "balance" ] = [ get_balance(0), get_balance(1) ] + + return results + +# --------------------------------------------------------------------- + +@app.route( "/scenario-card/" ) +def get_scenario_card( scenario_id ): #pylint: disable=too-many-branches + """Return a scenario card (HTML).""" + + # get the arguments + brief_mode = request.args.get( "brief" ) + + # find the specified scenario + scenario, args = _do_get_scenario( scenario_id ) + + # prepare the template parameters + args[ "DESIGNER" ] = scenario.get( "author" ) + args[ "PUBLICATION" ] = scenario.get( "pub_name" ) + args[ "PUBLISHER" ] = scenario.get( "publisher_name" ) + args[ "PUBLICATION_DATE" ] = _parse_date( scenario.get( "published_date" ) ) + args[ "PREV_PUBLICATION" ] = scenario.get( "prior_publication" ) + args[ "REVISED_PUBLICATION" ] = scenario.get( "revision" ) + args[ "OVERVIEW" ] = scenario.get( "overview" ) + if brief_mode: + args[ "OVERVIEW_BRIEF" ] = _make_brief_overview( scenario.get( "overview" ) ) + args[ "DEFENDER_NAME" ] = scenario.get( "defender" ) + args[ "ATTACKER_NAME" ] = scenario.get( "attacker" ) + args[ "BOARDS" ] = ", ".join( str(m) for m in scenario.get("maps",[]) ) + args[ "MAP_IMAGES" ] = scenario.get( "mapImages" ) + overlays = ", ".join( str(o) for o in scenario.get("overlays",[]) ) + if overlays.upper() == "NONE": + overlays = None + if overlays: + args[ "OVERLAYS" ] = overlays + args[ "EXTRA_RULES" ] = scenario.get( "misc" ) + args[ "ERRATA" ] = scenario.get( "errata" ) + + # prepare the template parameters + if scenario.get( "pub_id" ): + url_template = app.config[ "ASA_PUBLICATION_URL" ] + args[ "PUBLICATION_URL" ] = url_template.replace( "{ID}", scenario["pub_id"] ) + if scenario.get( "publisher_id" ): + url_template = app.config[ "ASA_PUBLISHER_URL" ] + args[ "PUBLISHER_URL" ] = url_template.replace( "{ID}", scenario["publisher_id"] ) + playing_time = scenario.get( "time_to_play", "0" ) + if not str( playing_time ).startswith( "0" ): + args[ "PLAYING_TIME" ] = friendly_fractions( playing_time, "hour", "hours" ) + + # prepare the turn count + min_turns = scenario.get( "min_turns", "0" ) + max_turns = scenario.get( "max_turns", "0" ) + if min_turns != "0": + if min_turns == max_turns or max_turns == "0": + args[ "TURN_COUNT" ] = friendly_fractions( min_turns, "turn", "turns" ) + elif max_turns != "0": + args[ "TURN_COUNT" ] = "{}-{} turns".format( friendly_fractions(min_turns), friendly_fractions(max_turns) ) + + # prepare any info icons + icons = {} + if scenario.get( "oba" ) in ("D","B"): + icons[ "DEFENDER_OBA" ] = True + if scenario.get( "oba" ) in ("A","B"): + icons[ "ATTACKER_OBA" ] = True + if scenario.get( "night" ) == "1": + icons[ "IS_NIGHT" ] = True + if scenario.get( "aslsk" ) == "1": + icons[ "IS_ASLSK" ] = True + if scenario.get( "deluxe" ) == "1": + icons[ "IS_DELUXE" ] = True + if icons: + args[ "ICONS" ] = icons + + # prepare the lat/long co-ordinates + is_valid_coord = lambda val: val and val != "-99.99" + if is_valid_coord( scenario.get("gps_lat") ) and is_valid_coord( scenario.get("gps_long") ): + url_template = app.config.get( "MAP_URL", "https://maps.google.com/maps?q={LAT},{LONG}&z=4&output=embed" ) + args["MAP_URL"] = url_template.replace( "{LAT}", scenario["gps_lat"] ).replace( "{LONG}", scenario["gps_long"] ) + + # process the template and return the generated HTML + return render_template( "scenario-card.html", **args ) + +def _make_brief_overview( content ): + """Truncate the scenario overview.""" + if not content: + return None + threshold = parse_int( app.config.get( "BRIEF_CONTENT_THRESHOLD" ), 200 ) + if threshold <= 0 or len(content) < threshold: + return None + regex = re.compile( "[.?!]+" ) + mo = regex.search( content, threshold ) + if not mo: + return content[:threshold] + "..." + val = content[ : mo.start() + len(mo.group()) ] + if val == content: + return None + return val + +# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +def _get_scenario( scenario_id ): + """Get the specified scenario.""" + with _asa_scenarios: + index = _asa_scenarios.index or {} + scenario = index.get( scenario_id ) + if not scenario: + abort( 404 ) + return scenario + +def _make_scenario_name( scenario ): + """Get the scenario's name.""" + return scenario.get( "title" ) or "Untitled scenario (#{})".format( scenario["scenario_id"] ) + +# --------------------------------------------------------------------- + +@app.route( "/scenario/nat-report" ) +def scenario_nat_report(): + """Generate the scenario nationalities report (for testing porpoises).""" + return render_template( "scenario-nat-report.html" ) + +# --------------------------------------------------------------------- + +def _parse_date( val ): + """Parse a date string.""" + parts = _split_date_parts( val ) + if not parts: + return None + return "{} {}, {}".format( + make_formatted_day_of_month( parts[0] ), + get_month_name( parts[1] ), + parts[2] + ) + +def _parse_date_iso( val ): + """Parse a date string.""" + parts = _split_date_parts( val ) + if not parts: + return None + return "{:04}-{:02}-{:02}".format( parts[2], parts[1], parts[0] ) + +def _split_date_parts( val ): + """Split a date into its component parts.""" + if val is None: + return None + mo = re.search( r"^(\d{4})-(\d{2})-(\d{2})", val ) + if not mo: + return None + if mo.group(1) == "1901": + return None # nb: 1901-01-01 seems to be used as a "invalid date" marker + return [ int(mo.group(3)), int(mo.group(2)), int(mo.group(1)) ] diff --git a/vasl_templates/webapp/static/css/scenario-card.css b/vasl_templates/webapp/static/css/scenario-card.css new file mode 100644 index 0000000..695ee1a --- /dev/null +++ b/vasl_templates/webapp/static/css/scenario-card.css @@ -0,0 +1,87 @@ +/* scenario card */ +.scenario-card { display: flex ; flex-direction: column ; position: relative ; } +.scenario-card a { text-decoration: none ; } + +/* scenario card - header */ +.scenario-card .header { + position: relative ; + border: 1px solid #b0b080 ; border-radius: 5px ; + background: #f0f0c0 ; padding: 0.25em 0.5em ; + margin-bottom: 0.35em ; +} +.scenario-card .scenario-name { font-size: 150% ; font-weight: bold ; } +.scenario-card .scenario-name a:focus { outline: 0 ; } +.scenario-card .scenario-id { font-style: italic ; } +.scenario-card .scenario-date { font-size: 80% ; font-style: italic ; } +.scenario-card .info { + position: absolute ; + border: 1px solid #c0c0a0 ; border-radius: 4px ; + padding: 0.25em 0.5em ; + background: #fff ; + font-size: 80% ; +} +.scenario-card .icons { margin-top: 0.25em ; opacity: 0.75 ; } +.scenario-card .icons img { height: 0.8em ; } +.scenario-card .icons img.oba { height: 0.7em ; margin-left: -3px ; } + +/* scenario card - sub-header */ +.scenario-card .header2 { margin: 0 0.5em ; font-size: 90% ; color: #666 ; } +.scenario-card .publisher { font-size: 90% ; font-style: italic ; } +.scenario-card .publication-date { font-size: 90% ; font-style: italic ; } + +/* scenario card - content */ +.scenario-card .content { margin: 0.5em 0.5em 0 0.5em ; padding-right: 0.5em ; overflow-y: auto ; } +.scenario-card .overview { margin-bottom: 1em ; text-align: justify ; } +.scenario-card .overview .more { display: inline-block ; font-size: 80% ; font-style: italic ; color: #666 ; cursor: pointer ; } +.scenario-card .map { float: right ; width: 30% ; min-width: 200px ; margin: 0 0 0.25em 1.5em ; } +.scenario-card .map iframe { width: 100% ; border: 0 ; } + +/* scenario card - player info */ +.scenario-card .player-info { margin-bottom: 1em ; } +.scenario-card .player-info .players td { vertical-align: top ; } +.scenario-card .player-info .players .label { font-weight: bold ; padding-right: 0.25em ; } +.scenario-card .player-info .players .flag { width: 1px ; text-align: center ; } +.scenario-card .player-info .players .name { padding-left: 0.1em ; } +.scenario-card .player-info .players .desc { font-size: 90% ; font-style: italic ; } + +.scenario-card .player-info .oba { clear: both ; float: right ; font-size: 90% ; border: 1px solid #c0c0a0 ; border-radius: 5px ; } +.scenario-card .player-info .oba .oba-header { border-bottom: 1px dotted #c0c0a0 ; padding: 2px 5px 5px 5px ; font-weight: bold ; text-align: center ; white-space: nowrap ; } +.scenario-card .player-info .oba td { line-height: 1em ; } +.scenario-card .player-info .oba .name { width: 1px ; padding-left: 5px ; font-size: 90% ; font-weight: bold ; white-space: nowrap ; } +.scenario-card .player-info .oba .count { width: 1px ; padding-left: 0.5em ; text-align: right ; } +.scenario-card .player-info .oba .comments { padding-left: 10px ; font-size: 80% ; font-style: italic ; color: #444 ; } +.scenario-card .player-info .oba .date-warning { padding-top: 0.5em ; font-size: 80% ; font-style: italic ; } +.scenario-card .player-info .oba .date-warning img { float: left ; height: 1.25em ; margin: -2px 0.5em 0 0 ; } + +.scenario-card .balance-graphs { display: inline-block ; border: 1px solid #c0c0a0 ; border-radius: 5px ; padding: 4px 8px ; } +.scenario-card .balance-graph { display: table-row ; font-size: 90% ; } +.scenario-card .balance-graph .player { display: table-cell ; } +.scenario-card .balance-graph .player.player1 { text-align: right ; } +.scenario-card .balance-graph .wins { display: table-cell ; text-align: right ; font-size: 70% ; font-style: italic ; color: #666 ; } +.scenario-card .balance-graph .wrapper { display: table-cell ; } +.scenario-card .balance-graph .progressbar { display: inline-block ; position: relative ; width: 4em ; height: 0.8em ; margin-bottom: -2px ; border: 1px solid #c0c0a0 ; background: #f0f0c0 ; } +.scenario-card .balance-graph a:focus { outline: 0 ; } +.scenario-card .balance-graph .progressbar.player1 { background: #f0f0c0 ; border-top-right-radius: 0 ; border-bottom-right-radius: 0 ; border-right: 0 ; } +.scenario-card .balance-graph .progressbar.player1 .ui-progressbar-value { background: #fff ; border-right: 1px solid #eee ; } +.scenario-card .balance-graph .progressbar.player2 { background: #fff ; border-top-left-radius: 0 ; border-bottom-left-radius: 0 ; } +.scenario-card .balance-graph .progressbar.player2 .ui-progressbar-value { background: #f0f0c0 ; border-right: 1px solid #eee ; } +.scenario-card .balance-graph .progressbar .score { position: absolute ; font-size: 70% ; font-style: italic ; color: #666 ; left: 35% ; } + +/* scenario card - misc */ +.scenario-card a.map-preview img { height: 0.75em ; margin-left: 0.25em ; } +.scenario-card a.map-preview:focus { outline: 0 ; } +.scenario-card .errata ul { margin-top: 0 ; } +.scenario-card .errata .source { font-size: 90% ; font-style: italic ; color: #666 ; } + +/* scenario info dialog */ +.ui-dialog.scenario-info { border-radius: 10px ; } +.ui-dialog.scenario-info .ui-dialog-titlebar { display: none ; } +.ui-dialog.scenario-info .credit { float: left ; margin-right: 0.5em ; display: flex ; align-items: center ; } +.ui-dialog.scenario-info .credit img { height: 1.4em ; margin-right: 0.5em ; opacity: 0.7 ; } +.ui-dialog.scenario-info .credit .caption { font-size: 70% ; line-height: 1em ; margin-top: -4px ; } +.ui-dialog.scenario-info .credit a { text-decoration: none ; font-style: italic ; color: #666 ; } +.ui-dialog.scenario-info .credit a:focus { outline: 0 ; } +#scenario-info-dialog .scenario-card { height: 100% ; overflow-y: hidden ; } +#scenario-info-dialog .connect-roar { display: inline-block ; margin-top: 0.25em ; font-size: 80% ; color: #444 ; cursor: pointer ; } +#scenario-info-dialog .connect-roar img { height: 0.75em ; Xmargin-right: 0.25em ; opacity: 0.7 ; } +#scenario-info-dialog .disconnect-roar img { height: 0.5em ; margin-left: 0.5em ; cursor: pointer ; } diff --git a/vasl_templates/webapp/static/css/scenario-search-dialog.css b/vasl_templates/webapp/static/css/scenario-search-dialog.css new file mode 100644 index 0000000..dac7853 --- /dev/null +++ b/vasl_templates/webapp/static/css/scenario-search-dialog.css @@ -0,0 +1,71 @@ +/* jQuery dialog */ +.ui-dialog.scenario-search .ui-dialog-titlebar { background: #e0e0a0 ; } +#scenario-search { display: flex ; } +#scenario-search .left { min-width: 300px ; display: flex ; flex-direction: column ; } +#scenario-search .left .scenarios { flex: 1 ; } +#scenario-search .right { min-width: 400px ; flex: 1 ; display: flex ; flex-direction: column ; } +#scenario-search .scenario-card { flex: 1 ; overflow-y: hidden ; } + +/* splitter */ +#scenario-search .gutter { position: relative ; margin: 0 0.5em ; background-color: #ccc ; cursor: col-resize ; } +#scenario-search .gutter img { position: absolute ; top: 45% ; left: -2px ; } + +/* search control */ +#scenario-search .select2-container { top: 10px !important ; } +#scenario-search .select2-selection { display: none ; } +#scenario-search .select2-dropdown { border: none ; } +#scenario-search .select2-search--dropdown { padding: 0 0 0.5em 0 ; } + +#scenario-search .select2-results__option { padding-left: 4px ; } +#scenario-search .select2-results__option--highlighted[aria-selected] { margin: 2px 2px -4px 0 ; padding-top:4px ; color: #f0f0f0 ; } +#scenario-search .select2-results__option--highlighted[aria-selected] .scenario-date { color: #f0f0f0 ; } +#scenario-search .select2-results__option--highlighted[aria-selected] .publication-name { color: #f0f0f0 ; } +#scenario-search .select2-results__option--highlighted[aria-selected] .publisher-name { color: #f0f0f0 ; } +#scenario-search .select2-results__option--highlighted[aria-selected] .publication-date { color: #f0f0f0 ; } +#scenario-search .select2-results__option[aria-selected] { background: #fff ; } +#scenario-search .select2-results__option--highlighted[aria-selected] { background: #5897fb ; } + +#scenario-search .select2-results__option { padding-bottom: 0 ; } +#scenario-search .select2-results__option--highlighted { background: #5897fb ; margin-bottom: -4px ; } +#scenario-search .select2-results__option--highlighted .search-result { border: none ; background: transparent ; } +#scenario-search .search-result { border: 1px dotted #888 ; padding: 0 0.5em 0.25em 0.5em ; background: #f0f0f0 ; font-size: 95% ; } +#scenario-search .search-result .scenario-name { font-weight: bold ; } +#scenario-search .search-result .scenario-id { font-size: 80% ; font-style: italic ; } +#scenario-search .search-result .scenario-location { font-size: 90% ; } +#scenario-search .search-result .scenario-date { font-size: 80% ; font-style: italic ; color: #444 ; } +#scenario-search .search-result .publication-name { font-size: 80% ; font-style: italic ; color: #666 ; } +#scenario-search .search-result .publisher-name { font-size: 80% ; font-style: italic ; color: #666 ; } +#scenario-search .search-result .publication-date { font-size: 80% ; font-style: italic ; color: #666 ; } + +/* footer */ +#scenario-search .footer { + margin-bottom: -0.5em ; + padding-top: 0.5em ; + font-size: 70% ; font-style: italic ; + opacity: 0.8 ; +} +#scenario-search .footer img.logo { height: 1.5em ; } +#scenario-search .footer .caption { display: inline-block ; line-height: 1em ; } +#scenario-search .footer a { color: #666 ; } +#scenario-search .footer a:link { text-decoration: none ; } +#scenario-search .footer a:focus { outline: 0 ; } + +/* import control */ +#scenario-search .import-control { margin-top: 0.5em ; padding-top: 0.25em ; border-top: 1px dotted #666 ; } +#scenario-search .import-control .buttons button { float: right ; margin-left: 0.5em ; padding: 0.1em 0.5em ; } +#scenario-search .import-control .buttons button.import { height: 2em ; display: flex ; align-items: center ; } +#scenario-search .import-control .buttons button.import img { height: 1em ; margin-right: 0.5em ; } +#scenario-search .import-control .buttons button.ok { background: #ddd ; } +#scenario-search .import-control .buttons button.ok:hover { background: #ccc ; } +#scenario-search .import-control .warnings { margin-bottom: 0.5em ; padding: 0.25em 0 0 10px ; font-size: 90% ; } +#scenario-search .import-control .warnings .header { display: flex ; align-items: center ; height: 1.5em ; margin-left: -5px ; font-size: 120% ; } +#scenario-search .import-control .warnings .header img { height: 1.5em ; margin: 0 0.5em 0.5em 0 ; } +#scenario-search .import-control .warnings .header .caption { margin-top: -5px ; } +#scenario-search .import-control .warnings input[type="checkbox"] { margin-right: 0.25em ; } +#scenario-search .import-control .warnings .hint { + font-size: 90% ; font-style: italic ; color: #444 ; + white-space: nowrap ; overflow: hidden ; text-overflow: ellipsis ; +} +#scenario-search .import-control .warnings div.hint { margin-left: 20px ; } +#scenario-search .import-control .warnings div.hint img { height: 1em ; margin: 0 0.25em -2px 0 ; } +#scenario-search .import-control .warnings .warning2 img { height: 1.25em ; margin: 0 0.25em -2px -2px ; } diff --git a/vasl_templates/webapp/static/css/select-roar-scenario-dialog.css b/vasl_templates/webapp/static/css/select-roar-scenario-dialog.css index 37faccc..b42ee13 100644 --- a/vasl_templates/webapp/static/css/select-roar-scenario-dialog.css +++ b/vasl_templates/webapp/static/css/select-roar-scenario-dialog.css @@ -1,9 +1,8 @@ -#select-roar-scenario { overflow: hidden ; } - -.ui-dialog.select-roar-scenario .ui-dialog-titlebar { background: #ffffcc ; border: 1px solid #e0e0cc ; } +.ui-dialog.select-roar-scenario .ui-dialog-titlebar { background: #f0f0c0 ; border: 1px solid #c0c0a0 ; } .ui-dialog.select-roar-scenario .ui-dialog-content { padding-top: 0 !important ; } .ui-dialog.select-roar-scenario .ui-dialog-buttonpane { border: none ; margin-top: 0 !important ; padding-top: 0 !important ; } +#select-roar-scenario { overflow: hidden ; } #select-roar-scenario .header { height: 1.75em ; margin-top: 0.25em ; font-size: 80% ; } #select-roar-scenario .select2-selection { display: none ; } diff --git a/vasl_templates/webapp/static/css/tabs-scenario.css b/vasl_templates/webapp/static/css/tabs-scenario.css index bae2f7c..f9edf3b 100644 --- a/vasl_templates/webapp/static/css/tabs-scenario.css +++ b/vasl_templates/webapp/static/css/tabs-scenario.css @@ -4,52 +4,35 @@ #panel-scenario .row { display: flex ; align-items: center ; } #panel-scenario input { flex-grow: 1 ; } -#panel-scenario input[name='SCENARIO_ID'] { margin-left: 0.25em ; width: 80px ; flex-grow: 0 ; } -#panel-scenario #search-roar { width: 25px ; height: 22px ; margin: 0 0 0 0.25em ; padding: 0 ; margin-top:-4px; } -#panel-scenario #search-roar img { margin-top: 2px ; width: 16px ; } +#panel-scenario input[name='SCENARIO_ID'] { margin-left: 0.25em ; width: 80px ; flex-grow: 0 ; text-align: right ; } +#panel-scenario .scenario-search { width: 25px ; height: 22px ; margin: 0 0 0 0.25em ; padding: 0 ; margin-top:-4px; } +#panel-scenario .scenario-search img { margin-top: 2px ; width: 16px ; } #panel-scenario input[name='SCENARIO_DATE'] { width: 6em ; flex-grow: 0 ; } #panel-scenario label[for='PLAYER_1'] { margin-top: 2px ; } +#panel-scenario .select2[name="PLAYER_1"] { flex: 1 ; } #panel-scenario label[for='PLAYER_2'] { margin-top: 2px ; } +#panel-scenario .select2[name="PLAYER_2"] { flex: 1 ; } -#panel-scenario label { font-weight: bold ; width: 5em ; } +#panel-scenario input[name="PLAYER_1_DESCRIPTION"], +#panel-scenario input[name="PLAYER_2_DESCRIPTION"] { + margin-left: calc(6.35em + 2px) ; margin-top: 1px ; font-size: 75% ; +} + +#panel-scenario label { font-weight: bold ; width: 4.75em ; } #panel-scenario label.header { font-weight: bold ; width: 3em ; text-align: center ; } #panel-scenario input { margin-bottom: 0.25em ; } -#panel-scenario #oba-info { height: 1em ; } -.oba-info-tooltip { max-width: 500px ; } - #panel-scenario .select2-container { margin: 2px ; } #panel-scenario .select2-selection__rendered { height: 23px ; line-height: 23px ; } #panel-scenario .select2-selection__arrow { margin-top: -2px ; } #panel-scenario .select2-selection { height: 24px !important ; border-radius: 0 !important ; } +#panel-scenario .select2[name="SCENARIO_THEATER"] { margin-left: 0.25em ; margin-right: 0 ; } #panel-scenario .select2-container[name="SCENARIO_THEATER"] .select2-selection { height: 22px !important ; margin-top: -4px ; } #panel-scenario .select2-container[name="SCENARIO_THEATER"] .select2-selection__rendered { height: 20px ; line-height: 20px ; } #panel-scenario .select2-container[name="SCENARIO_THEATER"] .select2-selection__arrow { margin-top: -6px ; } -/* - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */ - -#roar-info { position: relative ; margin-top: 0.75em ; border: 1px dotted #ccc ; border-radius: 3px ; padding: 0.5em ; background: #fcfcfc ; } -#roar-info .name { font-size: 90% ; } -#roar-info .count { font-size: 70% ; font-style: italic ; color: #666 ; } -#roar-info .progressbar { width: 4em ; height: 1em ; } -#roar-info .progressbar.player1 { background: #ffffcc ; border-top-right-radius: 0 ; border-bottom-right-radius: 0 ; border-right: 0 !important ; } -#roar-info .progressbar.player1 .ui-progressbar-value { background: #fff ; border-right: 1px solid #eee ; } -#roar-info .progressbar.player2 { background: #fff ; border-top-left-radius: 0 ; border-bottom-left-radius: 0 ; } -#roar-info .progressbar.player2 .ui-progressbar-value { background: #ffffcc ; border-right: 1px solid #eee ; } - -#roar-info .progressbar { position: relative ; } -#roar-info .progressbar .label { position: absolute ; font-size: 80% ; font-style: italic ; color: #666 ; left: 35% ; } - -input[name='ROAR_ID'] { flex-grow: 0 !important ; width: 3em ; } - -button#go-to-roar { height: 2em ; width: 4em ; margin-right: 0.5em ; padding: 2px 5px ; background: #ffffcc ; font-weight: bold ; } -button#disconnect-roar { background: #fcfcfc ; width: 20px ; height: 19px ; padding: 0 ; border: none ; - position: absolute ; right: -2px ; top: 0 ; -} -button#disconnect-roar img { width: 12px ; } - /* -------------------------------------------------------------------- */ #panel-vc { height: 100% ; display: flex ; flex-direction: column ; } diff --git a/vasl_templates/webapp/static/css/tabs.css b/vasl_templates/webapp/static/css/tabs.css index 4317ba0..9c7b6e5 100644 --- a/vasl_templates/webapp/static/css/tabs.css +++ b/vasl_templates/webapp/static/css/tabs.css @@ -19,7 +19,7 @@ /* - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */ #tabs-scenario { display: flex ; } -#tabs-scenario .left { width: 32em ; min-width: 32em ; } +#tabs-scenario .left { width: 30em ; min-width: 30em ; } #tabs-scenario .right { flex-grow: 1 ; min-width: 25em ; } #tabs-scenario .left { display: flex ; flex-direction: column ; } diff --git a/vasl_templates/webapp/static/imageZoom/jquery.imageZoom.css b/vasl_templates/webapp/static/imageZoom/jquery.imageZoom.css new file mode 100644 index 0000000..a730146 --- /dev/null +++ b/vasl_templates/webapp/static/imageZoom/jquery.imageZoom.css @@ -0,0 +1,48 @@ +div.jquery-image-zoom { + line-height: 0; + font-size: 0; + + z-index: 1000; + + border: 5px solid #fff; + background: #eee; /* TM 25jan15: Added this to make it easier to see images with transparent backgrounds. */ + margin: -5px; + + -webkit-box-shadow: 0 0 10px rgba(0, 0, 0, 0.3); + -moz-box-shadow: 0 0 10px rgba(0, 0, 0, 0.3); + box-shadow: 0 0 10px rgba(0, 0, 0, 0.3); +} + + div.jquery-image-zoom a { + background: url(/static/imageZoom/jquery.imageZoom.png) no-repeat; + + display: block; + width: 25px; + height: 25px; + + position: absolute; + left: -17px; + top: -17px; + /* IE-users are prolly used to close-link in right-hand corner */ + *left: auto; + *right: -17px; + + text-decoration: none; + text-indent: -100000px; + outline: 0; + + z-index: 11; + } + + div.jquery-image-zoom a:hover { + background-position: left -25px; + } + + div.jquery-image-zoom img, + div.jquery-image-zoom embed, + div.jquery-image-zoom object, + div.jquery-image-zoom div { + width: 100%; + height: 100%; + margin: 0; + } diff --git a/vasl_templates/webapp/static/imageZoom/jquery.imageZoom.min.js b/vasl_templates/webapp/static/imageZoom/jquery.imageZoom.min.js new file mode 100644 index 0000000..ccd572f --- /dev/null +++ b/vasl_templates/webapp/static/imageZoom/jquery.imageZoom.min.js @@ -0,0 +1 @@ +jQuery.fn.imageZoom=function(c,b){var a=c.extend({speed:200,dontFadeIn:1,hideClicked:1,imageMargin:30,className:"jquery-image-zoom",loading:"Loading..."},b);a.doubleSpeed=a.speed/4;c(document).keydown(function(d){if(d.keyCode==27){c("div.jquery-image-zoom a").click()}});return this.click(function(k){var h=c(k.target);var g=h.is("a")?h:h.parents("a");g=(g&&g.is("a")&&g.attr("href").search(/(.*)\.(jpg|jpeg|gif|png|bmp|tif|tiff)$/gi)!=-1)?g:false;var i=(g&&g.find("img").length)?g.find("img"):false;c("div.jquery-image-zoom a").click();if(g){g.oldText=g.text();g.setLoadingImg=function(){if(i){i.css({opacity:"0.5"})}else{g.text(a.loading)}};g.setNotLoadingImg=function(){if(i){i.css({opacity:"1"})}else{g.text(g.oldText)}};var d=g.attr("href");if(c("div."+a.className+' img[src="'+d+'"]').length){return false}var j=function(l){g.setNotLoadingImg();var u=i?i:g;var q=i?a.hideClicked:0;var p=u.offset();var n={width:u.outerWidth(),height:u.outerHeight(),left:p.left,top:p.top};var o=c('
').css("position","absolute").appendTo(document.body);var m={width:l.width,height:l.height};var s={width:c(window).width(),height:c(window).height()};if(m.width>(s.width-a.imageMargin*2)){var r=s.width-a.imageMargin*2;m.height=(r/m.width)*m.height;m.width=r}if(m.height>(s.height-a.imageMargin*2)){var t=s.height-a.imageMargin*2;m.width=(t/m.height)*m.width;m.height=t}m.left=(s.width-m.width)/2+c(window).scrollLeft();m.top=(s.height-m.height)/2+c(window).scrollTop();var e=c('Close').appendTo(o).hide();if(q){g.css("visibility","hidden")}o.addClass(a.className).css(n).animate(m,a.speed,function(){e.fadeIn(a.doubleSpeed)});var v=function(){e.fadeOut(a.doubleSpeed,function(){o.animate(n,a.speed,function(){g.css("visibility","visible");o.remove()})});return false};o.click(v);e.click(v)};var f=new Image();f.src=d;if(f.complete){j(f)}else{g.setLoadingImg();f.onload=function(){j(f)}}return false}})}; \ No newline at end of file diff --git a/vasl_templates/webapp/static/imageZoom/jquery.imageZoom.png b/vasl_templates/webapp/static/imageZoom/jquery.imageZoom.png new file mode 100644 index 0000000000000000000000000000000000000000..2f5a3811d5399eb5c56dfa6779387db04bcebb82 GIT binary patch literal 1755 zcmV<11|<23P)P001%w1^@s6se*8p00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBU&ZAnByRCwCV zSba!UXBdC3_g*!d@wJ?EbDywC4_ zzMp$7LI_&QvREtud2B%lvGn)%uU@`a%HbHEN zDG2)jDqmp6#>U2=gnLp(Muxb5|Gor*FUw3pTrStU%m9BF8rbc2v2EKnQCL_gY&M%D ztJNy6YuBz7m6equIy%|_Dl03KARH`;HkuX|ytv2=`}XZKHf`Oy)rh2%Cr=tc>FMcW ze0*F!Z)$2X_z@_gx3^b59~&Fnt$^S~BENt(l2PB3nwlz*6ADmfW+n?To}E5@S~1(< z!-sW~eWrjQEcfz@GiS~y8~D_$*@FiU459Fwao^tFF3sn;XVa!l@*NJ>t7AyJfpHCA z5x!pjYG`Pn^73+e`0z<$BlG2z^Cmw(-z!>m8Sv-x3W&eD#6Z`tUnfM4^6uKTYw{gJ zUU?2pp9l^KiBLe0eB*qLiHY$7+PHBeH8(dCB91g(c|CC803APm+>1XkF;QNjxl=L8 z;Nai}wj)P|r+rac1WJnHgW}?1#YgOD&pqrzE&I`Af&wom!-Qhb zo;?OoYip|l!okA}72rLuWSpL!KD98ySZGyMl{k9zsDO>-cizI3o0}_6oH!xYty^dC z^8wKUBna9=f)@xs1cQqi{1ruC#8slZyIVI^z0U&4{ezjf2C=CY1AyT0%nzGS5KbAK z;tOVGwT~P*60j1piF$f^^d>gSl{{cS;Zv}>y2D3DM@3Cdjo7|@yGTn*6S1+eGOJ5U zN~F{E1?{hx0{CjboQH^xLcR3+TWDBL4jO;+g2mso!b1c!$wi6SfdL{L5f&R78oCOJ zHVol?xLz0<8fwhS%5p8b$N%Fa(Ul;WmbyadKUe{WyL4FOTcXv=h;pHGVklAE9FYTa zkK-GnpoTlkW#7WBKN1~+(AUC=Hkk3C$WOY7ewrkDe+N;|qCoy2N-ab-^^@LlAh%W~1*y%v~1+pf}O9c7k}K zn;~l5Li8$b-9ABnCrU=?yabVvEmkYl0AMZ%$FfEr&Cg3v^u;eRycThP^NKF^0ZdfO z0OxS>4}t{Xbs79`+wGG78KT$W%Tm)2{({@0jgLup(GWl=RTdW%-%tBJM>YrpBio?v{bRM(1QX=ZSVJntVl+z>zeK z`*sf}qZ$+1th_&X@Sq$V06=YRZN|`X_wHSTpPg_HzSI&hU(uwUXfLB5LyKMNaF-ey zDmA%8qz-rZki(sGK^=RV;ZBzUP5!;%ZdTt*qz-o|_Hwv;)&yv=!`&*abs!s^icgwR zo7j#V8A~1RI67HYC(&7@3hyNP^&%{=Mu?Y^_-CDFSM+pRxpJlA1J(pxk?`}HAe1(5 z-mGZyvSvPLazTG)Eyo8)AY0RrMC(@oP&dyFMEiIKkVi1h8)Ya7`L%h1W zn(#YO^78>JkgR;f2_$4A(X>*#Pq?HBCd0TK=2S9EH*_s)k+U+Jx?cBkBg8>Un3#7Cs(Z@JN6L0zioA$%LVF3Wx zM77W$2M2>U#zs*f7xomdeu8RjLuAw;<@WM-7qn@CR^1?%*0d5JcnyTU0?O7x8a}-` zh7!Mvs4u9_$bB8ndndFw#Fg2fCWxY2Fi{J9@RsRc9{zf~4%UGI0&%_rfZ4EZEbdo9 x(l7+~ahTpDYJ|$Jry3-JUFrXqx$AEM1^`j@eVA4tGV=fc002ovPDHLkV1f;jL%{$5 literal 0 HcmV?d00001 diff --git a/vasl_templates/webapp/static/images/asl-scenario-archive.png b/vasl_templates/webapp/static/images/asl-scenario-archive.png new file mode 100644 index 0000000000000000000000000000000000000000..edff68a14f96c561b8922d2c0fa005056c2187e2 GIT binary patch literal 13201 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 literal 0 HcmV?d00001 diff --git a/vasl_templates/webapp/static/images/gripper.png b/vasl_templates/webapp/static/images/gripper-horz.png similarity index 100% rename from vasl_templates/webapp/static/images/gripper.png rename to vasl_templates/webapp/static/images/gripper-horz.png diff --git a/vasl_templates/webapp/static/images/gripper-vert.png b/vasl_templates/webapp/static/images/gripper-vert.png new file mode 100755 index 0000000000000000000000000000000000000000..7c84d007d59c95e5966a72a2de4423943eb1bcdf GIT binary patch literal 355 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 literal 0 HcmV?d00001 diff --git a/vasl_templates/webapp/static/images/icons/deluxe.png b/vasl_templates/webapp/static/images/icons/deluxe.png new file mode 100644 index 0000000000000000000000000000000000000000..9de0979d0ca618662144395fe57505d7ec360fa9 GIT binary patch literal 3262 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 Npmhy(-lFWI{ujSA0zCi# literal 0 HcmV?d00001 diff --git a/vasl_templates/webapp/static/images/icons/night.png b/vasl_templates/webapp/static/images/icons/night.png new file mode 100644 index 0000000000000000000000000000000000000000..b830e0687c316eb189c6cee3c6f845c2fbf40531 GIT binary patch literal 2496 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 literal 0 HcmV?d00001 diff --git a/vasl_templates/webapp/static/images/icons/oba.png b/vasl_templates/webapp/static/images/icons/oba.png new file mode 100644 index 0000000000000000000000000000000000000000..0b1c2a96d81da45580f643395b087cf964429605 GIT binary patch literal 13852 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" ) ; + var $gripper = $( "" ) ; $( "#lfa .gutter.gutter-vertical" ).append( $gripper ) ; // initialize other controls $(this).find( "select[name='roll-type']" ).selectmenu( { @@ -1836,8 +1836,6 @@ function makeArray( val, count ) { function checkRollType( evt, rollType ) { return evt.rollType === rollType ; } function makeDieImageUrl( dieVal, color ) { return gImagesBaseUrl + "/lfa/die/" + color + "/" + dieVal + ".png" ; } -function fpFmt( val, nDigits ) { return val.toFixed( nDigits ) ; } -function pluralString( n, val1, val2 ) { return n == 1 ? val1 : val2 ; } // -------------------------------------------------------------------- diff --git a/vasl_templates/webapp/static/main.js b/vasl_templates/webapp/static/main.js index 74087b2..602366d 100644 --- a/vasl_templates/webapp/static/main.js +++ b/vasl_templates/webapp/static/main.js @@ -117,14 +117,16 @@ $(document).ready( function () { var navHeight = $("#tabs .ui-tabs-nav").height() ; $("#tabs .ui-tabs-nav a").click( function() { $(this).blur() ; } ) ; - // initialize ROAR integration - $("#search-roar").button( {} ) + // initialize scenario search + function scenarioSearchOrInfo( evt ) { + if ( ! $( "input[name='ASA_ID']" ).val() || evt.shiftKey ) + searchForScenario() ; + else + showScenarioInfo() ; + } + $(".scenario-search").button( {} ) .html( "" ) - .click( search_roar ) ; - $("#go-to-roar").button( {} ).click( go_to_roar_scenario ) ; - $("#disconnect-roar").button( {} ) - .html( "" ) - .click( disconnect_roar ) ; + .click( scenarioSearchOrInfo ) ; // initialize the scenario theater init_select2( @@ -140,12 +142,6 @@ $(document).ready( function () { onClose: on_scenario_date_change, } ) ; - // initialize the OBA INFO tooltip - $( "#oba-info" ).tooltip( { - tooltipClass: "oba-info-tooltip", - content: make_oba_info_tooltip, - } ) ; - // initialize the SSR's $("#ssr-sortable").sortable2( "init", { add: add_ssr, edit: edit_ssr @@ -218,14 +214,14 @@ $(document).ready( function () { "" ) ; } init_select2( $( "select[name='PLAYER_1']" ), - "12.5em", false, format_player_droplist_item + "auto", false, format_player_droplist_item ).on( "select2:open", function() { on_player_droplist_open( $(this) ) ; } ).on( "change", function() { on_player_change_with_confirm( 1 ) ; } ) ; init_select2( $( "select[name='PLAYER_2']" ), - "12.5em", false, format_player_droplist_item + "auto", false, format_player_droplist_item ).on( "select2:open", function() { on_player_droplist_open( $(this) ) ; } ).on( "change", function() { @@ -259,6 +255,12 @@ $(document).ready( function () { $.getJSON( gAppConfigUrl, function(data) { gAppConfig = data ; update_page_load_status( "app-config" ) ; + // load the available theaters + var $sel = $( "select[name='SCENARIO_THEATER']" ) ; + gAppConfig.THEATERS.forEach( function( theater ) { + $sel.append( $( "" ) ) ; + } ) ; + // set the alternate webapp base URL var alt_base_url = gAppConfig.ALTERNATE_WEBAPP_BASE_URL ; if ( alt_base_url ) { var $elem = $( "#alt-webapp-base-url" ) ; @@ -544,16 +546,6 @@ function update_page_load_status( id ) $("#tabs").tabs({ disabled: [] }) ; $("#loader").fadeOut( 500 ) ; adjust_footer_vspacers() ; - // position the PLAYERS snippet button - // FUDGE! Just setting the button's "left" attribute works, except in the Windows desktop app :-/ - // I think there's a weird timing error wrt wrapping it as a snippet button, but positioning it - // via CSS seems to work everywhere :-/ - var $btn = $( ".snippet-control[data-id='players']" ) ; - var $sel = $( ".select2[name='PLAYER_2_SAN']" ) ; - var newLeft = $sel.offset().left + $sel.outerWidth() - $btn.outerWidth() ; - newLeft -= 5 ; // nb: for the page margin - $btn.parent().height( $btn.parent().height() ) ; // nb: this forces a redraw - $btn.css( { position: "absolute", left: newLeft, top: $btn.position().top+2 } ) ; // NOTE: The watermark image appears briefly in IE when reloading the page, but not even // creating the watermark dynamically and removing it when the page unloads fixes it :-( $("#watermark").fadeIn( 5*1000 ) ; @@ -750,52 +742,6 @@ function on_player_change( player_no ) } update_add_vo_button( "vehicles" ) ; update_add_vo_button( "ordnance" ) ; - - // update the ROAR info panel - set_roar_scenario( $("input[name='ROAR_ID']").val() ) ; -} - -// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -function make_oba_info_tooltip() -{ - // initialize - var buf = [ "" ] ; - buf.push( "", "" ) ; - var player_nat = $( "select[name='PLAYER_" + player_no + "']" ).val() ; - var display_name = get_nationality_display_name( player_nat ) ; - buf.push( "", "
Off-Board Artillery" ) ; - - // initialize - var params = { - SCENARIO_THEATER: $( "select.param[name='SCENARIO_THEATER']" ).val() - } ; - var scenario_date = get_scenario_date() ; - if ( scenario_date ) { - params.SCENARIO_MONTH = 1 + scenario_date.getMonth() ; - params.SCENARIO_YEAR = scenario_date.getFullYear() ; - } - - // add the OBA info for each player - for ( var player_no=1 ; player_no <= 2 ; ++player_no ) { - buf.push( "
", display_name+":" ) ; - set_nat_caps_params( player_nat, params ) ; - if ( ! params.NAT_CAPS ) - params.NAT_CAPS = { OBA_BLACK: "-", OBA_RED: "-" } ; - buf.push( "" ) ; - var colors = [ "BLACK", "RED" ] ; - for ( var i=0 ; i < colors.length ; ++i ) { - var val = params.NAT_CAPS[ "OBA_"+colors[i] ] || "-" ; - buf.push( "", val, "" ) ; - } - if ( params.NAT_CAPS.OBA_COMMENTS ) { - for ( i=0 ; i < params.NAT_CAPS.OBA_COMMENTS.length ; ++i ) - buf.push( "
", "", params.NAT_CAPS.OBA_COMMENTS[i] ) ; - } - } - - buf.push( "
" ) ; - return buf.join( "" ) ; } // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -821,7 +767,9 @@ function update_nationality_specific_buttons( player_no ) { // initialize var player_nat = $( "select[name='PLAYER_" + player_no + "']" ).val() ; - var theater = $( "select.param[name='SCENARIO_THEATER']" ).val().toLowerCase() ; + var theater = $( "select.param[name='SCENARIO_THEATER']" ).val() ; + if ( theater ) + theater = theater.toLowerCase() ; // hide/show each nationality-specific button var $elem ; diff --git a/vasl_templates/webapp/static/roar.js b/vasl_templates/webapp/static/roar.js index f0e9d94..e2e7c58 100644 --- a/vasl_templates/webapp/static/roar.js +++ b/vasl_templates/webapp/static/roar.js @@ -1,148 +1,111 @@ -gRoarScenarioIndex = null ; +/* jshint esnext: true */ -// -------------------------------------------------------------------- - -function _get_roar_scenario_index( on_ready ) -{ - // check if we already have the ROAR scenario index - if ( gRoarScenarioIndex && Object.keys(gRoarScenarioIndex).length > 0 ) { - // yup - just do it - on_ready() ; - } else { - // nope - download it (nb: we do this on-demand, instead of during startup, - // to give the backend time if it wants to download a fresh copy). - // NOTE: We will also get here if we downloaded the scenario index, but it's empty. - // This can happen if the cached file is not there, and the server is still downloading - // a fresh copy, in which case, we will keep retrying until we get something. - $.getJSON( gGetRoarScenarioIndexUrl, function(data) { - gRoarScenarioIndex = data ; - on_ready() ; - } ).fail( function( xhr, status, errorMsg ) { - showErrorMsg( "Can't get the ROAR scenario index:

" + escapeHTML(errorMsg) + "
" ) ; - } ) ; - } -} +( function() { // nb: put the entire file into its own local namespace, global stuff gets added to window. // -------------------------------------------------------------------- -function search_roar() +window.selectRoarScenario = function( onSelected ) { - var unknown_nats = [] ; - function on_scenario_selected( roar_id ) { - // update the UI for the selected ROAR scenario - set_roar_scenario( roar_id ) ; - // populate the scenario name/ID - var scenario = gRoarScenarioIndex[ roar_id ] ; - if ( $("input[name='SCENARIO_NAME']").val() === "" && $("input[name='SCENARIO_ID']").val() === "" ) { - $("input[name='SCENARIO_NAME']").val( scenario.name ) ; - $("input[name='SCENARIO_ID']").val( scenario.scenario_id ) ; - } - // update the player nationalities - // NOTE: The player order as returned by ROAR is undetermined (and could change from call to call), - // so what we set here might not match what's in the scenario card, but we've got a 50-50 chance of being right... :-/ - update_player( scenario, 1 ) ; - update_player( scenario, 2 ) ; - if ( unknown_nats.length > 0 ) { - var buf = [ "Unrecognized nationality in ROAR:", "
    " ] ; - for ( var i=0 ; i < unknown_nats.length ; ++i ) - buf.push( "
  • " + unknown_nats[i] ) ; - buf.push( "
" ) ; - showWarningMsg( buf.join("") ) ; + function formatEntry( scenario ) { + // generate the HTML for a scenario + var buf = [ "
", + scenario.name + ] ; + if ( scenario.scenario_id ) { + buf.push( " [", + strReplaceAll( scenario.scenario_id, " ", " " ), + "]" + ) ; } + buf.push( + " ", scenario.publication, "", + "
" + ) ; + return $( buf.join("") ) ; } - function update_player( scenario, player_no ) { - var roar_nat = scenario.results[ player_no-1 ][0] ; - var nat = convert_roar_nat( roar_nat ) ; - if ( ! nat ) { - unknown_nats.push( roar_nat ) ; - return ; - } - if ( nat === get_player_nat( player_no ) ) - return ; - if ( ! is_player_ob_empty( player_no ) ) - return ; - $( "select[name='PLAYER_" + player_no + "']" ).val( nat ).trigger( "change" ) ; - on_player_change( player_no ) ; - } + // get the scenario index, then open the dialog + getRoarScenarioIndex( function( scenarios ) { - // ask the user to select a ROAR scenario - _get_roar_scenario_index( function() { - do_search_roar( on_scenario_selected ) ; - } ) ; -} + // initialize the select2 + var $sel = $( "#select-roar-scenario select" ) ; + $sel.select2( { + width: "100%", + templateResult: function( opt ) { + return opt.id ? formatEntry( scenarios[opt.id] ) : opt.text ; + }, + dropdownParent: $("#select-roar-scenario"), // FUDGE! need this for the searchbox to work :-/ + closeOnSelect: false, + } ) ; -function do_search_roar( on_ok ) -{ - // initialize the select2 - var $sel = $( "#select-roar-scenario select" ) ; - $sel.select2( { - width: "100%", - templateResult: function( opt ) { return opt.id ? _format_entry(opt.id) : opt.text ; }, - dropdownParent: $("#select-roar-scenario"), // FUDGE! need this for the searchbox to work :-/ - closeOnSelect: false, - } ) ; + // stop the select2 droplist from closing up + $sel.on( "select2:closing", function( evt ) { + stopEvent( evt ) ; + } ) ; - // stop the select2 droplist from closing up - $sel.on( "select2:closing", function(evt) { - evt.preventDefault() ; - } ) ; + function onResize( $dlg ) { + $( ".select2-results ul" ).height( $dlg.height() - 50 ) ; + } - // let the user select a scenario - function on_resize( $dlg ) { - $( ".select2-results ul" ).height( $dlg.height() - 50 ) ; - } - var $dlg = $("#select-roar-scenario").dialog( { - title: "Search ROAR", - dialogClass: "select-roar-scenario", - modal: true, - minWidth: 400, - minHeight: 350, - create: function() { - // initialize the dialog - init_dialog( $(this), "OK", false ) ; - // handle ENTER, ESCAPE and double-click - function auto_select_scenario( evt ) { - if ( $sel.val() ) { - $( ".ui-dialog.select-roar-scenario button:contains('OK')" ).click() ; - evt.preventDefault() ; + // let the user select a scenario + var $dlg = $( "#select-roar-scenario" ).dialog( { + title: "Connect scenario to ROAR", + dialogClass: "select-roar-scenario", + modal: true, + minWidth: 400, + minHeight: 350, + create: function() { + // initialize the dialog + init_dialog( $(this), "OK", false ) ; + loadScenarios( $sel, scenarios ) ; + // handle ENTER, ESCAPE and double-click + function autoSelectScenario( evt ) { + if ( $sel.val() ) { + $( ".ui-dialog.select-roar-scenario button.ok" ).click() ; + stopEvent( evt ) ; + } } - } - $(this).keydown( function(evt) { - if ( evt.keyCode == $.ui.keyCode.ENTER ) - auto_select_scenario( evt ) ; - else if ( evt.keyCode == $.ui.keyCode.ESCAPE ) - $(this).dialog( "close" ) ; - } ).dblclick( function(evt) { - auto_select_scenario( evt ) ; - } ) ; - }, - open: function() { - // initialize - // NOTE: We do this herem instead of in the "create" handler, to handle the case - // where the scenario index was initially unavailable but the download has since completed. - _load_select2( $sel ) ; - on_dialog_open( $(this) ) ; - $sel.select2( "open" ) ; - // update the UI - on_resize( $(this) ) ; - }, - resize: function() { on_resize( $(this) ) ; }, - buttons: { - OK: function() { - // notify the caller about the selected scenario - var roar_id = $sel.select2("data")[0].id ; - on_ok( roar_id ) ; - $dlg.dialog( "close" ) ; + $(this).keydown( function( evt ) { + if ( evt.keyCode == $.ui.keyCode.ENTER ) + autoSelectScenario( evt ) ; + else if ( evt.keyCode == $.ui.keyCode.ESCAPE ) { + $(this).dialog( "close" ) ; + stopEvent( evt ) ; + } + } ).dblclick( function( evt ) { + autoSelectScenario( evt ) ; + } ) ; + }, + open: function() { + // initialize + on_dialog_open( $(this) ) ; + $sel.select2( "open" ) ; + // update the UI + onResize( $(this) ) ; + }, + resize: onResize, + buttons: { + OK: { text: "OK", class: "ok", click: function() { + // notify the caller about the selected scenario + // FIXME! Clicking on the OK button doesn't result in the correct scenario being returned, + // but pressing ENTER or double-clicking, which triggers a click on OK, does?! + // The select2 docs say to use $sel.select2("data") or $sel.find(":selected"), + // but they both return the wrong thing :-/ + var roarId = $sel.select2("data")[0].id ; + onSelected( roarId ) ; + $dlg.dialog( "close" ) ; + } }, + Cancel: function() { $(this).dialog( "close" ) ; }, }, - Cancel: function() { $(this).dialog( "close" ) ; }, - }, + } ) ; + } ) ; -} -function _load_select2( $sel ) +} ; + +function loadScenarios( $sel, scenarios ) { - function remove_quotes( lquote, rquote ) { + function removeQuotes( name, lquote, rquote ) { var len = name.length ; if ( name.substr( 0, lquote.length ) === lquote && name.substr( len-rquote.length ) === rquote ) name = name.substr( lquote.length, len-lquote.length-rquote.length ) ; @@ -151,26 +114,28 @@ function _load_select2( $sel ) return name ; } - // sort the scenarios - var roar_ids=[], roar_id, scenario ; - for ( roar_id in gRoarScenarioIndex ) { - if ( roar_id[0] === "_" ) + // prepare the scenarios + var roarIds=[], roarId, scenario ; + for ( roarId in scenarios ) { + if ( roarId[0] === "_" ) continue ; - roar_ids.push( roar_id ) ; - scenario = gRoarScenarioIndex[ roar_id ] ; + roarIds.push( roarId ) ; + scenario = scenarios[ roarId ] ; var name = scenario.name ; - name = remove_quotes( '"', '"' ) ; - name = remove_quotes( "'", "'" ) ; - name = remove_quotes( """, """ ) ; - name = remove_quotes( "\u2018", "\u2019" ) ; - name = remove_quotes( "\u201c", "\u201d" ) ; + name = removeQuotes( name, '"', '"' ) ; + name = removeQuotes( name, "'", "'" ) ; + name = removeQuotes( name, """, """ ) ; + name = removeQuotes( name, "\u2018", "\u2019" ) ; + name = removeQuotes( name, "\u201c", "\u201d" ) ; if ( name.substring(0,3) === "..." ) name = name.substr( 3 ) ; - scenario._sort_name = name.trim().toUpperCase() ; + scenario._sortName = name.trim().toUpperCase() ; } - roar_ids.sort( function( lhs, rhs ) { - lhs = gRoarScenarioIndex[ lhs ]._sort_name ; - rhs = gRoarScenarioIndex[ rhs ]._sort_name ; + + // sort the scenarios + roarIds.sort( function( lhs, rhs ) { + lhs = scenarios[ lhs ]._sortName ; + rhs = scenarios[ rhs ]._sortName ; if ( lhs < rhs ) return -1 ; else if ( lhs > rhs ) @@ -178,136 +143,53 @@ function _load_select2( $sel ) return 0 ; } ) ; - // get the currently-active ROAR scenario - var curr_roar_id = $("input[name='ROAR_ID']").val() ; - - // load the select2 + // load the select var buf = [] ; - for ( var i=0 ; i < roar_ids.length ; ++i ) { - roar_id = roar_ids[ i ] ; - scenario = gRoarScenarioIndex[ roar_id ] ; - // NOTE: The " ) ; } $sel.html( buf.join("") ) ; } -function _format_entry( roar_id ) { - // generate the HTML for a scenario - var scenario = gRoarScenarioIndex[ roar_id ] ; - var buf = [ "
", - scenario.name, - " [", strReplaceAll(scenario.scenario_id," "," "), "]", - " ", scenario.publication, "", - "
" - ] ; - return $( buf.join("") ) ; -} - // -------------------------------------------------------------------- -function disconnect_roar() -{ - // disconnect from the ROAR scenario - set_roar_scenario( null ) ; -} - -// -------------------------------------------------------------------- +var _roarScenarioIndex = null ; // nb: don't access this directly, use getRoarScenarioIndex() -function go_to_roar_scenario() +function getRoarScenarioIndex( onReady ) { - // go the currently-active ROAR scenario - var roar_id = $( "input[name='ROAR_ID']" ).val() ; - var url = gRoarScenarioIndex[ roar_id ].url ; - if ( gWebChannelHandler ) - window.location = url ; // nb: AppWebPage will intercept this and launch a new browser window - else - window.open( url ) ; -} + // check if we already have the ROAR scenario index + if ( _roarScenarioIndex ) { -// -------------------------------------------------------------------- + // yup - just do it + onReady( _roarScenarioIndex ) ; -function set_roar_scenario( roar_id ) -{ - var total_playings ; - function safe_score( nplayings ) { return total_playings === 0 ? 0 : nplayings / total_playings ; } - function get_label( score ) { return total_playings === 0 ? "" : percentString( score ) ; } + } else { - function do_set_roar_scenaro() { - if ( roar_id ) { - // save the ROAR ID - $( "input[name='ROAR_ID']" ).val( roar_id ) ; - // update the progress bars - var scenario = gRoarScenarioIndex[ roar_id ] ; - if ( ! scenario ) + // nope - download it + $.getJSON( gGetRoarScenarioIndexUrl, function( resp ) { + if ( resp.error ) { + var msg = resp.error ; + if ( resp.message ) + msg += "
" + escapeHTML(resp.message) + "
" ; + showErrorMsg( msg ) ; return ; - var results = scenario.results ; - if ( convert_roar_nat(results[0][0]) === get_player_nat(2) || convert_roar_nat(results[1][0]) === get_player_nat(1) ) { - // FUDGE! The order of players returned by ROAR is indeterminate (and could change from call to call), - // so we try to show the results in the way that best matches what's on-screen. - results = [ results[1], results[0] ] ; } - total_playings = results[0][1] + results[1][1] ; - $( "#roar-info .name.player1" ).html( results[0][0] ) ; - $( "#roar-info .count.player1" ).html( "(" + results[0][1] + ")" ) ; - var score = 100 * safe_score( results[0][1] ) ; - $( "#roar-info .progressbar.player1" ).progressbar( { value: 100-score } ) - .find( ".label" ).text( get_label( score ) ) ; - $( "#roar-info .name.player2" ).html( results[1][0] ) ; - $( "#roar-info .count.player2" ).html( "(" + results[1][1] + ")" ) ; - score = 100 * safe_score( results[1][1] ) ; - $( "#roar-info .progressbar.player2" ).progressbar( { value: score } ) - .find( ".label" ).text( get_label( score ) ) ; - // show the ROAR scenario details - $( "#go-to-roar" ).attr( "title", scenario.name+" ["+scenario.scenario_id+"]\n" + scenario.publication ) ; - // NOTE: We see the fade in if the panel is already visible and we load a scenario that has a ROAR ID, - // because we reset the scenario the scenario before loading another one, which causes the panel - // to be hidden. Fixing this is more trouble than it's worth... :-/ - $( "#roar-info" ).fadeIn( 1*1000 ) ; - } else { - // there is no associated ROAR scenario - hide the info panel - $( "input[name='ROAR_ID']" ).val( "" ) ; - $( "#roar-info" ).hide() ; - } - // FUDGE! The scenario notes panel doesn't always resize itself after showing the ROAR info panel, - // causing the main window to show a vertical scrollbar. We hack around this by forcing a resize. - $(window).trigger( "resize" ) ; - } + _roarScenarioIndex = resp ; + onReady( resp ) ; + } ).fail( function( xhr, status, errorMsg ) { + showErrorMsg( "Can't get the ROAR scenario index:
" + escapeHTML(errorMsg) + "
" ) ; + } ) ; - // set the ROAR scenario - _get_roar_scenario_index( do_set_roar_scenaro ) ; + } } // -------------------------------------------------------------------- -function convert_roar_nat( roar_nat ) -{ - // clean up the ROAR nationality - roar_nat = roar_nat.toUpperCase() ; - var pos = roar_nat.indexOf( "/" ) ; - if ( pos > 0 ) - roar_nat = roar_nat.substr( 0, pos ) ; // e.g. "British/Partisan" -> "British" - else { - var match = roar_nat.match( /\(.*\)$/ ) ; - if ( match ) - roar_nat = roar_nat.substr( 0, roar_nat.length-match[0].length ).trim() ; // e.g. "Thai (Chinese)" -> "Thai" - } - - // try to match the ROAR nationality with one of ours - for ( var nat in gTemplatePack.nationalities ) { - if ( roar_nat === gTemplatePack.nationalities[nat].display_name.toUpperCase() ) - return nat ; - } - - return null ; -} +} )() ; // end local namespace diff --git a/vasl_templates/webapp/static/scenarios.js b/vasl_templates/webapp/static/scenarios.js new file mode 100644 index 0000000..b800ec0 --- /dev/null +++ b/vasl_templates/webapp/static/scenarios.js @@ -0,0 +1,1201 @@ +/* jshint esnext: true */ + +( function() { // nb: put the entire file into its own local namespace, global stuff gets added to window. + +var gIsFirstSearch ; +var $gDialog, $gScenariosSelect, $gSearchQueryInputBox, $gScenarioCard, $gFooter ; +var $gImportControl, $gImportScenarioButton, $gConfirmImportButton, $gCancelImportButton, $gImportWarnings ; + +// At time of writing, there are ~8600 scenarios, and loading them all into a select2 makes it a bit sluggish. +// The problem is when the user types the first 1 or 2 characters of the search query, which can result in +// thousands of results being loaded into the DOM. We work-around this by limiting the number of results +// shown for these very short query strings. +// An index is built of search results for very short query strings (e.g. "a" or "th"), with the additional +// requirement that the query string must appear at the start of a word (so "th" will match "the", but not "with"). +// This index also means that we can return results for these short query strings quickly, since we don't +// have to scan through all the scenarios looking for matches. +// The only down-side is that the search results shown to the user may change radically when we switch +// from using the prefix index to a normal substring search, but we can live with that. +var gPrefixIndex = null ; +const PREFIX_SIZE = 3 ; + +// -------------------------------------------------------------------- + +window.searchForScenario = function() +{ + // initialize + var $dlg ; + var eventHandlers = new jQueryHandlers() ; + + // NOTE: We have to get the scenario index before we can do anything. + getScenarioIndex( function( scenarios ) { + + // show the dialog + $( "#scenario-search" ).dialog( { + title: "Search for scenarios", + dialogClass: "scenario-search", + modal: true, + closeOnEscape: false, + width: $(window).width() * 0.8, + minWidth: 750, + height: $(window).height() * 0.8, + minHeight: 400, + position: { my: "center center", at: "center center", of: window }, + create: function() { + initPrefixIndex( scenarios ) ; + initDialog( $(this), scenarios ) ; + // FUDGE! This works around a weird layout problem. The very first time the dialog opens, + // the search input box (the whole .select2-dropdown, actually) is too far left. The layout + // fixes itself on the first keypress, but we adjust the initial position here. + $(this).find( ".select2-dropdown" ).css( "left", 10 ) ; + }, + open: function() { + // initialize + $dlg = $(this) ; + eventHandlers.addHandler( $(document), "keydown", function( evt ) { + // FUDGE! Escape doesn't always close the dialog, we handle it ourself :-/ + if ( evt.keyCode == $.ui.keyCode.ESCAPE ) + close_dialog_if_no_others( $dlg ) ; + } ) ; + // reset everything + $gSearchQueryInputBox.val( "" ) ; + $gDialog.find( ".select2-results__option" ).remove() ; + updateForSearchResults() ; + $gScenarioCard.empty() ; + $gFooter.hide() ; + $gImportWarnings.empty().hide() ; + $gImportScenarioButton.show() ; + $gConfirmImportButton.hide() ; + $gCancelImportButton.hide() ; + updateLayout() ; + gIsFirstSearch = true ; + gActiveScenaridCardRequest = null ; + gScenarioCardRequestTimerId = null ; + }, + close: function() { + // clean up + eventHandlers.cleanUp() ; + }, + resize: updateLayout, + } ) ; + } ) ; +} ; + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +function initDialog( $dlg, scenarios ) +{ + // initialize + $gDialog = $dlg ; + fixup_external_links( $dlg ) ; + $gScenarioCard = $dlg.find( ".scenario-card" ) ; + $gImportControl = $dlg.find( ".import-control" ) ; + $gImportScenarioButton = $dlg.find( "button.import" ).button() + .on( "click", onImportScenario ) ; + $gConfirmImportButton = $dlg.find( "button.confirm-import" ).button() + .on( "click", onConfirmImportScenario ) ; + $gCancelImportButton = $dlg.find( "button.cancel-import" ).button() + .on( "click", onCancelImportScenario ) ; + $gImportWarnings = $dlg.find( ".import-control .warnings" ) ; + $gFooter = $dlg.find( ".footer" ) ; + + // initialize the splitter + Split( [ $dlg.find( ".left" )[0], $dlg.find( ".right" )[0] ], { + sizes: [ 30, 70 ], + direction: "horizontal", + gutterSize: 3, + onDrag: updateLayout, + } ) ; + var $gripper = $( "" ) ; + $dlg.find( ".gutter.gutter-horizontal" ).append( $gripper ) ; + + // initialize the select2 + var options = [] ; + scenarios.forEach( function( scenario ) { + options.push( { + id: scenario.scenario_id, + text: scenario.scenario_name, // nb: this will always have something + scenario: scenario, + } ) ; + } ) ; + sortScenarios( options ) ; + $gScenariosSelect = $dlg.find( ".scenarios select" ) ; + $gScenariosSelect.select2( { + data: options, + matcher: isMatchingItem, sorter: sortItems, templateResult: formatItem, + width: "100%", + closeOnSelect: false, + dropdownParent: $dlg.find( ".scenarios" ), + } ) ; + + // stop the select2 droplist from closing up + $gScenariosSelect.select2( "open" ) ; + $gScenariosSelect.on( "select2:closing", function( evt ) { + stopEvent( evt ) ; + } ) ; + + // keep the UI up-to-date as items are selected + $gScenariosSelect.on( "select2:select", function( evt ) { + onItemSelected( evt.params.data.id ) ; + } ) ; + $gSearchQueryInputBox = $dlg.find( ".select2-search__field" ) ; + $gSearchQueryInputBox.on( "input", function() { + // FUDGE! select2 rebuilds the list of matching items, and selects the first one, + // but doesn't send us a "select" event for it - we do things manually here :-/ + var $elem = $( ".select2-results__option--highlighted .search-result" ) ; + onItemSelected( $elem.attr( "data-id" ) ) ; + updateForSearchResults() ; + // FUDGE! Undo the positioning hack we did in the "create" handler. + $dlg.find( ".select2-dropdown" ).css( "left", 0 ) ; + } ) ; + + // handle Up and Down key-presses + $gSearchQueryInputBox.on( "keydown", function( evt ) { + if ( evt.keyCode == $.ui.keyCode.UP || evt.keyCode == $.ui.keyCode.DOWN ) { + // NOTE: We don't want to refresh the scenario card if it's not necessary (e.g. after the user + // presses UP when already at the top of the list), since it causes flickering (because some + // elements in the scenario card fade in/out). We seem to get this event *after* the selection + // has already changed in the search result, so we compare the currently-selected item + // with what's currently showing in the scenario card to decide if anything's changed. + var $elem = $( ".select2-results__option--highlighted .search-result" ) ; + var currId = $dlg.find( ".scenario-card" ).attr( "data-id" ) ; + if ( $elem.attr( "data-id" ) != currId ) + onItemSelected( $elem.attr( "data-id" ) ) ; + } + } ) ; +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +function initPrefixIndex( scenarios ) +{ + function addEntry( key, scenario ) { + if ( gPrefixIndex[ key ] === undefined ) + gPrefixIndex[ key ] = {} ; + gPrefixIndex[ key ][ scenario.scenario_id ] = true ; + } + + // build the prefix index + gPrefixIndex = {} ; + scenarios.forEach( function( scenario ) { + + // get the searchable text for the next scenario (and cache it) + scenario._searchText = makeSearchText( scenario ) ; + + // add each word to the prefix index + var words = scenario._searchText.split( " " ) ; + words.forEach( function( word ) { + if ( word.length < 3 ) + return ; // nb: ignore short words + for ( var i=1 ; i <= PREFIX_SIZE ; ++i ) + addEntry( word.substring(0,i).toLowerCase(), scenario ) ; + } ) ; + + } ) ; +} + +// -------------------------------------------------------------------- + +function isMatchingItem( params, item ) +{ + // NOTE: This function is called by the select2 to decide if an item should be shown. + + // check if an item should be shown + if ( ! params.term ) + return null ; // nb: we don't show anything if there is no query string + var termLC = params.term.trim().toLowerCase() ; + if ( termLC.length <= PREFIX_SIZE ) { + // seaerch the prefix index + if ( gPrefixIndex[termLC] && gPrefixIndex[termLC][item.id] ) + return item ; + } else { + // search for a matching substring + if ( item.scenario._searchText.indexOf( termLC ) !== -1 ) + return item ; + } + return null ; +} + +function makeSearchText( scenario ) +{ + // return the text that will be searched upon + var val = scenario.scenario_name.trim().toLowerCase() ; + val = val.replace( /\s{2,}/g, " " ) ; + return val ; +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +function sortItems( items ) +{ + // NOTE: This function is called by the select2 to sort the items being shown. + + // NOTE: We used to sort the items alphabetically here, but this could cause a new item to appear + // at the top of the list, which we want to be selected. It was ridiculously difficult to figure out + // how to select an item: + // $gScenariosSelect.select2( "trigger", "select", { + // data: { id: items[0].id } + // } ) ; + // but unfortunately, this slows things down a lot in Chrome (everything flies in Firefox). + // We really need to present the scenarios in alphabetical order (so that scenarios with the same name + // are grouped together), but we can achieve that same effect by loading them into the select2 + // in alphabetical order. + + return items ; +} + +function sortScenarios( options ) +{ + // NOTE See sortItems() for why we load the scenarios in alphabetical order. + function getSortVal( text ) { + text = text.trim().toLowerCase() ; + if ( text[0] == '"' || text[0] == "'" ) + text = text.substring( 1 ) ; + if ( text[0] == "\u00a1" || text[0] == "\u00bf" ) // nb: inverted ! and ? + text = text.substring( 1 ) ; + if ( text.substring( 0, 3 ) == "..." ) + text = text.substring( 3 ) ; + return text ; + } + options.sort( function( lhs, rhs ) { + return getSortVal( lhs.text ).localeCompare( getSortVal( rhs.text ) ) ; + } ) ; +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +function formatItem( opt ) +{ + // NOTE: This function is called by the select2 to format items being shown. + + // initialize + if ( ! opt.id ) + return opt.text ; + var scenario = opt.scenario ; + + function addVal( val, className, prefix, postfix ) { + if ( val ) { + buf.push( "" ) ; + if ( prefix ) + buf.push( prefix ) ; + buf.push( val ) ; + if ( postfix ) + buf.push( postfix ) ; + buf.push( "" ) ; + } + } + + // generate the search result + const nowrap = "white-space:nowrap;overflow:hidden;text-overflow:ellipsis;" ; + var buf = [ "
" ] ; + buf.push( "
" ) ; + addVal( scenario.scenario_name ) ; + addVal( scenario.scenario_display_id, "scenario-id", " (", ")" ) ; + buf.push( "
" ) ; + if ( scenario.scenario_location ) { + buf.push( "
" ) ; + addVal( scenario.scenario_location, "scenario-location" ) ; + addVal( scenario.scenario_date, "scenario-date", " (", ")" ) ; + buf.push( "
" ) ; + } + if ( scenario.publication_name ) { + buf.push( "
" ) ; + addVal( scenario.publication_name, "publication-name" ) ; + addVal( scenario.publisher_name, "publisher-name", " (", ")" ) ; + if ( scenario.publication_date ) { + addVal( scenario.publication_date.substring(5,7) + "/" + scenario.publication_date.substring(0,4), + "publication-date", " (", ")" + ) ; + } + buf.push( "
" ) ; + } + buf.push( "
" ) ; + + // check if this is the first time we're showing search results + if ( gIsFirstSearch ) { + $gFooter.fadeIn( 5*1000 ) ; + gIsFirstSearch = false ; + } + + return $( buf.join("") ) ; +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +function updateForSearchResults() +{ + // check if there were any search results + $gDialog.find( ".select2-results__message" ).each( function() { + if ( $(this).text() == "No results found" ) { + // nope - update the UI + $(this).hide() ; + onItemSelected( null ) ; + } + } ) ; + + // update the import control + var hasSearchResults = $gDialog.find( ".select2-results .search-result" ).length > 0 ; + $gImportScenarioButton.button( hasSearchResults ? "enable" : "disable" ) ; + $gImportControl.css( { "border-top-color": hasSearchResults ? "#666": "#ccc" } ) ; +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +function onItemSelected( scenarioId ) +{ + // update the UI + onCancelImportScenario() ; + + // load the specified scenario + if ( ! scenarioId ) { + $gScenarioCard.empty() ; + return ; + } + // NOTE: We pass "auto-match" as the ROAR override, to tell the server to try to find + // a matching ROAR scenario. + loadScenarioCard( $gScenarioCard, scenarioId, true, null, "auto-match", false, + function( scenario ) { + // update the layout + updateLayout() ; + // NOTE: We set focus to the query input box so that UP/DOWN will work + // after clicking on a search result. + $gSearchQueryInputBox.focus() ; + }, + function( xhr, status, errorMsg ) { + $gScenarioCard.html( "Can't get the scenario card:
" + escapeHTML(errorMsg) + "
" ) ; + } + ) ; +} + +// -------------------------------------------------------------------- + +// It's quite easy for there to be multiple requests for scenario cards in progress at the same time +// e.g. if the user holds down the arrow keys to scroll through the search results. We try to optimize +// the process by ignoring all responses except for the most recently requested scenario card. We could +// count the number of active requests pending, but this will end up showing the wrong scenario card +// if the responses come back in a different order. +// So, we instead remember the ID of the most recently requested scenario card, and load that +// into the UI when it arrives. We will load the "wrong" response if the user requests, for example, +// scenarios 1, 2, 3, 2 (i.e. we will load the first "2" response, not the second one), but since +// these things never change, it doesn't actually matter. +var gActiveScenaridCardRequest = null ; +var gScenarioCardRequestTimerId ; + +function loadScenarioCard( $target, scenarioId, briefMode, scenarioDateOverride, roarOverride, showRoarButtons, onDone, onError ) +{ + // NOTE: Loading scenario cards is usually quick, but it can occasionally take some time (especially + // if the query string has just been changed). We show a "loading" spinner if a response hasn't arrived + // within a short amount of time. + if ( ! gScenarioCardRequestTimerId ) { + gScenarioCardRequestTimerId = setTimeout( function() { + $target.html( "" + ) ; + }, 500 ) ; + } + gActiveScenaridCardRequest = scenarioId ; + + // load the specified scenario + var url = gGetScenarioCardUrl.replace( "ID", scenarioId ) ; + if ( briefMode ) + url = addUrlParam( url, "brief", 1 ) ; + $.get( url, function( resp ) { + + // check if this response is for the most recently requested scenario card + if ( scenarioId != gActiveScenaridCardRequest ) + return ; + gActiveScenaridCardRequest = null ; + clearTimeout( gScenarioCardRequestTimerId ) ; + gScenarioCardRequestTimerId = null ; + + // NOTE: We used to load the received HTML into the UI here, then get the scenario details, + // but updating the UI with the details when they arrive can cause the layout to change. + // Instead, we hold everything and only update the UI when it's all ready. + var $card = $( resp ) ; + fixup_external_links( $card ) ; + $card.find( ".overview .more" ).on( "click", function() { + $(this).hide() ; + $(this).siblings( ".brief" ).hide() ; + $(this).siblings( ".full" ).fadeIn( 250 ) ; + $gSearchQueryInputBox.focus() ; + } ) ; + $card.find( "a.map-preview" ).imageZoom( $ ) ; + + // get the scenario details + getScenarioData( scenarioId, roarOverride, function( scenario ) { + // add the details to the scenario card + insertPlayerFlags( $card, scenario ) ; + makeBalanceGraphs( $card, scenario, showRoarButtons ) ; + loadObaInfo( $card, scenario, scenarioDateOverride ) ; + // all done - load the card into the UI and notify the caller + $target.html( $card ).fadeIn( 100 ) ; + $target.attr( "data-id", scenarioId ) ; + onDone( scenario ) ; + } ) ; + + } ).fail( onError ) ; +} + +function insertPlayerFlags( $target, scenario ) +{ + // insert flags for each player + [ "defender", "attacker" ].forEach( function( playerType ) { + var effectiveNat = getEffectivePlayerNat( scenario[ playerType+"_name" ] ) ; + if ( ! effectiveNat ) + return ; + var url = make_player_flag_url( effectiveNat[0], false ) ; + $target.find( ".player-info ." + playerType + " .flag" ).html( + $( "" ) + ) ; + } ) ; +} + +function loadObaInfo( $target, scenario, scenarioDateOverride ) +{ + // initialize + var theater = getEffectiveTheater( scenario.theater ) ; + var scenarioDate = scenario.scenario_date_iso ; + if ( ! theater || ( !scenarioDate && !scenarioDateOverride ) ) + return ; + if ( scenarioDateOverride ) { + scenarioDateOverride = scenarioDateOverride.toISOString().substring( 0, 10 ) ; + if ( scenarioDateOverride == scenarioDate ) + scenarioDateOverride = null ; + else + scenarioDate = scenarioDateOverride ; + } + var params = { + SCENARIO_THEATER: theater, + SCENARIO_YEAR: scenarioDate.substring( 0, 4 ), + SCENARIO_MONTH: scenarioDate.substring( 5, 7 ), + } ; + + // show the OBA info for the defender/attacker + function showInfo( playerType ) { + + // get the OBA info + var effectiveNat = getEffectivePlayerNat( scenario[ playerType+"_name" ] ) ; + if ( ! effectiveNat ) + return ; + delete params.NAT_CAPS ; + set_nat_caps_params( effectiveNat[0], params ) ; + if ( params.NAT_CAPS === undefined ) + params.NAT_CAPS = {} ; + + // load the OBA into the scenario card + $target.find( ".oba ." + playerType + " .black" ).text( params.NAT_CAPS.OBA_BLACK || "-" ) ; + $target.find( ".oba ." + playerType + " .red" ).text( params.NAT_CAPS.OBA_RED || "-" ) ; + + // show any OBA comments + var $comments = $target.find( ".oba ." + playerType + " .comments" ) ; + if ( params.NAT_CAPS.OBA_COMMENTS ) { + var buf = [] ; + params.NAT_CAPS.OBA_COMMENTS.forEach( function( cmt ) { + buf.push( "
", cmt, "
" ) ; + } ) ; + $comments.html( buf.join("") ).show() ; + } else { + $comments.hide() ; + } + + // update the date warning + if ( scenarioDateOverride ) { + $target.find( ".date-warning .val" ).text( + parseInt( scenarioDateOverride.substring(5,7) ) + "/" + scenarioDateOverride.substring(2,4) + ) ; + $target.find( ".date-warning" ).show() ; + } + } + showInfo( "defender" ) ; + showInfo( "attacker" ) ; + + // NOTE: To stop the OBA panel from flickering on-screen, it is configured to be hidden + // in the template, and we show it here after it has been loaded. + $target.find( ".oba" ).show() ; +} + +// -------------------------------------------------------------------- + +const IMPORT_FIELDS = [ + { key: "scenario_name", name: "scenario name", paramName: "SCENARIO_NAME", type: "text" }, + { key: "scenario_display_id", name: "scenario ID", paramName: "SCENARIO_ID", type: "text" }, + { key: "scenario_location", name: "location", paramName: "SCENARIO_LOCATION", type: "text" }, + { key: "scenario_date_iso", name: "scenario date", paramName: "SCENARIO_DATE", type: "date" }, + { key: "theater", name: "theater", paramName: "SCENARIO_THEATER", type: "select2" }, + { key: "defender_name", name: "defender", paramName: "PLAYER_1", type: "player" }, + { key: "defender_desc", name: "defender description", paramName: "PLAYER_1_DESCRIPTION", type: "text" }, + { key: "attacker_name", name: "attacker", paramName: "PLAYER_2", type: "player" }, + { key: "attacker_desc", name: "attacker description", paramName: "PLAYER_2_DESCRIPTION", type: "text" }, +] ; + +function onImportScenario() +{ + var warnings=[], warnings2=[] ; + + function getWarnings( scenario ) { + + // check if it's OK to import each field + var buf ; + IMPORT_FIELDS.forEach( function( importField ) { + + // check for warnings for the next field + var newVal = trimString( scenario[ importField.key ] ) ; + if ( newVal ) { + var msg = eval( "checkImportField_" + importField.type )( importField, newVal ) ; // jshint ignore: line + if ( msg ) { + if ( msg.substring(0,2) == "!=" ) + newVal = msg.substring( 2 ) ; + else { + buf = [ "
", + "", + msg, + "
" + ] ; + warnings2.push( $( buf.join("") ) ) ; + return ; + } + } + } + + // get the next field's current value + var currVal = eval( "getImportFieldCurrVal_" + importField.type )( importField ) ; // jshint ignore: line + if ( ! currVal ) + return ; + var displayCurrVal, extraMsg ; + if ( $.isArray( currVal ) ) { + displayCurrVal = currVal[1] ; + extraMsg = currVal[2] ; + currVal = currVal[0].trim() ; + } else { + currVal = currVal.trim() ; + displayCurrVal = currVal ; + } + + // compare the field's current value with what it will be changed to + if ( currVal != newVal ) { + // add a warning that the current value will be changed + var checked = extraMsg ? "" : " checked" ; + buf = [ "
", + "", + "", "Update the " + importField.name, "" + ] ; + if ( displayCurrVal.length <= 20 ) { + buf.push( " ", "", "(from \"" + displayCurrVal + "\")", "" ) ; + buf.push( "
" ) ; + } else { + buf.push( "" ) ; + buf.push( "
", "Currently \"" + displayCurrVal + "\".", "
" ) ; + } + if ( extraMsg ) + buf.push( "
", "", extraMsg, "
" ) ; + warnings.push( $( buf.join("") ) ) ; + } + } ) ; + } + + // get the scenario details + var scenarioId = $gScenarioCard.attr( "data-id" ) ; + getScenarioData( scenarioId, "auto-match", function( scenario ) { + + // got them - check if it will be a clean import + getWarnings( scenario ) ; + if ( warnings.length === 0 && warnings2.length === 0 ) { + // yup - do the import + doImportScenario( scenario ) ; + } else { + // nope - show the warnings + if ( warnings.length > 0 ) { + var buf = [ + "
", + "", + "
Some values in your scenario will be changed:
", + "
" + ] ; + warnings.unshift( $( buf.join("") ) ) ; + } + if ( warnings2.length > 0 ) { + if ( warnings.length > 0 ) + warnings2[0].css( "margin-top", "0.5em" ) ; + $.merge( warnings, warnings2 ) ; + } + $gImportWarnings.empty().append( warnings ).slideToggle( 100 ) ; + $gConfirmImportButton.data( "scenario", scenario ).show() ; + $gCancelImportButton.show() ; + $gImportScenarioButton.hide() ; + } + + } ) ; +} + +function doImportScenario( scenario ) +{ + // import each field + IMPORT_FIELDS.forEach( function( importField ) { + var $elem = $gDialog.find( ".import-control .warnings input[name='" + importField.key + "']" ) ; + if ( $elem.length > 0 && ! $elem.prop("checked") ) + return ; + var newVal = scenario[ importField.key ] ; + eval( "doImportField_" + importField.type )( importField, newVal ) ; // jshint ignore: line + } ) ; + + // update for the newly-connected scenario + // NOTE: We could reset the ELR/SAN here, but if the user is importing on top of an existing setup, + // the most likely reason is because they want to connect it to an ASA scenario, not because + // they want to import a whole set of new details, so clearing the ELR/SAN wouldn't make sense. + updateForConnectedScenario( + scenario.scenario_id, + scenario.roar ? scenario.roar.scenario_id : null + ) ; + + // all done - we can now close the dialog + $gDialog.dialog( "close" ) ; +} + +function onConfirmImportScenario() +{ + // import the scenario + var scenario = $gConfirmImportButton.data( "scenario" ) ; + doImportScenario( scenario ) ; +} + +function onCancelImportScenario() +{ + // remove all the warnings and cancel/confirm buttons, and revert back to the "import" state + // NOTE: Because we "cancel" the import every time the scenario card is updated, we only + // call slideToggle() if we actually need to. + if ( $gImportWarnings.css( "display" ) != "none" ) { + $gImportWarnings.slideToggle( 100, function() { + // FUDGE! If the content box is short enough to not need a vertical scrollbar, but the warning box + // causes one to appear, it doesn't go away when the warning box disappears. Triggering resizes + // doesn't seem to help, so we force the content box to be taller, which accumulates, but stops + // once the bottom reaches the bottom of the flex box. Sigh... + var $content = $gScenarioCard.find( ".content" ) ; + $content.css( "height", $content.outerHeight() + 100 ) ; + } ) ; + } + $gConfirmImportButton.hide() ; + $gCancelImportButton.hide() ; + $gImportScenarioButton.show() ; +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +function checkImportField_text( importField, newVal, warnings2 ) { + return null ; +} + +function getImportFieldCurrVal_text( importField ) { + // get the current field value + return $( "input[name='" + importField.paramName + "']" ).val().trim() ; +} + +function doImportField_text( importField, newVal ) { + // update the field in the scenario + $( "input[name='" + importField.paramName + "']" ).val( newVal ) ; +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +function checkImportField_select2( importField, newVal, warnings2 ) { + if ( importField.paramName == "SCENARIO_THEATER" ) { + // check if we will be able to import this theater + if ( newVal && ! getEffectiveTheater( newVal ) ) { + // nope - issue a warning + return "Unknown theater: " + newVal ; + } + } + return null ; +} + +function getImportFieldCurrVal_select2( importField ) { + // get the current field value + if ( importField.paramName == "SCENARIO_THEATER" ) + return null ; // nb: this will always be updated without warning + return $( "select[name='" + importField.paramName + "']" ).val().trim() ; +} + +function doImportField_select2( importField, newVal ) { + // update the field in the scenario + if ( importField.paramName == "SCENARIO_THEATER" ) { + if ( newVal ) { + newVal = getEffectiveTheater( newVal ) ; + if ( ! newVal ) + newVal = "other" ; + } + } + var $elem = $( "select[name='" + importField.paramName + "']" ) ; + $elem.val( newVal || "ETO" ).trigger( "change" ) ; +} + +function getEffectiveTheater( theater ) { + if ( gAppConfig.THEATERS.indexOf( theater ) !== -1 ) + return theater ; + theater = gAppConfig.SCENARIOS_CONFIG["theater-mappings"][ theater ] ; + if ( theater ) + return theater ; + return null ; +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +function checkImportField_date( importField, newVal, warnings2 ) { + return null ; +} + +function getImportFieldCurrVal_date( importField ) { + // get the current field value + if ( importField.paramName != "SCENARIO_DATE" ) + return null ; // nb: shouldn't get here! + var scenarioDate = get_scenario_date() ; + if ( ! scenarioDate ) + return null ; + return [ + scenarioDate.toISOString().substring( 0, 10 ), + scenarioDate.getDate() + " " + get_month_name(scenarioDate.getMonth()) + ", " + scenarioDate.getFullYear() + ] ; +} + +function doImportField_date( importField, newVal ) { + // update the field in the scenario + var $elem = $( "input[name='" + importField.paramName + "']" ) ; + $elem.datepicker( "setDate", + newVal ? $.datepicker.parseDate( "yy-mm-dd", newVal ) : null + ) ; +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +function checkImportField_player( importField, newVal, warnings2 ) { + // check if we will be able to import this player + if ( newVal ) { + var effectiveNat = getEffectivePlayerNat( newVal ) ; + if ( ! effectiveNat ) { + // nope - issue a warning + return "Unknown player: " + newVal ; + } + return "!=" + effectiveNat[0] ; // nb: tell the caller to update its "newVal" + } + return null ; +} + +function getImportFieldCurrVal_player( importField ) { + // get the current field value + // NOTE: Player nationalities will be changed without warning, *if* they have no OB. + // If they have OB, no warning will be issued if the nationality is the same. + if ( importField.paramName == "PLAYER_1" && is_player_ob_empty( 1 ) ) + return null ; + if ( importField.paramName == "PLAYER_2" && is_player_ob_empty( 2 ) ) + return null ; + var currVal = $( "select[name='" + importField.paramName + "']" ).val() ; + // NOTE: The extra warning will only show if the new player is different from the current player. + return [ currVal, get_nationality_display_name(currVal), "This player's OB will be removed." ] ; +} + +function doImportField_player( importField, newVal ) { + // update the player's nationality in the scenario + var effectiveNat = getEffectivePlayerNat( newVal ) ; + if ( ! effectiveNat ) + return ; // nb: unknown nationality - ignore it + newVal = effectiveNat[0] ; + var $elem = $( "select[name='" + importField.paramName + "']" ) ; + if ( $elem.val() != newVal ) { + // NOTE: We manually call on_player_change() to reset the player's OB, so that the user + // doesn't get a warning about losing OB settings when the player droplist is changed. + var playerNo = importField.paramName.substring( importField.paramName.length-1 ) ; + on_player_change( playerNo ) ; + $elem.val( newVal ).trigger( "change" ) ; + } +} + +window.getEffectivePlayerNat = function( playerName ) { + + if ( ! playerName ) + return null ; + + // try to find an exact match with one of our nationalities + var playerNameLC = playerName.toLowerCase() ; + for ( var nat in gTemplatePack.nationalities ) { + if ( gTemplatePack.nationalities[nat].display_name.toLowerCase() == playerNameLC ) + return [ nat, "exactMatch", playerName ] ; + } + + // try to find a mapping (exact match) + var nat2 = gAppConfig.SCENARIOS_CONFIG["nat-mappings"][ playerNameLC ] ; + if ( nat2 ) + return [ nat2, "exactMapping", playerName ] ; + + // try to find a partial match with one of our nationalities + for ( nat in gTemplatePack.nationalities ) { + var natDisplayName = gTemplatePack.nationalities[ nat ].display_name ; + if ( playerName.match( new RegExp( "\\b"+natDisplayName+"\\b" ), "i" ) ) + return [ nat, "partialMatch", natDisplayName ] ; + } + + // try to find a mapping (partial match) + var mappings = gAppConfig.SCENARIOS_CONFIG[ "nat-mappings" ] ; + for ( var key in mappings ) { + if ( playerName.match( new RegExp( "\\b"+key+"\\b", "i" ) ) ) + return [ mappings[key], "partialMapping", key ] ; + } + + return null ; +} ; + +// -------------------------------------------------------------------- + +function updateLayout() +{ + // resize and position the search select2 + var $dlg = $( "#scenario-search" ) ; + var $sel = $dlg.find( ".select2-container" ) ; + $dlg.find( ".select2-dropdown" ).css( "width", + $dlg.find( ".scenarios" ).width() + ) ; + var newHeight = $dlg.find( ".scenarios" ).height() - $dlg.find( ".select2-search" ).height() - 15 ; + $sel.find( ".select2-results__options" ).css( { + height: newHeight, + "max-height": newHeight, + } ) ; + + // resize and position the info box + updateInfoBox( $dlg ) ; +} + +function updateInfoBox( $parent ) +{ + // resize and position the info box + var $header = $parent.find( ".scenario-card .header" ) ; + var $info = $parent.find( ".scenario-card .info" ) ; + if ( $header.length > 0 && $info.length > 0 ) { + $header.css( { "padding-right": $info.width() + 30 } ) ; + var newTop = $header.outerHeight() + 5 - $info.outerHeight() ; + $info.css( { top: Math.max( newTop, 10 ), right: 10 } ) ; + // NOTE: To stop the info box from jumping around visibly, it is configured to be hidden + // in the template, and we show it here after it has been moved into position. + $info.show() ; + } +} + +// -------------------------------------------------------------------- + +function makeBalanceGraphs( $target, scenario, showRoarButtons ) +{ + // NOTE: If we have balance graphs for both the ASL Scenario Archive and ROAR, we try to show + // the players in the same order for both. Since the player nationalities may be unknown to us, + // we just do a simple text comparison on the display names. + // NOTE: If we only have the ROAR balance, it would be nice to order the players so that they match + // the ASL Scenario Archive's attacker/defender order, but would be more trouble than it's worth :-/ + var asaBalance = scenario.balance ; + var roarBalance = scenario.roar ? scenario.roar.balance : null ; + var roar_url = scenario.roar ? scenario.roar.url : null ; + if ( asaBalance && roarBalance ) { + if ( ( asaBalance[0].name.toLowerCase() == roarBalance[1].name.toLowerCase() ) || + ( asaBalance[1].name.toLowerCase() == roarBalance[0].name.toLowerCase() ) ) { + var tmp = roarBalance[0] ; + roarBalance[0] = roarBalance[1] ; + roarBalance[1] = tmp ; + } + } + + // make the balance graphs + var rc1 = doMakeBalanceGraph( $target.find( ".balance-graph.asa" ), + asaBalance, scenario.scenario_url, false, + "Balance at the ASL Scenario Archive" + ) ; + var rc2 = doMakeBalanceGraph( $target.find( ".balance-graph.roar" ), + roarBalance, roar_url, true, + "Balance at ROAR" + ) ; + + // update the UI + if ( showRoarButtons && ! rc2 ) + $target.find( ".connect-roar" ).show() ; + else + $target.find( ".connect-roar" ).hide() ; + if ( rc1 || rc2 ) + return true ; + else { + if ( ! showRoarButtons ) + $target.find( ".balance-graphs" ).hide() ; + return false ; + } + + function doMakeBalanceGraph( $bgraph, balance, url, isRoar, tooltip ) + { + // initialize + if ( ! balance ) { + $bgraph.hide() ; + return false ; + } + var buf = [] ; + var link1 = url ? "" : "" ; + var link2 = url ? "" : "" ; + + // add the the 1st player's details + buf.push( "
", balance[0].name, "
" ) ; + buf.push( " " ) ; + buf.push( "
", "("+balance[0].wins+")", "
" ) ; + buf.push( " ", " " ) ; + // NOTE: The wrapper div contains both progress bars (to work-around a Chromium rendering problem :-/). + buf.push( "
", link1, "", + "
", "
", "
" + ) ; + + // add the the 2nd player's details + buf.push( + "
", "
", "
", + "
", link2, "
" + ) ; + buf.push( " ", " " ) ; + buf.push( "
", "("+balance[1].wins+")", "
" ) ; + buf.push( " " ) ; + buf.push( "
", balance[1].name, "
" ) ; + + // show the "disconnect from ROAR" button + if ( showRoarButtons && isRoar ) { + buf.push( "
", + "", + "
" + ) ; + } + + // load the balance graph + $bgraph.empty().append( buf.join("") ).show() ; + fixup_external_links( $bgraph ) ; + + // configure the progressbar's + $bgraph.find( ".progressbar" ).each( function() { + var isPlayer1 = $(this).hasClass( "player1" ) ; + var score = balance[ isPlayer1 ? 0 : 1 ].percentage ; + if ( score === undefined ) + score = 0 ; + else + score = parseInt( score ) ; + $(this).progressbar( { + value: isPlayer1 ? 100-score : score + } ) ; + $(this).children( ".score" ).text( score+"%" ) ; + $(this).attr( "title", tooltip ) ; + } ) ; + + // show the progressbar's in grey if there are not many playings + var threshold = gAppConfig.BALANCE_GRAPH_THRESHOLD || 20 ; + var totalGames = parseInt(balance[0].wins) + parseInt(balance[1].wins) ; + if ( totalGames < threshold ) { + var alpha = Math.max( totalGames / threshold, 0.5 ) ; + var color = "rgba( 224, 224, 224, " + fpFmt(alpha,1) + ")" ; + var borderColor = "#d0d0d0" ; + $bgraph.find( ".progressbar.player1" ).css( "background", color ) ; + $bgraph.find( ".progressbar.player2 .ui-progressbar-value" ).css( "background", color ) ; + $bgraph.find( ".progressbar.player1" ).css( { + border: "1px solid "+borderColor, + "border-right": 0 + } ) ; + $bgraph.find( ".progressbar.player2" ).css( "border", "1px solid "+borderColor ) ; + } + + // add a click handler for "disconnect from ROAR" + $bgraph.find( ".disconnect-roar" ).on( "click", function() { + updateForConnectedScenario( scenario.scenario_id, null ) ; + getScenarioData( scenario.scenario_id, null, function( newScenario ) { + // NOTE: We enable "showRoarButtons" so that the "connect to ROAR" button appears. + makeBalanceGraphs( $target, newScenario, true ) ; + $target.find( ".connect-roar" ).show() ; + } ) ; + } ) ; + + return true ; + } +} + +// -------------------------------------------------------------------- + +window.showScenarioInfo = function() +{ + // initialize + var $dlg ; + var eventHandlers = new jQueryHandlers() ; + var scenarioId = $( "input[name='ASA_ID']" ).val() ; + var scenarioDate = get_scenario_date() ; + + function onResize() { updateInfoBox( $dlg ) ; } + + // request the scenario card + var roarOverride = $( "input[name='ROAR_ID']" ).val() ; + loadScenarioCard( $("#scenario-info-dialog .scenario-card"), scenarioId, false, scenarioDate, roarOverride, true, + function( scenario ) { + + // show the dialog + $( "#scenario-info-dialog" ).dialog( { + dialogClass: "scenario-info", + modal: true, + closeOnEscape: false, + width: $(window).width() * 0.8, + minWidth: 500, + height: $(window).height() * 0.8, + minHeight: 300, + create: function() { + // add the credit panel + var $btnPane = $( ".ui-dialog.scenario-info .ui-dialog-buttonpane" ) ; + var buf = [ "" + ] ; + var $credit = $( buf.join("") ) ; + fixup_external_links( $credit ) ; + $btnPane.prepend( $credit ) ; + }, + open: function() { + // initialize + $dlg = $(this) ; + var $draggable = $dlg.parent().draggable() ; + // add a click handler for "connect to ROAR" + $(this).find( ".connect-roar" ).on( "click", function() { + connectToRoar( $dlg.find(".scenario-card"), scenario ) ; + } ) ; + // change the credit link + var url = gAppConfig.ASA_SCENARIO_URL.replace( "{ID}", scenarioId ) ; + var $btnPane = $( ".ui-dialog.scenario-info .ui-dialog-buttonpane" ) ; + $btnPane.find( ".credit a" ).attr( "href", url ) ; + // configure the "unlink scenario" button + var $btn = $btnPane.find( "button.unlink" ) ; + $btn.prepend( + $( "" ) + ) ; + var creditWidth = $btnPane.find( ".credit" ).outerWidth() ; + $btn.css( { float: "left", position: "absolute", left: creditWidth+20, padding: "2px 5px" } ) ; + $btn.attr( "title", "Unlink your scenario from the ASL Scenario Archive" ) ; + // update the layout + onResize() ; + eventHandlers.addHandler( $(document), "keydown", function( evt ) { + // FUDGE! Escape doesn't always close the dialog, we handle it ourself :-/ + if ( evt.keyCode == $.ui.keyCode.ESCAPE ) { + close_dialog_if_no_others( $dlg ) ; + } else if ( evt.keyCode == 16 ) { // nb: checking evt.shiftKey is unreliable + window.getSelection().empty() ; + $draggable.draggable( "disable" ) ; + } + } ) ; + eventHandlers.addHandler( $(document), "keyup", function( evt ) { + if ( evt.keyCode == 16 ) + $draggable.draggable( "enable" ) ; + } ) ; + // set initial focus + $btnPane.find( "button.ok" ).focus() ; + }, + close: function() { + // clean up + eventHandlers.cleanUp() ; + }, + resize: onResize, + draggable: false, + buttons: { + OK: { text: "OK", class: "ok", click: function() { + $dlg.dialog( "close" ) ; + } }, + Unlink: { text: "Unlink", class: "unlink", click: function() { + updateForConnectedScenario( null, null ) ; + $dlg.dialog( "close" ) ; + } }, + }, + } ) ; + }, + + function( xhr, status, errorMsg ) { + showErrorMsg( "Can't get the scenario card:
" + escapeHTML(errorMsg) + "
" ) ; + } + + ) ; +} ; + +function connectToRoar( $target, scenario ) +{ + // ask the user to select a ROAR scenario + selectRoarScenario( function( roarId ) { + // save the selected ROAR scenario and update the UI + updateForConnectedScenario( scenario.scenario_id, roarId ) ; + getScenarioData( scenario.scenario_id, roarId, function( newScenario ) { + if ( ! newScenario.roar || ! newScenario.roar.balance ) { + showWarningMsg( "There are no playing results for this ROAR scenario." ) ; + return ; + } + makeBalanceGraphs( $target, newScenario, true ) ; + } ) ; + } ) ; +} + +// -------------------------------------------------------------------- + +window.updateForConnectedScenario = function( scenarioId, roarId ) +{ + // save the scenario ID's + $( "input[name='ASA_ID']" ).val( scenarioId ) ; + $( "input[name='ROAR_ID']" ).val( roarId ) ; + + // update the UI + var $btn = $( "button.scenario-search" ) ; + if ( scenarioId ) { + $btn.find( "img" ).attr( "src", gImagesBaseUrl+"/info.gif" ) ; + $btn.attr( "title", "Scenario details" ) ; + } else { + $btn.find( "img" ).attr( "src", gImagesBaseUrl+"/search.png" ) ; + $btn.attr( "title", "Search for scenarios" ) ; + } +} ; + +// -------------------------------------------------------------------- + +var _scenarioIndex ; // nb: don't access this directly, use getScenarioIndex() + +function getScenarioIndex( onReady ) +{ + // check if we already have the scenario index + if ( _scenarioIndex ) { + + // yup - just do it + onReady( _scenarioIndex ) ; + + } else { + + // nope - download it (nb: we do this on-demand, instead of during startup, + // to give the backend time if it wants to download a fresh copy). + $.getJSON( gGetScenarioIndexUrl, function( resp ) { + if ( resp.error ) { + var msg = resp.error ; + if ( resp.message ) + msg += "
" + escapeHTML(resp.message) + "
" ; + showErrorMsg( msg ) ; + return ; + } + _scenarioIndex = resp ; + onReady( resp ) ; + } ).fail( function( xhr, status, errorMsg ) { + showErrorMsg( "Can't get the scenario index:
" + escapeHTML(errorMsg) + "
" ) ; + return ; + } ) ; + + } +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +function getScenarioData( scenarioId, roarOverride, onReady ) +{ + // get the scenario data + var url = gGetScenarioUrl.replace( "ID", scenarioId ) ; + if ( roarOverride ) + url = addUrlParam( url, "roar", roarOverride) ; + $.getJSON( url, function( resp ) { + onReady( resp ) ; + } ).fail( function( xhr, status, errorMsg ) { + showErrorMsg( "Can't get the scenario details:
" + escapeHTML(errorMsg) + "
" ) ; + return ; + } ) ; +} + +// -------------------------------------------------------------------- + +} )() ; // end local namespace diff --git a/vasl_templates/webapp/static/snippets.js b/vasl_templates/webapp/static/snippets.js index 7baf8c4..aa80b28 100644 --- a/vasl_templates/webapp/static/snippets.js +++ b/vasl_templates/webapp/static/snippets.js @@ -4,16 +4,6 @@ var _MANDATORY_PARAMS = { scenario: { "SCENARIO_NAME": "scenario name", "SCENARIO_DATE": "scenario date" }, } ; -var _MONTH_NAMES = [ // nb: we assume English :-/ - "January", "February", "March", "April", "May", "June", - "July", "August", "September", "October", "November", "December" -] ; -var _DAY_OF_MONTH_POSTFIXES = { // nb: we assume English :-/ - 0: "th", - 1: "st", 2: "nd", 3: "rd", 4: "th", 5: "th", 6: "th", 7: "th", 8: "th", 9: "th", 10: "th", - 11: "th", 12: "th", 13: "th" -} ; - // NOTE: Blood & Jungle has a lot of multi-applicable notes that simply refer to other // multi-applicable notes e.g. "Fr C" = "French Multi-Applicable Note C". // NOTE: These are also used for Lend-Lease vehicles. @@ -818,14 +808,9 @@ function unload_snippet_params( unpack_scenario_date, template_id ) var scenario_date = get_scenario_date() ; if ( scenario_date ) { params.SCENARIO_DAY_OF_MONTH = scenario_date.getDate() ; - var postfix ; - if ( params.SCENARIO_DAY_OF_MONTH in _DAY_OF_MONTH_POSTFIXES ) - postfix = _DAY_OF_MONTH_POSTFIXES[ params.SCENARIO_DAY_OF_MONTH ] ; - else - postfix = _DAY_OF_MONTH_POSTFIXES[ params.SCENARIO_DAY_OF_MONTH % 10 ] ; - params.SCENARIO_DAY_OF_MONTH_POSTFIX = params.SCENARIO_DAY_OF_MONTH + postfix ; + params.SCENARIO_DAY_OF_MONTH_POSTFIX = make_formatted_day_of_month( params.SCENARIO_DAY_OF_MONTH ) ; params.SCENARIO_MONTH = 1 + scenario_date.getMonth() ; - params.SCENARIO_MONTH_NAME = _MONTH_NAMES[scenario_date.getMonth()] ; + params.SCENARIO_MONTH_NAME = get_month_name( scenario_date.getMonth() ) ; params.SCENARIO_YEAR = scenario_date.getFullYear() ; } } @@ -1586,8 +1571,8 @@ function do_load_scenario_data( params ) } else { $elem.val( params[key] ) ; - if ( key === "ROAR_ID" ) - set_roar_scenario( params[key] ) ; + if ( key === "ASA_ID" ) + updateForConnectedScenario( params[key], params.ROAR_ID ) ; } if ( $elem[0].nodeName.toLowerCase() === "select" ) $elem.trigger( "change" ) ; @@ -1680,8 +1665,8 @@ function do_load_scenario_data( params ) set_param( $(this), key ).trigger( "change" ) ; } ) ; } - if ( ! params.ROAR_ID ) - set_roar_scenario( null ) ; + if ( ! params.ASA_ID ) + updateForConnectedScenario( null, null ) ; // look for unrecognized keys var buf = [] ; diff --git a/vasl_templates/webapp/static/timer.js b/vasl_templates/webapp/static/timer.js new file mode 100644 index 0000000..4dabed6 --- /dev/null +++ b/vasl_templates/webapp/static/timer.js @@ -0,0 +1,27 @@ +/* jshint esnext: true */ + +// -------------------------------------------------------------------- + +class PerformanceTimer +{ + constructor( msg ) { + // initialize + if ( msg ) + console.log( "Starting timer:", msg ) ; + this.startTimer() ; + } + + startTimer() { + // start the timer + this.startTime = window.performance.now() ; + return this.startTime ; + } + + stopTimer() { + // stop the timer + var elapsedTime = window.performance.now() - this.startTime ; + this.startTime = null ; + return elapsedTime ; + } + +} diff --git a/vasl_templates/webapp/static/utils.js b/vasl_templates/webapp/static/utils.js index 9dfaedd..1f1a363 100644 --- a/vasl_templates/webapp/static/utils.js +++ b/vasl_templates/webapp/static/utils.js @@ -238,6 +238,20 @@ function click_dialog_button( $dlg, btn_text ) $( $dlg2.find( ".ui-dialog-buttonpane button:contains('" + btn_text + "')" ) ).click() ; } +function close_dialog_if_no_others( $dlg ) +{ + // NOTE: Escape doesn't always close a dialog, so we do it ourself, but we have to handle the case + // where more than one dialog is on-screen, and only close if there isn't another dialog on top of us. + if ( $(".ui.dialog").length >= 2 ) + return ; + // NOTE: We also want to stop Escape from closing a dialog if an image preview is being shown. + if ( $( ".jquery-image-zoom" ).length > 0 ) + return ; + + // close the dialog + $dlg.dialog( "close" ) ; +} + // -------------------------------------------------------------------- function ask( title, msg, args ) @@ -378,6 +392,33 @@ function add_flag_to_dialog_titlebar( $dlg, player_no ) // -------------------------------------------------------------------- +var _MONTH_NAMES = [ // nb: we assume English :-/ + "January", "February", "March", "April", "May", "June", + "July", "August", "September", "October", "November", "December" +] ; +var _DAY_OF_MONTH_POSTFIXES = { // nb: we assume English :-/ + 0: "th", + 1: "st", 2: "nd", 3: "rd", 4: "th", 5: "th", 6: "th", 7: "th", 8: "th", 9: "th", 10: "th", + 11: "th", 12: "th", 13: "th" +} ; + +function make_formatted_day_of_month( dom ) +{ + // generate the formatted day of month + if ( dom in _DAY_OF_MONTH_POSTFIXES ) + return dom + _DAY_OF_MONTH_POSTFIXES[ dom ] ; + else + return dom + _DAY_OF_MONTH_POSTFIXES[ dom % 10 ] ; +} + +function get_month_name( month ) +{ + // get the name of the month + return _MONTH_NAMES[ month ] ; +} + +// -------------------------------------------------------------------- + function getElemSizeAndPosition( $elem ) { // return the element's size and position @@ -412,16 +453,17 @@ function getUrlParam( param ) } } -function escapeHTML( val ) +function addUrlParam( url, param, val ) { - // escape HTML - return new Option(val).innerHTML ; + // add a parameter to a URL + var sep = url.indexOf( "?" ) === -1 ? "?" : "&" ; + return url + "?" + param + "=" + encodeURIComponent(val) ; } -function pluralString( n, str1, str2 ) -{ - return (n == 1) ? str1 : str2 ; -} +function escapeHTML( val ) { return new Option(val).innerHTML ; } +function pluralString( n, str1, str2 ) { return (n == 1) ? str1 : str2 ; } +function trimString( val ) { return val ? val.trim() : val ; } +function fpFmt( val, nDigits ) { return val.toFixed( nDigits ) ; } function percentString( val ) { diff --git a/vasl_templates/webapp/templates/check-roar.html b/vasl_templates/webapp/templates/check-roar.html deleted file mode 100644 index 7e29dc9..0000000 --- a/vasl_templates/webapp/templates/check-roar.html +++ /dev/null @@ -1,120 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - diff --git a/vasl_templates/webapp/templates/index.html b/vasl_templates/webapp/templates/index.html index f7e437b..87b236e 100644 --- a/vasl_templates/webapp/templates/index.html +++ b/vasl_templates/webapp/templates/index.html @@ -10,6 +10,7 @@ + @@ -29,6 +30,8 @@ + + @@ -72,6 +75,8 @@ {%include "select-vo-dialog.html"%} {%include "select-vo-image-dialog.html"%} {%include "edit-vo-dialog.html"%} +{%include "scenario-info-dialog.html"%} +{%include "scenario-search-dialog.html"%} {%include "select-roar-scenario-dialog.html"%} {%include "vassal.html"%} @@ -92,6 +97,7 @@ + @@ -115,6 +121,9 @@ gOrdnanceListingsUrl = "{{url_for('get_ordnance_listings',merge_common=1)}}" ; gVehicleNotesUrl = "{{url_for('get_vehicle_notes')}}" ; gOrdnanceNotesUrl = "{{url_for('get_ordnance_notes')}}" ; gGetVaslPieceInfoUrl = "{{url_for('get_vasl_piece_info')}}" ; +gGetScenarioIndexUrl = "{{url_for('get_scenario_index')}}" ; +gGetScenarioUrl = "{{url_for('get_scenario',scenario_id='ID')}}" ; +gGetScenarioCardUrl = "{{url_for('get_scenario_card',scenario_id='ID')}}" ; gGetRoarScenarioIndexUrl = "{{url_for('get_roar_scenario_index')}}" ; gAnalyzeVsavUrl = "{{url_for('analyze_vsav')}}" ; gAnalyzeVlogsUrl = "{{url_for('analyze_vlogs')}}" ; @@ -125,6 +134,7 @@ gHelpUrl = "{{url_for('show_help')}}" ; + @@ -139,6 +149,7 @@ gHelpUrl = "{{url_for('show_help')}}" ; + {%include "testing.html"%} diff --git a/vasl_templates/webapp/templates/scenario-card.html b/vasl_templates/webapp/templates/scenario-card.html new file mode 100644 index 0000000..e935213 --- /dev/null +++ b/vasl_templates/webapp/templates/scenario-card.html @@ -0,0 +1,153 @@ +
+ +
+ {%if SCENARIO_NAME%} + {%if SCENARIO_URL%}{%endif%}{{SCENARIO_NAME}}{%if SCENARIO_URL%}{%endif%} + {%else%} + Untitled scenario + {%endif%} + {%if SCENARIO_DISPLAY_ID%} ({{SCENARIO_DISPLAY_ID}}) {%endif%} +
+{%if SCENARIO_LOCATION%}
+ {{SCENARIO_LOCATION}} + {%if SCENARIO_DATE%} ({{SCENARIO_DATE}}) {%endif%} +
{%endif%} + + +{%if THEATER or TURN_COUNT or PLAYING_TIME or ICONS is defined%} {%endif%} + +
+ +
+ +{%if DESIGNER%} +
Designer: {{DESIGNER}}
+{%endif%} + +{%if PUBLICATION%}
+ + {%if PUBLICATION_URL%}{%endif%}{{PUBLICATION}}{%if PUBLICATION_URL%}{%endif%} + + {%if PUBLISHER%} + ({%if PUBLISHER_URL%}{%endif%}{{PUBLISHER}}{%if PUBLISHER_URL%}{%endif%}) + {%endif%} + {%if PUBLICATION_DATE%} ({{PUBLICATION_DATE}}) {%endif%} +
{%endif%} + +{%if PREV_PUBLICATION%} +
Previously: {{PREV_PUBLICATION}}
+{%endif%} + +{%if REVISED_PUBLICATION%} +
Revised: {{REVISED_PUBLICATION}}
+{%endif%} + +
+ +
+ +{%if MAP_URL%}
+ {%if MAP_URL.startswith("http") %} + + {%else%} +
{{MAP_URL}}
+ {%endif%} +
{%endif%} + +{%if OVERVIEW%}
+ {%if OVERVIEW_BRIEF%} + +
{{OVERVIEW_BRIEF|safe}}
+
more...
+ {%else%} + {{OVERVIEW|safe}} + {%endif%} +
{%endif%} + +
+ +{%if ICONS is defined and (ICONS.DEFENDER_OBA or ICONS.ATTACKER_OBA)%} + + +{%endif%} + + +{%if DEFENDER_NAME%} + + + + +
Defender: {{DEFENDER_NAME}} +{%if DEFENDER_DESC%} +
{{DEFENDER_DESC}} +{%endif%} +{%endif%} +{%if ATTACKER_NAME%} +
Attacker: {{ATTACKER_NAME}} +{%if ATTACKER_DESC%} +
{{ATTACKER_DESC}} +{%endif%} +{%endif%} +
+ +
+
+
+ +
+ +
+ +{%if BOARDS%}
+ Boards: {{BOARDS}} + {%if MAP_IMAGES%} {%for map in MAP_IMAGES%} + + {%endfor%} {%endif%} +
{%endif%} + +{%if OVERLAYS%}
Overlays: {{OVERLAYS}}
{%endif%} + +{%if EXTRA_RULES%}
Rules: {{EXTRA_RULES}}
{%endif%} + +{%if ERRATA%}
+{%if ERRATA|length == 1%} + Errata: + {{ERRATA[0].text}} + {%if ERRATA[0].source%} [{{ERRATA[0].source}}] {%endif%} +{%elif ERRATA|length >= 2%} + Errata:
    + {%for errata in ERRATA%}
  • + {{errata.text}} + {%if errata.source%}[{{errata.source}}] {%endif%} + {%endfor%} +
+{%endif%} + +
{%endif%} + +
diff --git a/vasl_templates/webapp/templates/scenario-info-dialog.html b/vasl_templates/webapp/templates/scenario-info-dialog.html new file mode 100644 index 0000000..c496324 --- /dev/null +++ b/vasl_templates/webapp/templates/scenario-info-dialog.html @@ -0,0 +1,3 @@ + diff --git a/vasl_templates/webapp/templates/scenario-nat-report.html b/vasl_templates/webapp/templates/scenario-nat-report.html new file mode 100644 index 0000000..997719e --- /dev/null +++ b/vasl_templates/webapp/templates/scenario-nat-report.html @@ -0,0 +1,132 @@ + + + + + + Scenario nationalities + + + + + +
+ + + + + + + + + + + diff --git a/vasl_templates/webapp/templates/scenario-search-dialog.html b/vasl_templates/webapp/templates/scenario-search-dialog.html new file mode 100644 index 0000000..650fdc0 --- /dev/null +++ b/vasl_templates/webapp/templates/scenario-search-dialog.html @@ -0,0 +1,34 @@ + diff --git a/vasl_templates/webapp/templates/tabs-scenario.html b/vasl_templates/webapp/templates/tabs-scenario.html index 1146d6d..414e33b 100644 --- a/vasl_templates/webapp/templates/tabs-scenario.html +++ b/vasl_templates/webapp/templates/tabs-scenario.html @@ -8,18 +8,12 @@ - +
- +
@@ -32,8 +26,9 @@
- + +
@@ -43,28 +38,28 @@
+
+ +
-  
- - +
- diff --git a/vasl_templates/webapp/tests/fixtures/asl-scenario-archive.json b/vasl_templates/webapp/tests/fixtures/asl-scenario-archive.json new file mode 100644 index 0000000..3326459 --- /dev/null +++ b/vasl_templates/webapp/tests/fixtures/asl-scenario-archive.json @@ -0,0 +1,137 @@ +{ + +"_generatedBy_": "(test data)", +"_generatedAt_": "(just now)", + +"scenarios": [ + +{ "scenario_id": "1", + "title": "Full content scenario", + "sc_id": "FCS-1", + "scen_location": "Some place", + "scen_date": "1945-12-31 00:00:00", + "theatre": "PTO", + "time_to_play": 1.25, + "min_turns": "6", "max_turns": "6", + "oba": "B", "night": "1", "deluxe": "1", "aslsk": "1", + "author": "Joe Author", + "pub_name": "ASL Journal", + "pub_id": "PUB-1", + "publisher_name": "Avalon Hill", + "publisher_id": "42", + "published_date": "2001-02-03", + "prior_publication": "Prior version", + "revision": "Revised version", + "gps_lat": "1.23", "gps_long": "4.56", + "overview": "This is a really good scenario.", + "defender": "Dutch", + "def_desc": "1st Dutch Army", + "attacker": "Romanian", + "att_desc": "1st Romanian Army", + "maps": [ 1, "2", "RB" ], + "mapImages": [ + "http://localhost:5010/static/images/asl-scenario-archive.png" + ], + "overlays": [ 1, "2", "OG1" ], + "misc": "Some extra rules", + "errata": [ + { "text": "Errata #1", "source": "over there" }, + { "text": "Errata #2", "source": "right here" } + ], + "playings": [ { + "totalGames": "10", + "defender_wins": "3", + "attacker_wins": "7" + } ] +}, + +{ "scenario_id": "2", + "title": "Fighting Withdrawal", + "sc_id": "FW", + "scen_location": "Sestroretsk Road, Terijoki, Finland", + "pub_name": "Beyond Valor", + "overview": "The Finns, seeking restitution for the Winter War of 1939, had erupted across the borders and breached the Soviet Karelian front even as the crisis to the south of Leningrad came.", + "attacker": "Finnish", + "defender": "Russians", + "playings": [ { + "totalGames": "134", + "defender_wins": "78", + "attacker_wins": "56" + } ] +}, + +{ "scenario_id": "3a", + "title": "MTO Scenario", + "theatre": "MTO" +}, +{ "scenario_id": "3b", + "title": "African Scenario", + "theatre": "Africa" +}, + +{ "scenario_id": "4a", + "title": "Unknown players", + "attacker": "Oceania", + "defender": "Eastasia", + "playings": [ { + "totalGames": "3", + "defender_wins": "2", + "attacker_wins": "1" + } ] +}, +{ "scenario_id": "4b", + "title": "Test partial nationality matches", + "defender": "Massed Russian Hordes", + "attacker": "The Japanese" +}, +{ "scenario_id": "4c", + "title": "Test nationality mapping", + "defender": "Gurkha", + "attacker": "Oh, Canada!" +}, + +{ "scenario_id": "5a", + "title": "OBA test", + "theatre": "ETO", + "scen_location": "Some place", + "scen_date": "1940-02-01 00:00:00", + "defender": "Finnish", + "attacker": "German", + "oba": "B" +}, +{ "scenario_id": "5b", + "title": "Defender OBA only", + "theatre": "ETO", + "scen_location": "Some place", + "scen_date": "1940-01-01 00:00:00", + "defender": "Burmese", + "oba": "D" +}, +{ "scenario_id": "5c", + "title": "Attacker OBA only", + "theatre": "ETO", + "scen_location": "Some place", + "scen_date": "1940-01-01 00:00:00", + "attacker": "Russian", + "defender": "The Other Guy", + "oba": "B" +}, + +{ "scenario_id": "6a", + "title": "ROAR Exact Match", + "sc_id": "EXACT-MATCH" +}, +{ "scenario_id": "6b", + "title": "ROAR Exact Match 2", + "sc_id": "EXACT-MATCH-2" +}, +{ "scenario_id": "6c", + "title": "ROAR Multiple Matches" +}, + +{ "scenario_id": "no-content" +} + +] + +} diff --git a/vasl_templates/webapp/tests/fixtures/data/default-template-pack/national-capabilities.json b/vasl_templates/webapp/tests/fixtures/data/default-template-pack/national-capabilities.json index 97ee232..40a07f1 100644 --- a/vasl_templates/webapp/tests/fixtures/data/default-template-pack/national-capabilities.json +++ b/vasl_templates/webapp/tests/fixtures/data/default-template-pack/national-capabilities.json @@ -24,6 +24,10 @@ ] }, +"burmese": { + "oba": null +}, + "allied-minor": { "oba": [ "1B", "1R" ] } diff --git a/vasl_templates/webapp/tests/fixtures/data/default-template-pack/nationalities.json b/vasl_templates/webapp/tests/fixtures/data/default-template-pack/nationalities.json index 5c50ba8..688e3a5 100644 --- a/vasl_templates/webapp/tests/fixtures/data/default-template-pack/nationalities.json +++ b/vasl_templates/webapp/tests/fixtures/data/default-template-pack/nationalities.json @@ -19,6 +19,10 @@ "display_name": "British", "ob_colors": [ "OBCOL:british","OBCOL2:british", "OBCOL-BORDER:british" ] }, +"british~canadian": { + "display_name": "Canadian", + "ob_colors": [ "OBCOL:british~canadian","OBCOL2:british~canadian", "OBCOL-BORDER:british~canadian" ] +}, "french": { "display_name": "French", @@ -35,6 +39,11 @@ "ob_colors": [ "OBCOL:finnish","OBCOL2:finnish", "OBCOL-BORDER:finnish" ] }, +"burmese": { + "display_name": "Burmese", + "ob_colors": [ "OBCOL:burmese","OBCOL2:burmese", "OBCOL-BORDER:burmese" ] +}, + "japanese": { "display_name": "Japanese", "ob_colors": [ "OBCOL:japanese","OBCOL2:japanese", "OBCOL-BORDER:japanese" ] diff --git a/vasl_templates/webapp/tests/fixtures/data/default-template-pack/players.j2 b/vasl_templates/webapp/tests/fixtures/data/default-template-pack/players.j2 index abca931..1a5711f 100644 --- a/vasl_templates/webapp/tests/fixtures/data/default-template-pack/players.j2 +++ b/vasl_templates/webapp/tests/fixtures/data/default-template-pack/players.j2 @@ -1,2 +1,2 @@ -player1=[{{PLAYER_1}}:{{PLAYER_1_NAME}}] ; ELR=[{{PLAYER_1_ELR}}] ; SAN=[{{PLAYER_1_SAN}}] -player2=[{{PLAYER_2}}:{{PLAYER_2_NAME}}] ; ELR=[{{PLAYER_2_ELR}}] ; SAN=[{{PLAYER_2_SAN}}] +player1=[{{PLAYER_1}}:{{PLAYER_1_NAME}}] ; ELR=[{{PLAYER_1_ELR}}] ; SAN=[{{PLAYER_1_SAN}}] ; description=[{{PLAYER_1_DESCRIPTION}}] +player2=[{{PLAYER_2}}:{{PLAYER_2_NAME}}] ; ELR=[{{PLAYER_2_ELR}}] ; SAN=[{{PLAYER_2_SAN}}] ; description=[{{PLAYER_2_DESCRIPTION}}] diff --git a/vasl_templates/webapp/tests/fixtures/roar-scenario-index.json b/vasl_templates/webapp/tests/fixtures/roar-scenario-index.json index 34be9d0..110876f 100644 --- a/vasl_templates/webapp/tests/fixtures/roar-scenario-index.json +++ b/vasl_templates/webapp/tests/fixtures/roar-scenario-index.json @@ -1,29 +1,44 @@ { -"1": { "scenario_id": "TEST 1", "name": "Fighting Withdrawal", - "publication": "Beyond Valor", - "results": [ [ "Finnish", 200 ], [ "Russian", 300 ] ], - "url": "http://test.com/1" -}, +"_lastUpdated_": "some time ago", +"_nPlayings_": 123, +"_generatedBy_": "(test data)", +"_generatedAt_": "(just now)", -"2": { "scenario_id": "TEST 2", "name": "Whitewash 1", - "results": [ [ "American", 10 ], [ "Japanese", 0 ] ], - "url": "http://test.com/2" +"100": { + "scenario_id": "FW", "name": "Fighting Withdrawal", + "publication": "Beyond Valor", + "results": [ [ "Finnish", 279 ], [ "Russian", 325 ] ], + "url": "http://asl-roar.com/100" }, -"3": { "scenario_id": "TEST 3", "name": "Whitewash 2", - "results": [ [ "American", 0 ], [ "Japanese", 10 ] ], - "url": "http://test.com/3" +"101": { + "scenario_id": "ANOTHER", "name": "Another ROAR scenario", + "results": [ [ "British", 2 ], [ "French", 1 ] ], + "url": "http://asl-roar.com/101" }, -"4": { "scenario_id": "TEST 4", "name": "No playings", - "results": [ [ "British", 0 ], [ "French", 0 ] ], - "url": "http://test.com/4" +"200": { + "name": "!! ROAR exact-match !!" }, - -"5": { "scenario_id": "TEST 5", "name": "Unknown nationality", - "results": [ [ "American", 1 ], [ "Martian", 1 ] ], - "url": "http://test.com/5" +"210": { + "name": "ROAR Exact Match 2", + "scenario_id": "foo" +}, +"211": { + "name": "ROAR Exact Match 2", + "scenario_id": "EXACT-MATCH-2" +}, +"220": { + "name": "ROAR Multiple Matches", + "results": [ [ "British", 5 ], [ "French", 2 ] ] +}, +"221": { + "name": "ROAR Multiple Matches" +}, +"222": { + "name": "ROAR Multiple Matches", + "results": [ [ "German", 10 ], [ "Russian", 5 ] ] } } diff --git a/vasl_templates/webapp/tests/remote.py b/vasl_templates/webapp/tests/remote.py index 51e72ab..b992873 100644 --- a/vasl_templates/webapp/tests/remote.py +++ b/vasl_templates/webapp/tests/remote.py @@ -22,7 +22,7 @@ from vasl_templates.webapp.config.constants import DATA_DIR from vasl_templates.webapp.vasl_mod import set_vasl_mod from vasl_templates.webapp import main as webapp_main from vasl_templates.webapp import snippets as webapp_snippets -from vasl_templates.webapp import roar as webapp_roar +from vasl_templates.webapp import scenarios as webapp_scenarios from vasl_templates.webapp import vasl_mod as vasl_mod_module from vasl_templates.webapp import vo_utils as vo_utils_module @@ -314,9 +314,18 @@ class ControlTests: dname = os.path.join( os.path.split(__file__)[0], "fixtures" ) fname = os.path.join( dname, fname ) _logger.info( "Setting the ROAR scenario index file: %s", fname ) - with open( fname, "r" ) as fp: - with webapp_roar._roar_scenario_index_lock: #pylint: disable=protected-access - webapp_roar._roar_scenario_index = json.load( fp ) #pylint: disable=protected-access + webapp_scenarios._roar_scenarios._set_data( fname ) #pylint: disable=protected-access + else: + assert False + return self + + def _set_scenario_index( self, fname=None ): + """Set the scenario index file.""" + if fname: + dname = os.path.join( os.path.split(__file__)[0], "fixtures" ) + fname = os.path.join( dname, fname ) + _logger.info( "Setting the scenario index file: %s", fname ) + webapp_scenarios._asa_scenarios._set_data( fname ) #pylint: disable=protected-access else: assert False return self diff --git a/vasl_templates/webapp/tests/test_default_scenario.py b/vasl_templates/webapp/tests/test_default_scenario.py index c3b86ef..e54e520 100644 --- a/vasl_templates/webapp/tests/test_default_scenario.py +++ b/vasl_templates/webapp/tests/test_default_scenario.py @@ -48,6 +48,7 @@ def test_default_scenario( webapp, webdriver ): check_droplist( "PLAYER_2", "japanese" ) check_droplist( "PLAYER_2_ELR", "3" ) check_droplist( "PLAYER_2_SAN", "4" ) + check_textbox( "PLAYERS_WIDTH", "" ) # check the victory conditions check_textarea( "VICTORY_CONDITIONS", "default victory conditions" ) diff --git a/vasl_templates/webapp/tests/test_ob.py b/vasl_templates/webapp/tests/test_ob.py index 1de4e68..2d8322e 100644 --- a/vasl_templates/webapp/tests/test_ob.py +++ b/vasl_templates/webapp/tests/test_ob.py @@ -3,13 +3,11 @@ import re import types -from selenium.webdriver.support.ui import Select - from vasl_templates.webapp.tests.utils import \ get_nationalities, wait_for_clipboard, get_stored_msg, set_stored_msg_marker, select_tab, \ find_child, find_children, \ add_simple_note, edit_simple_note, get_sortable_entry_count, drag_sortable_entry_to_trash, \ - init_webapp, wait_for, adjust_html, set_scenario_date, set_player, select_droplist_val + init_webapp, wait_for, adjust_html, set_scenario_date, set_player, set_theater # --------------------------------------------------------------------- @@ -210,8 +208,7 @@ def test_nationality_specific( webapp, webdriver ): #pylint: disable=too-many-lo nat2 = expected[0] else: nat2 = expected[0][0] - sel = Select( find_child( "select[name='SCENARIO_THEATER']" ) ) - select_droplist_val( sel, expected[0][1] ) + set_theater( expected[0][1] ) if nat == nat2: # the button should be shown for this nationality assert elem.is_displayed() diff --git a/vasl_templates/webapp/tests/test_players.py b/vasl_templates/webapp/tests/test_players.py index ea0657a..8046ea4 100644 --- a/vasl_templates/webapp/tests/test_players.py +++ b/vasl_templates/webapp/tests/test_players.py @@ -1,9 +1,7 @@ """ Test how players are handled. """ -from selenium.webdriver.support.ui import Select - from vasl_templates.webapp.tests.utils import get_nationality_display_name, select_tab, find_child, \ - init_webapp, load_scenario_params, set_player, \ + init_webapp, load_scenario_params, set_player, get_player_nat, \ wait_for, get_sortable_entry_count, click_dialog_button # --------------------------------------------------------------------- @@ -21,10 +19,9 @@ def test_player_change( webapp, webdriver ): # make sure that the UI was updated correctly for the initial players for player_no in [1,2]: - sel = Select( find_child( "select[name='PLAYER_{}']".format( player_no ) ) ) - player_id = sel.first_selected_option.get_attribute( "value" ) - expected = "{} OB".format( get_nationality_display_name(player_id) ) - assert ob_tabs[player_no].text.strip() == expected + player_nat = get_player_nat( player_no ) + expected = "{} OB".format( get_nationality_display_name( player_nat ) ) + assert ob_tabs[ player_no ].text.strip() == expected # check that we can change the player nationalities without being asked to confirm # nb: the frontend ignores the vehicle/ordnance snippet widths when deciding if to ask for confirmation diff --git a/vasl_templates/webapp/tests/test_roar.py b/vasl_templates/webapp/tests/test_roar.py deleted file mode 100644 index 1be2f4e..0000000 --- a/vasl_templates/webapp/tests/test_roar.py +++ /dev/null @@ -1,148 +0,0 @@ -"""Test ROAR integration.""" - -import re - -from selenium.webdriver.support.ui import Select -from selenium.webdriver.common.keys import Keys - -from vasl_templates.webapp.tests.utils import init_webapp, select_tab, select_menu_option, click_dialog_button, \ - set_stored_msg_marker, get_stored_msg, set_template_params, add_simple_note, \ - find_child, find_children, wait_for_elem - -# --------------------------------------------------------------------- - -def test_roar( webapp, webdriver ): - """Test ROAR integration.""" - - # initialize - init_webapp( webapp, webdriver ) - - # check the ROAR info panel - _check_roar_info( webdriver, None ) - - # select a ROAR scenario - _select_roar_scenario( "fighting withdrawal" ) - _check_roar_info( webdriver, ( - ( "Fighting Withdrawal", "TEST 1" ), - ( "Finnish", 200, "Russian", 300 ), - ( 40, 60 ) - ) ) - - # select some other ROAR scenarios - # NOTE: The scenario name/ID are already populated, so they don't get updated with the new details. - _select_roar_scenario( "whitewash 1" ) - _check_roar_info( webdriver, ( - ( "Fighting Withdrawal", "TEST 1" ), - ( "American", 10, "Japanese", 0 ), - ( 100, 0 ) - ) ) - _select_roar_scenario( "whitewash 2" ) - _check_roar_info( webdriver, ( - ( "Fighting Withdrawal", "TEST 1" ), - ( "American", 0, "Japanese", 10 ), - ( 0, 100 ) - ) ) - - # unlink from the ROAR scenario - btn = find_child( "#disconnect-roar" ) - btn.click() - _check_roar_info( webdriver, None ) - - # select another ROAR scenario (that has no playings) - set_template_params( { "SCENARIO_NAME": "", "SCENARIO_ID": "" } ) - _select_roar_scenario( "no playings" ) - _check_roar_info( webdriver, ( - ( "No playings", "TEST 4" ), - ( "British", 0, "French", 0 ), - None - ) ) - -# --------------------------------------------------------------------- - -def test_setting_players( webapp, webdriver ): - """Test setting players after selecting a ROAR scenario.""" - - # initialize - init_webapp( webapp, webdriver ) - - # select a ROAR scenario - _select_roar_scenario( "fighting withdrawal" ) - _check_players( "finnish", "russian" ) - - # add something to the Player 1 OB - select_tab( "ob1" ) - add_simple_note( find_child("#ob_setups-sortable_1"), "a setup note", None ) - - # select another ROAR scenario - select_tab( "scenario" ) - _select_roar_scenario( "whitewash 1" ) - _check_players( "finnish", "japanese" ) # nb: player 1 remains unchanged - - # add something to the Player 2 OB - select_tab( "ob2" ) - add_simple_note( find_child("#ob_setups-sortable_2"), "another setup note", None ) - - # select another ROAR scenario - select_tab( "scenario" ) - _select_roar_scenario( "no playings" ) - _check_players( "finnish", "japanese" ) # nb: both players remain unchanged - - # reset the scenario and select a ROAR scenario with an unknown nationality - select_menu_option( "new_scenario" ) - click_dialog_button( "OK" ) # nb: dismiss the "discard changes?" prompt - _ = set_stored_msg_marker( "_last-warning_" ) - _select_roar_scenario( "unknown nationality" ) - _check_players( "american", "russian" ) - last_warning = get_stored_msg( "_last-warning_" ) - assert re.search( r"Unrecognized nationality.+\bMartian\b", last_warning ) - -# --------------------------------------------------------------------- - -def _select_roar_scenario( scenario_name ): - """Select a ROAR scenario.""" - btn = find_child( "#search-roar" ) - btn.click() - dlg = wait_for_elem( 2, ".ui-dialog.select-roar-scenario" ) - search_field = find_child( "input", dlg ) - search_field.send_keys( scenario_name ) - elems = find_children( ".select2-results li", dlg ) - assert len(elems) == 1 - search_field.send_keys( Keys.RETURN ) - -def _check_roar_info( webdriver, expected ): - """Check the state of the ROAR info panel.""" - - # check if the panel is displayed or hidden - panel = find_child( "#roar-info" ) - if not expected: - assert not panel.is_displayed() - return - assert panel.is_displayed() - - # check the displayed information - assert find_child( ".name.player1", panel ).text == expected[1][0] - assert find_child( ".count.player1", panel ).text == "({})".format( expected[1][1] ) - assert find_child( ".name.player2", panel ).text == expected[1][2] - assert find_child( ".count.player2", panel ).text == "({})".format( expected[1][3] ) - - # check the progress bars - progress1 = find_child( ".progressbar.player1", panel ) - progress2 = find_child( ".progressbar.player2", panel ) - if expected[2]: - label1 = "{}%".format( expected[2][0] ) - label2 = "{}%".format( expected[2][1] ) - expected1, expected2 = 100-expected[2][0], expected[2][1] - else: - label1 = label2 = "" - expected1, expected2 = 100, 0 - assert find_child( ".label", progress1 ).text == label1 - assert webdriver.execute_script( "return $(arguments[0]).progressbar('value')", progress1 ) == expected1 - assert find_child( ".label", progress2 ).text == label2 - assert webdriver.execute_script( "return $(arguments[0]).progressbar('value')", progress2 ) == expected2 - -def _check_players( expected1, expected2 ): - """Check the selected players.""" - sel = Select( find_child( "select[name='PLAYER_1']" ) ) - assert sel.first_selected_option.get_attribute("value") == expected1 - sel = Select( find_child( "select[name='PLAYER_2']" ) ) - assert sel.first_selected_option.get_attribute("value") == expected2 diff --git a/vasl_templates/webapp/tests/test_scenario_persistence.py b/vasl_templates/webapp/tests/test_scenario_persistence.py index c31206c..694b107 100644 --- a/vasl_templates/webapp/tests/test_scenario_persistence.py +++ b/vasl_templates/webapp/tests/test_scenario_persistence.py @@ -18,9 +18,10 @@ ALL_SCENARIO_PARAMS = { "SCENARIO_NAME", "SCENARIO_ID", "SCENARIO_LOCATION", "SCENARIO_THEATER", "SCENARIO_DATE", "SCENARIO_WIDTH", - "ROAR_ID", - "PLAYER_1", "PLAYER_1_ELR", "PLAYER_1_SAN", - "PLAYER_2", "PLAYER_2_ELR", "PLAYER_2_SAN", + "ASA_ID", "ROAR_ID", + "PLAYER_1", "PLAYER_1_ELR", "PLAYER_1_SAN", "PLAYER_1_DESCRIPTION", + "PLAYER_2", "PLAYER_2_ELR", "PLAYER_2_SAN", "PLAYER_2_DESCRIPTION", + "PLAYERS_WIDTH", "VICTORY_CONDITIONS", "VICTORY_CONDITIONS_WIDTH", "SCENARIO_NOTES", "SSR", "SSR_WIDTH", @@ -71,9 +72,12 @@ def test_scenario_persistence( webapp, webdriver ): #pylint: disable=too-many-st "SCENARIO_THEATER": "PTO", "SCENARIO_DATE": "12/31/1945", "SCENARIO_WIDTH": "101", - "ROAR_ID": "", + "ASA_ID": "", "ROAR_ID": "", "PLAYER_1": "russian", "PLAYER_1_ELR": "1", "PLAYER_1_SAN": "2", + "PLAYER_1_DESCRIPTION": "The Army of Player 1", "PLAYER_2": "german", "PLAYER_2_ELR": "3", "PLAYER_2_SAN": "4", + "PLAYER_2_DESCRIPTION": "The Army of Player 2", + "PLAYERS_WIDTH": "42", "VICTORY_CONDITIONS": "just do it!", "VICTORY_CONDITIONS_WIDTH": "102", "SCENARIO_NOTES": [ { "caption": "note #1", "width": "" }, diff --git a/vasl_templates/webapp/tests/test_scenario_search.py b/vasl_templates/webapp/tests/test_scenario_search.py new file mode 100644 index 0000000..e0b95ba --- /dev/null +++ b/vasl_templates/webapp/tests/test_scenario_search.py @@ -0,0 +1,756 @@ +"""" Test scenario search. """ + +import os +import time + +import pytest +from selenium.webdriver.common.action_chains import ActionChains +from selenium.webdriver.common.keys import Keys +from selenium.common.exceptions import ElementClickInterceptedException, ElementNotInteractableException + +from vasl_templates.webapp.tests.test_scenario_persistence import save_scenario, load_scenario +from vasl_templates.webapp.tests.utils import init_webapp, select_tab, new_scenario, \ + set_player, set_template_params, set_scenario_date, get_player_nat, get_theater, set_theater, \ + wait_for, wait_for_elem, find_child, find_children, get_css_classes + +# --------------------------------------------------------------------- + +def test_scenario_cards( webapp, webdriver ): + """Test showing scenario cards.""" + + # initialize + init_webapp( webapp, webdriver ) + + # search for the "full" scenario and check the scenario card + _do_scenario_search( "full", [1], webdriver ) + card = _unload_scenario_card() + assert card == { + "scenario_name": "Full content scenario", "scenario_id": "FCS-1", + "scenario_url": "https://aslscenarioarchive.com/scenario.php?id=1", + "scenario_location": "Some place", "scenario_date": "31st December, 1945", + "theater": "PTO", "turn_count": "6", "playing_time": "1\u00bc hours", + "icons": [ "aslsk.png", "deluxe.png", "night.png", "oba.png" ], + "designer": "Joe Author", + "publication": "ASL Journal", + "publication_url": "https://aslscenarioarchive.com/viewPub.php?id=PUB-1", + "publication_date": "3rd February, 2001", + "publisher": "Avalon Hill", + "publisher_url": "https://aslscenarioarchive.com/viewPublisher.php?id=42", + "prev_publication": "Prior version", "revised_publication": "Revised version", + "overview": "This is a really good scenario.", + "map_url": "MAP:[1.23,4.56]", + "defender_name": "Dutch", "defender_desc": "1st Dutch Army", + "attacker_name": "Romanian", "attacker_desc": "1st Romanian Army", + "balances": { "asa": [ + { "name": "Dutch", "wins": 3, "percentage": 30 }, { "name": "Romanian", "wins": 7, "percentage": 70 } + ] }, + "oba": [ + [ "Dutch", "1B", "1R" ], [ "Romanian", "-", "-" ] + ], + "boards": "1, 2, RB", + "map_previews": [ "asl-scenario-archive.png" ], + "overlays": "1, 2, OG1", + "extra_rules": "Some extra rules", + "errata": [ + [ "Errata #1", "over there" ], + [ "Errata #2", "right here" ] + ] + } + + # search for the "empty" scenario and check the scenario card + _do_scenario_search( "Untitled", ["no-content"], webdriver ) + card = _unload_scenario_card() + assert card == { + "scenario_name": "Untitled scenario (#no-content)", + "scenario_url": "https://aslscenarioarchive.com/scenario.php?id=no-content", + } + +# --------------------------------------------------------------------- + +def test_import_scenario( webapp, webdriver ): + """Test importing a scenario.""" + + # initialize + init_webapp( webapp, webdriver ) + + # import the "full" scenario + dlg = _do_scenario_search( "full", [1], webdriver ) + find_child( "button.import", dlg ).click() + _check_scenario( + SCENARIO_NAME="Full content scenario", SCENARIO_ID="FCS-1", + SCENARIO_LOCATION="Some place", + PLAYER_1="dutch", PLAYER_1_DESCRIPTION="1st Dutch Army", + PLAYER_2="romanian", PLAYER_2_DESCRIPTION="1st Romanian Army", + THEATER="PTO" + ) + + # import the "empty" scenario + _unlink_scenario() + dlg = _do_scenario_search( "Untitled", ["no-content"], webdriver ) + find_child( "button.import", dlg ).click() + find_child( "button.confirm-import", dlg ).click() + # NOTE: Since there are no players defined in the scenario, what's on-screen will be left unchanged. + _check_scenario( + SCENARIO_NAME="Untitled scenario (#no-content)", SCENARIO_ID="", + SCENARIO_LOCATION="", + PLAYER_1="dutch", PLAYER_1_DESCRIPTION="", + PLAYER_2="romanian", PLAYER_2_DESCRIPTION="", + THEATER="ETO" + ) + +def _check_scenario( **kwargs ): + """Check the scenario import.""" + keys = [ "SCENARIO_NAME", "SCENARIO_ID", "SCENARIO_LOCATION", "PLAYER_1_DESCRIPTION", "PLAYER_2_DESCRIPTION" ] + for key in keys: + elem = find_child( "input[name='{}']".format( key ) ) + assert elem.get_attribute( "value" ) == kwargs[ key ] + assert get_player_nat( 1 ) == kwargs[ "PLAYER_1" ] + assert get_player_nat( 2 ) == kwargs[ "PLAYER_2" ] + assert get_theater() == kwargs[ "THEATER" ] + +# --------------------------------------------------------------------- + +def test_import_warnings( webapp, webdriver ): #pylint: disable=too-many-statements + """Test warnings when importing a scenario.""" + + # initialize + init_webapp( webapp, webdriver, scenario_persistence=1 ) + + # import a scenario on top of an empty scenario + dlg = _do_scenario_search( "full", [1], webdriver ) + find_child( "button.import", dlg ).click() + wait_for( 2, lambda: not dlg.is_displayed() ) + + def do_test( param_name, expected_warning, expected_val, curr_val="CURR-VAL" ): #pylint: disable=missing-docstring + + # start with a new scenario + new_scenario() + + # set the scenario parameter + set_template_params( { param_name: curr_val } ) + + # import a scenario + _do_scenario_search( "full", [1], webdriver ) + find_child( "#scenario-search button.import" ).click() + + # check if any warnings were expected + elem = find_child( "[name='{}']".format( param_name ) ) + if expected_warning: + # yup - make sure they are being shown + warnings = find_children( ".warnings input[type='checkbox']", dlg ) + if expected_warning: + assert [ w.get_attribute( "name" ) for w in warnings ] == [ expected_warning ] + else: + assert not warnings + # cancel the import + find_child( "button.cancel-import", dlg ).click() + wait_for( 2, lambda: not find_child( ".warnings", dlg ).is_displayed() ) + # do the import again, and accept it + find_child( "#scenario-search button.import" ).click() + find_child( "button.confirm-import", dlg ).click() + assert not dlg.is_displayed() + assert elem.get_attribute( "value" ) == expected_val + else: + # nope - check that the import was done + assert not dlg.is_displayed() + assert elem.get_attribute( "value" ) == expected_val + + # do the tests + do_test( "SCENARIO_NAME", "scenario_name", "Full content scenario" ) + do_test( "SCENARIO_ID", "scenario_display_id", "FCS-1" ) + do_test( "SCENARIO_LOCATION", "scenario_location", "Some place" ) + do_test( "SCENARIO_DATE", "scenario_date_iso", "12/31/1945", curr_val="01/02/1940" ) + do_test( "SCENARIO_THEATER", None, "PTO", curr_val="Burma" ) + do_test( "PLAYER_1", None, "dutch", curr_val="german" ) + do_test( "PLAYER_1_DESCRIPTION", "defender_desc", "1st Dutch Army" ) + do_test( "PLAYER_2", None, "romanian", curr_val="german" ) + do_test( "PLAYER_2_DESCRIPTION", "attacker_desc", "1st Romanian Army" ) + + # test importing a scenario on top of existing OB owned by the same nationality + new_scenario() + load_scenario( { + "PLAYER_1": "dutch", + } ) + _do_scenario_search( "full", [1], webdriver ) + find_child( "#scenario-search button.import" ).click() + warnings = find_children( ".warnings input[type='checkbox']", dlg ) + assert [ w.get_attribute( "name" ) for w in warnings ] == [] + + # test importing a scenario on top of existing OB owned by the same nationality + new_scenario() + load_scenario( { + "PLAYER_1": "dutch", + "OB_SETUPS_1": [ { "caption": "Dutch setup note" } ] + } ) + _do_scenario_search( "full", [1], webdriver ) + find_child( "#scenario-search button.import" ).click() + wait_for( 2, lambda: not dlg.is_displayed() ) + + # test importing a scenario on top of existing OB owned by the different nationality + new_scenario() + load_scenario( { + "PLAYER_1": "german", + "OB_SETUPS_1": [ { "caption": "German setup note" } ] + } ) + _do_scenario_search( "full", [1], webdriver ) + find_child( "#scenario-search button.import", dlg ).click() + warnings = wait_for( 2, lambda: find_children( ".warnings input[type='checkbox']", dlg ) ) + assert [ w.get_attribute( "name" ) for w in warnings ] == [ "defender_name" ] + assert not warnings[0].is_selected() + try: + warnings[0].click() + except (ElementClickInterceptedException, ElementNotInteractableException): + # FUDGE! We sometimes get a "Other element would receive the click" (div.warning) error, + # I suspect because the warnings panel is still sliding up. + time.sleep( 0.5 ) + warnings[0].click() + find_child( "button.confirm-import", dlg ).click() + assert not dlg.is_displayed() + assert get_player_nat( 1 ) == "dutch" + +# --------------------------------------------------------------------- + +def test_oba_info( webapp, webdriver ): + """Test showing OBA info in the scenario card.""" + + # initialize + init_webapp( webapp, webdriver ) + + def check_oba_info( card, expected ): + """Check the OBA info.""" + assert card["oba"] == expected + assert "oba.png" in card["icons"] + + # search for the "OBA test" scenario + dlg = _do_scenario_search( "OBA test", ["5a"], webdriver ) + expected = [ + [ "Finnish", "6B", "3R", "Plentiful Ammo included" ], + [ "German", "1B", "2R", "a comment", "another comment" ] + ] + check_oba_info( _unload_scenario_card(), expected ) + + # import the scenario and check the OBA info + find_child( "button.import" ).click() + wait_for( 2, lambda: not dlg.is_displayed() ) + check_oba_info( _get_scenario_info(), expected ) + + # change the scenario date and check the OBA info + set_scenario_date( "01/02/1943" ) + check_oba_info( _get_scenario_info(), [ + [ "Finnish", "8B", "3R", "Plentiful Ammo included" ], + [ "German", "1B", "2R", "a comment", "another comment" ], + "Based on a scenario date of 1/43." + ] ) + + # change the scenario date and check the OBA info + set_scenario_date( "" ) + check_oba_info( _get_scenario_info(), expected ) + + # check a scenario where only the defender has OBA + dlg = _do_scenario_search( "Defender OBA", ["5b"], webdriver ) + check_oba_info( _unload_scenario_card(), [ + [ "Burmese", "-", "-" ], + None + ] ) + + # check a scenario where the attacker has OBA, the defender is an unknwon nationality + dlg = _do_scenario_search( "Attacker OBA", ["5c"], webdriver ) + check_oba_info( _unload_scenario_card(), [ + [ "The Other Guy", "?", "?" ], + [ "Russian", "3B", "4R" ] + ] ) + +# --------------------------------------------------------------------- + +def test_unknown_theaters( webapp, webdriver ): + """Test importing scenarios with unknown theaters.""" + + # initialize + init_webapp( webapp, webdriver ) + + # search for the "MTO" scenario (this has a theater mapping) + set_theater( "Korea" ) + dlg = _do_scenario_search( "MTO", ["3a"], webdriver ) + find_child( "button.import", dlg ).click() + wait_for( 2, lambda: not dlg.is_displayed() ) + assert get_theater() == "ETO" + + # search for the "Africa" scenario (this has no theater mapping) + new_scenario() + set_theater( "Korea" ) + dlg = _do_scenario_search( "Africa", ["3b"], webdriver ) + find_child( "button.import", dlg ).click() + _check_warnings( [], ["Unknown theater: Africa"] ) + find_child( "button.confirm-import", dlg ).click() + assert get_theater() == "other" + +# --------------------------------------------------------------------- + +def test_unknown_nats( webapp, webdriver ): + """Test importing scenarios with unknown player nationalities.""" + + # initialize + init_webapp( webapp, webdriver ) + + # test importing a scenario with 2 completely unknown player nationalities + set_player( 1, "french" ) + set_player( 2, "italian" ) + dlg = _do_scenario_search( "Unknown players", ["4a"], webdriver ) + find_child( "button.import", dlg ).click() + _check_warnings( [], ["Unknown player: Eastasia","Unknown player: Oceania"] ) + expected_bgraphs = { "asa": [ + { "name": "Eastasia", "wins": 2, "percentage": 67 }, + { "name": "Oceania", "wins": 1, "percentage": 33 } + ] } + assert _unload_balance_graphs( dlg ) == expected_bgraphs + find_child( "button.confirm-import", dlg ).click() + wait_for( 2, lambda: not find_child( "#scenario-search" ).is_displayed() ) + assert get_player_nat( 1 ) == "french" + assert get_player_nat( 2 ) == "italian" + + # test matching nationalities (partial name matches) + new_scenario() + dlg = _do_scenario_search( "partial nationality matches", ["4b"], webdriver ) + find_child( "button.import", dlg ).click() + wait_for( 2, lambda: not find_child( "#scenario-search" ).is_displayed() ) + assert get_player_nat( 1 ) == "russian" + assert get_player_nat( 2 ) == "japanese" + + # test nationality mapping + new_scenario() + dlg = _do_scenario_search( "nationality mapping", ["4c"], webdriver ) + find_child( "button.import", dlg ).click() + wait_for( 2, lambda: not find_child( "#scenario-search" ).is_displayed() ) + assert get_player_nat( 1 ) == "british" + assert get_player_nat( 2 ) == "british~canadian" + +# --------------------------------------------------------------------- + +def test_roar_matching( webapp, webdriver ): + """Test matching scenarios with ROAR scenarios.""" + + # initialize + init_webapp( webapp, webdriver ) + + # search for the "full" scenario + _do_scenario_search( "full", [1], webdriver ) + card = _unload_scenario_card() + assert card["balances"] == { "asa": [ + { "name": "Dutch", "wins": 3, "percentage": 30 }, + { "name": "Romanian", "wins": 7, "percentage": 70 } + ] } + + # search for the "empty" scenario + _do_scenario_search( "Untitled", ["no-content"], webdriver ) + card = _unload_scenario_card() + assert "balances" not in card + + # search for "Fighting Withdrawal" + _do_scenario_search( "Withdrawal", ["2"], webdriver ) + card = _unload_scenario_card() + assert card["balances"] == { + # NOTE: The 2 sides in the ROAR balance graph should have been swapped around + # to match what's in the ASL Scenario Archive. + "roar": [ + { "name": "Russian", "wins": 325, "percentage": 54 }, + { "name": "Finnish", "wins": 279, "percentage": 46 } + ], + "asa": [ + { "name": "Russians", "wins": 78, "percentage": 58 }, # nb: the player nationality has a trailing "s" + { "name": "Finnish", "wins": 56, "percentage": 42 } + ] + } + +# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +@pytest.mark.skipif( pytest.config.option.server_url is not None, reason="--server-url specified" ) #pylint: disable=no-member +def test_roar_matching2( webapp, webdriver ): + """Test matching scenarios with ROAR scenarios.""" + + # initialize + init_webapp( webapp, webdriver ) + + from vasl_templates.webapp.scenarios import _asa_scenarios, _match_roar_scenario + def do_test( scenario_name, expected ): #pylint: disable=missing-docstring + scenarios = [ + s for s in _asa_scenarios.index.values() #pylint: disable=no-member + if s.get( "title" ) == scenario_name + ] + assert len(scenarios) == 1 + matches = _match_roar_scenario( scenarios[0] ) + if not isinstance( expected, list ): + expected = [ expected ] + assert [ ( m["roar_id"], m["name"] ) for m in matches ] == expected + + with _asa_scenarios: + + # check for no match + do_test( "Full content scenario", [] ) + + # check for an exact match + do_test( "ROAR Exact Match", ("200","!! ROAR exact-match !!") ) + + # check for multiple matches, resolved by the scenario ID + do_test( "ROAR Exact Match 2", ("211","ROAR Exact Match 2") ) + + # check for multiple matches + # NOTE: These should be sorted in descending order of number of playings. + do_test( "ROAR Multiple Matches", [ + ("222","ROAR Multiple Matches"), ("220","ROAR Multiple Matches"), ("221","ROAR Multiple Matches") + ] ) + +# --------------------------------------------------------------------- + +def test_roar_linking( webapp, webdriver ): + """Test linking scenarios with ROAR scenarios.""" + + # initialize + init_webapp( webapp, webdriver ) + + def check( bgraph, connect, disconnect ): + """Check the scenario card.""" + + # unload the scenario card + if find_child( "#scenario-search" ).is_displayed(): + parent = find_child( "#scenario-search .scenario-card" ) + elif find_child( "#scenario-info-dialog" ).is_displayed(): + parent = find_child( "#scenario-info-dialog .scenario-card" ) + else: + assert False + card = _unload_scenario_card() + + # check if the balance graph is shown + if bgraph: + balance = card["balances"]["roar"] + assert balance[0]["name"] == bgraph[0] + assert balance[1]["name"] == bgraph[1] + else: + assert "roar" not in card["balances"] + + # check if the "connect to ROAR" button is shown + elem = find_child( ".connect-roar", parent ) + if connect: + assert elem.is_displayed() + else: + assert not elem.is_displayed() + + # check if the "disconnect from ROAR" is shown + elem = find_child( ".disconnect-roar", parent ) + if disconnect: + assert elem.is_displayed() + else: + assert not elem or not elem.is_displayed() + + # import the "Fighting Withdrawal" scenario + _do_scenario_search( "withdrawal", [2], webdriver ) + check( ["Russian","Finnish"], False, False ) + find_child( "#scenario-search button.import" ).click() + + # connect to another ROAR scenario + find_child( "button.scenario-search" ).click() + check( ["Russian","Finnish"], False, True ) + find_child( "#scenario-info-dialog .disconnect-roar" ).click() + check( None, True, False ) + find_child( "#scenario-info-dialog .connect-roar" ).click() + dlg = wait_for_elem( 2, ".ui-dialog.select-roar-scenario" ) + find_child( ".select2-search__field", dlg ).send_keys( "another" ) + find_child( ".select2-search__field", dlg ).send_keys( Keys.RETURN ) + check( ["British","French"], False, True ) + find_child( ".ui-dialog.scenario-info button.ok" ).click() + + # disconnect from the ROAR scenario + find_child( "button.scenario-search" ).click() + check( ["British","French"], False, True ) + find_child( "#scenario-info-dialog .disconnect-roar" ).click() + check( None, True, False ) + find_child( ".ui-dialog.scenario-info button.ok" ).click() + + # connect to a ROAR scenario + find_child( "button.scenario-search" ).click() + check( None, True, False ) + find_child( "#scenario-info-dialog .connect-roar" ).click() + dlg = wait_for_elem( 2, ".ui-dialog.select-roar-scenario" ) + elem = find_child( ".select2-search__field", dlg ) + elem.send_keys( "withdrawal" ) + elem.send_keys( Keys.RETURN ) + check( ["Russian","Finnish"], False, True ) + find_child( ".ui-dialog.scenario-info button.ok" ).click() + +# --------------------------------------------------------------------- + +def test_scenario_linking( webapp, webdriver ): + """Test linking scenarios with the ASL Scenario Archive.""" + + # initialize + init_webapp( webapp, webdriver, scenario_persistence=1 ) + + def get_asa_id(): + """Get the ASL Scenario Archive scenario ID.""" + return find_child( "input[name='ASA_ID']" ).get_attribute( "value" ) + + def check( asa_id ): + """Check the current state of the scenario.""" + + # check that the ASL Scenario Archive scenario ID has been set + wait_for( 2, lambda: get_asa_id() == asa_id ) + + # check that the ASL Scenario Archive scenario ID is saved correctly + saved_scenario = save_scenario() + assert saved_scenario[ "ASA_ID" ] == asa_id + + # reset the scenario + new_scenario() + assert get_asa_id() == "" + + # check that the ASL Scenario Archive scenario ID is loaded correctly + load_scenario( saved_scenario ) + assert get_asa_id() == asa_id + + # import the "full" scenario + _do_scenario_search( "full", [1], webdriver ) + find_child( "#scenario-search button.import" ).click() + check( "1" ) + + # import the "empty" scenario (on top of the current scenario) + _do_scenario_search( "Untitled", ["no-content"], webdriver ) + find_child( "#scenario-search button.import" ).click() + find_child( "#scenario-search button.confirm-import" ).click() + check( "no-content" ) + + # import the "Fighting Withdrawal" scenario (on top of the current scenario) + _do_scenario_search( "Fighting Withdrawal", [2], webdriver ) + find_child( "#scenario-search button.import" ).click() + find_child( "#scenario-search button.confirm-import" ).click() + check( "2" ) + + # unlink the scenario + _unlink_scenario() + check( "" ) + +# --------------------------------------------------------------------- + +def _do_scenario_search( query, expected, webdriver ): + """Do a scenario search.""" + + # find the dialog + dlg = find_child( "#scenario-search" ) + if not dlg.is_displayed(): + select_tab( "scenario" ) + btn = find_child( "button.scenario-search" ) + ActionChains( webdriver ).key_down( Keys.SHIFT ).click( btn ).perform() + dlg = wait_for_elem( 2, "#scenario-search" ) + ActionChains( webdriver ).key_up( Keys.SHIFT ).perform() + + # do the search and check the results + elem = find_child( "input.select2-search__field", dlg ) + elem.clear() + elem.send_keys( query ) + results = _unload_search_results() + assert [ r[0] for r in results ] == [ str(e) for e in expected ] + + return dlg + +def _unload_search_results(): + """Unload the current search results.""" + results = [] + for sr in find_children( "#scenario-search .select2-results .search-result" ): + results.append( ( sr.get_attribute("data-id"), sr ) ) + return results + +def _unload_scenario_card(): #pylint: disable=too-many-branches,too-many-locals + """Unload the scenario card.""" + + if find_child( "#scenario-search" ).is_displayed(): + card = find_child( "#scenario-search .scenario-card" ) + elif find_child( "#scenario-info-dialog" ).is_displayed(): + card = find_child( "#scenario-info-dialog .scenario-card" ) + else: + assert False + results = {} + + # unload the basic text content + def unload_text_field( key, sel, trim_prefix=None, trim_postfix=None, trim_parens=False ): + """Unload a text field from the scenario card.""" + elem = find_child( sel, card ) + if not elem: + return + val = elem.text.strip() + if val: + if trim_parens: + assert val.startswith( "(" ) and val.endswith( ")" ) + val = val[1:-1] + if trim_prefix: + assert val.startswith( trim_prefix ) + val = val[ len(trim_prefix) : ] + if trim_postfix: + assert val.endswith( trim_postfix ) + val = val[ : -len(trim_postfix) ] + results[ key ] = val.strip() + def unload_attr( key, sel, attr ): + """Unload a element's attribute from the scenario card.""" + elem = find_child( sel, card ) + if not elem: + return + results[ key ] = elem.get_attribute( attr ) + unload_text_field( "scenario_name", ".scenario-name" ) + unload_attr( "scenario_url", ".scenario-name a", "href" ) + unload_text_field( "scenario_id", ".scenario-id", trim_parens=True ) + unload_text_field( "scenario_location", ".scenario-location" ) + unload_text_field( "scenario_date", ".scenario-date", trim_parens=True ) + unload_text_field( "theater", ".info .theater" ) + unload_text_field( "turn_count", ".info .turn-count", trim_postfix="turns" ) + unload_text_field( "playing_time", ".info .playing-time" ) + unload_text_field( "designer", ".designer", trim_prefix="Designer:" ) + unload_text_field( "publication", ".publication" ) + unload_attr( "publication_url", ".publication a", "href" ) + unload_text_field( "publication_date", ".publication-date", trim_parens=True ) + unload_text_field( "publisher", ".publisher", trim_parens=True ) + unload_attr( "publisher_url", ".publisher a", "href" ) + unload_text_field( "prev_publication", ".prev-publication", trim_prefix="Previously:" ) + unload_text_field( "revised_publication", ".revised-publication", trim_prefix="Revised:" ) + unload_text_field( "map_url", ".map" ) # nb: we don't show a real map in test mode + unload_text_field( "overview", ".overview" ) + unload_text_field( "defender_name", ".defender .name" ) + unload_text_field( "defender_desc", ".defender .desc" ) + unload_text_field( "attacker_name", ".attacker .name" ) + unload_text_field( "attacker_desc", ".attacker .desc" ) + unload_text_field( "boards", ".boards", trim_prefix="Boards:" ) + unload_text_field( "overlays", ".overlays", trim_prefix="Overlays:" ) + unload_text_field( "extra_rules", ".extra-rules", trim_prefix="Rules:" ) + + # unload the balance graphs + balances = _unload_balance_graphs( card ) + if balances: + results[ "balances" ] = balances + + # FUDGE! We just show the lat/long in test mode, not a real map, so we have to remove it + # from the overview content. + if "overview" in results and "map_url" in results: + results["overview"] = results["overview"].replace( results["map_url"], "" ).strip() + + # unload the icons + icons = set( + c.get_attribute( "src" ) + for c in find_children( ".info .icons img", card ) + ) + if icons: + results[ "icons" ] = sorted( os.path.split(i)[1] for i in icons ) + + # unload the OBA info + oba = find_child( ".player-info .oba", card ) + if oba and oba.is_displayed(): + oba_info = [] + for player in ["defender","attacker"]: + row = find_child( ".{}".format( player ), oba ) + if not row: + oba_info.append( None ) + continue + oba_info.append( [ + find_child( ".name", row ).text, + find_child( ".black", row ).text, + find_child( ".red", row ).text + ] ) + comments = find_child( ".{} .comments".format(player), oba ).text + if comments: + oba_info[-1].extend( comments.split( "\n" ) ) + elem = find_child( ".date-warning", oba ) + if elem.is_displayed(): + oba_info.append( elem.text ) + results[ "oba" ] = oba_info + + # unload any map preview images + elems = find_children( ".map-preview", card ) + if elems: + urls = [ e.get_attribute("href") for e in elems ] + results[ "map_previews" ] = [ os.path.basename(u) for u in urls ] + + # unload any errata + def get_source( val ): #pylint: disable=missing-docstring + assert val.startswith( "[" ) and val.endswith( "]" ) + return val[1:-1] + elems1 = find_children( ".errata .text", card ) + elems2 = find_children( ".errata .source", card ) + assert len(elems1) == len(elems2) + if len(elems1) > 0: + results[ "errata" ] = [ + [ e1.text, get_source(e2.text) ] + for e1,e2 in zip(elems1,elems2) + ] + + return results + +def _unload_balance_graphs( parent ): + """Unload balance graph(s).""" + + def get_player_no( elem ): + """Figure out what player an element belongs to.""" + # FUDGE! Selenium doesn't seem to let us select elements using things like ".wins.player1", + # so we have to iterate over all ".wins" elements, and figure out which player each one belongs to :-/ + classes = elem.get_attribute( "class" ) + if "player1" in classes: + return 0 + elif "player2" in classes: + return 1 + else: + assert False + return -1 + + # unload the balance graphs + balances = {} + balance_graphs = find_children( ".balance-graph", parent ) or [] + for bgraph in balance_graphs: + if not bgraph.is_displayed(): + continue + balance = [ {}, {} ] + for elem in find_children( ".player", bgraph ): + balance[ get_player_no(elem) ][ "name" ] = elem.text + for elem in find_children( ".wins", bgraph ): + wins = elem.text + assert wins.startswith( "(" ) and wins.endswith( ")" ) + balance[ get_player_no(elem) ][ "wins" ] = int( wins[1:-1] ) + for elem in find_children( ".progressbar", bgraph ): + percentage = int( elem.get_attribute( "aria-valuenow" ) ) + player_no = get_player_no( elem ) + if player_no == 0: + percentage = 100 - percentage + balance[ player_no ][ "percentage" ] = percentage + classes = [ c for c in get_css_classes(bgraph) if c != "balance-graph" ] + assert len(classes) == 1 + balances[ classes[0] ] = balance + + return balances + +def _check_warnings( expected, expected2 ): + """Check any import warnings being shown.""" + def do_check_warnings(): #pylint: + """Get import warnings.""" + warnings = [ + c.get_attribute( "name" ) + for c in find_children( "#scenario-search .warnings input[type='checkbox']" ) + ] + warnings2 = [ + c.text + for c in find_children( "#scenario-search .warnings .warning2" ) + ] + return warnings == expected and warnings2 == expected2 + wait_for( 2, do_check_warnings ) + +def _get_scenario_info(): + """Open the scenario info and unload the information.""" + btn = find_child( "button.scenario-search" ) + assert find_child( "img", btn ).get_attribute( "src" ).endswith( "/info.gif" ) + btn.click() + wait_for_elem( 2, "#scenario-info-dialog" ) + card = _unload_scenario_card() + btn = find_children( ".ui-dialog .ui-dialog-buttonpane button" )[0] + assert btn.text == "OK" + btn.click() + return card + +def _unlink_scenario(): + """Unlink the scenario from the ASL Scenario Archive.""" + find_child( "button.scenario-search" ).click() + wait_for_elem( 2, "#scenario-info-dialog" ) + btn = find_children( ".ui-dialog .ui-dialog-buttonpane button" )[1] + assert btn.text == "Unlink" + btn.click() diff --git a/vasl_templates/webapp/tests/test_snippets.py b/vasl_templates/webapp/tests/test_snippets.py index 1d68026..c1e31e4 100644 --- a/vasl_templates/webapp/tests/test_snippets.py +++ b/vasl_templates/webapp/tests/test_snippets.py @@ -202,11 +202,14 @@ def test_players_snippets( webapp, webdriver ): "PLAYER_1": "french", "PLAYER_1_ELR": "1", "PLAYER_1_SAN": "2", + "PLAYER_1_DESCRIPTION": "Froggy Army", "PLAYER_2": "british", "PLAYER_2_ELR": "3", "PLAYER_2_SAN": "4", + "PLAYER_2_DESCRIPTION": "Barmy Army", }, - "player1=[french:French] ; ELR=[1] ; SAN=[2] | player2=[british:British] ; ELR=[3] ; SAN=[4]", + "player1=[french:French] ; ELR=[1] ; SAN=[2] ; description=[Froggy Army]" \ + " | player2=[british:British] ; ELR=[3] ; SAN=[4] ; description=[Barmy Army]", None ) @@ -214,7 +217,8 @@ def test_players_snippets( webapp, webdriver ): _test_snippet( btn, { "PLAYER_1": "british", }, - "player1=[british:British] ; ELR=[1] ; SAN=[2] | player2=[british:British] ; ELR=[3] ; SAN=[4]", + "player1=[british:British] ; ELR=[1] ; SAN=[2] ; description=[Froggy Army]" \ + " | player2=[british:British] ; ELR=[3] ; SAN=[4] ; description=[Barmy Army]", [ "Both players have the same nationality!" ], ) diff --git a/vasl_templates/webapp/tests/test_template_packs.py b/vasl_templates/webapp/tests/test_template_packs.py index 6c4e245..e1202ee 100644 --- a/vasl_templates/webapp/tests/test_template_packs.py +++ b/vasl_templates/webapp/tests/test_template_packs.py @@ -14,7 +14,7 @@ from vasl_templates.webapp.tests.utils import \ select_tab, select_menu_option, set_player, \ wait_for_clipboard, get_stored_msg, set_stored_msg, set_stored_msg_marker,\ add_simple_note, for_each_template, find_child, find_children, wait_for, \ - get_droplist_vals_index, init_webapp, get_css_classes + get_player_nat, get_droplist_vals_index, init_webapp, get_css_classes # --------------------------------------------------------------------- @@ -112,7 +112,7 @@ def test_nationality_data( webapp, webdriver ): assert tab_ob1.text.strip() == "British OB" # FUDGE! player1_sel.first_selected_option.text doesn't contain the right value # if we're using jQuery selectmenu's :-/ - assert player1_sel.first_selected_option.get_attribute( "value" ) == "british" + assert get_player_nat( 1 ) == "british" droplist_vals = get_droplist_vals_index( player1_sel ) assert droplist_vals["british"] == "British" @@ -123,7 +123,7 @@ def test_nationality_data( webapp, webdriver ): # check that the UI was updated correctly assert tab_ob1.text.strip() == "Poms! OB" - assert player1_sel.first_selected_option.get_attribute( "value" ) == "british" + assert get_player_nat( 1 ) == "british" droplist_vals2 = get_droplist_vals_index( player1_sel ) assert droplist_vals2["british"] == "Poms!" diff --git a/vasl_templates/webapp/tests/test_utils.py b/vasl_templates/webapp/tests/test_utils.py new file mode 100644 index 0000000..ce9db70 --- /dev/null +++ b/vasl_templates/webapp/tests/test_utils.py @@ -0,0 +1,37 @@ +"""Test utility functions.""" + +from vasl_templates.webapp.utils import friendly_fractions + +# --------------------------------------------------------------------- + +def test_friendly_fractions(): + """Test generating friendly fractions.""" + + def do_test( val, expected, singular=False ): #pylint: disable=missing-docstring + # test without a postfix + assert friendly_fractions( val ) == expected + # test the singular/plural postfixes + expected = expected+" foo" if singular else expected+" foos" + assert friendly_fractions( val, "foo", "foos" ) == expected + + # do the test + do_test( 0, "0" ) + do_test( 0.124, "0" ) + do_test( 0.125, "¼", singular=True ) + do_test( 0.374, "¼", singular=True ) + do_test( 0.375, "½", singular=True ) + do_test( 0.624, "½", singular=True ) + do_test( 0.625, "¾", singular=True ) + do_test( 0.874, "¾", singular=True ) + do_test( 0.875, "1", singular=True ) + do_test( 1.124, "1", singular=True ) + do_test( 1.125, "1¼" ) + do_test( 1.374, "1¼" ) + do_test( 1.375, "1½" ) + do_test( 1.624, "1½" ) + do_test( 1.625, "1¾" ) + do_test( 1.874, "1¾" ) + do_test( 1.875, "2" ) + do_test( 2.125, "2¼" ) + do_test( 2.5, "2½" ) + do_test( 2.75, "2¾" ) diff --git a/vasl_templates/webapp/tests/test_vassal.py b/vasl_templates/webapp/tests/test_vassal.py index 2c13275..63b1aad 100644 --- a/vasl_templates/webapp/tests/test_vassal.py +++ b/vasl_templates/webapp/tests/test_vassal.py @@ -54,12 +54,15 @@ def test_full_update( webapp, webdriver ): "SCENARIO_THEATER": "PTO", "SCENARIO_DATE": "12/31/1945", "SCENARIO_WIDTH": "101", - "ROAR_ID": "", + "ASA_ID": "", "ROAR_ID": "", # NOTE: We used to change both nationalities here, but since we started tagging labels # with their owning player, the old labels would be left in-place, so we have to test # using the same nationalities. "PLAYER_1": "american", "PLAYER_1_ELR": "5", "PLAYER_1_SAN": "4", + "PLAYER_1_DESCRIPTION": "The Americans", "PLAYER_2": "belgian", "PLAYER_2_ELR": "3", "PLAYER_2_SAN": "2", + "PLAYER_2_DESCRIPTION": "The Belgians", + "PLAYERS_WIDTH": "42", "VICTORY_CONDITIONS": "Just do it!", "VICTORY_CONDITIONS_WIDTH": "102", "SCENARIO_NOTES": [ { "caption": "Modified scenario note #1", "width": "" }, diff --git a/vasl_templates/webapp/tests/utils.py b/vasl_templates/webapp/tests/utils.py index 0000439..ae99f33 100644 --- a/vasl_templates/webapp/tests/utils.py +++ b/vasl_templates/webapp/tests/utils.py @@ -74,7 +74,9 @@ def init_webapp( webapp, webdriver, **options ): .set_vassal_engine( vengine=None ) \ .set_vo_notes_dir( dtype=None ) \ .set_user_files_dir( dtype=None ) \ - .set_roar_scenario_index( fname="roar-scenario-index.json" ) + .set_roar_scenario_index( fname="roar-scenario-index.json" ) \ + .set_scenario_index( fname="asl-scenario-archive.json" ) \ + .set_app_config( key="MAP_URL", val="MAP:[{LAT},{LONG}]" ) if "reset" in options: options.pop( "reset" )( control_tests ) @@ -328,6 +330,21 @@ def set_player( player_no, nat ): select_droplist_val( sel, nat ) return sel +def get_player_nat( player_no ): + """Get a player's nationality.""" + sel = Select( find_child( "select[name='PLAYER_{}']".format( player_no ) ) ) + return sel.first_selected_option.get_attribute( "value" ) + +def set_theater( theater ): + """Set the scenario theater.""" + sel = Select( find_child( "select[name='SCENARIO_THEATER']" ) ) + select_droplist_val( sel, theater ) + +def get_theater(): + """Get the scenario theater.""" + sel = Select( find_child( "select[name='SCENARIO_THEATER']" ) ) + return sel.first_selected_option.get_attribute( "value" ) + # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - _nationalities = None @@ -566,8 +583,9 @@ def wait_for( timeout, func ): timeout *= 2 start_time = time.time() while True: - if func(): - break + rc = func() + if rc: + return rc assert time.time() - start_time < timeout time.sleep( 0.1 ) diff --git a/vasl_templates/webapp/utils.py b/vasl_templates/webapp/utils.py index 10fb6c0..59578e1 100644 --- a/vasl_templates/webapp/utils.py +++ b/vasl_templates/webapp/utils.py @@ -5,6 +5,7 @@ import shutil import io import tempfile import pathlib +import math from collections import defaultdict from flask import request, Response, send_file @@ -176,6 +177,64 @@ def is_empty_file( fname ): """Check if a file is empty.""" return os.stat( fname ).st_size == 0 +def parse_int( val, default=None ): + """Parse an integer.""" + try: + return int( val ) + except (ValueError, TypeError): + return default + +# --------------------------------------------------------------------- + +def friendly_fractions( val, postfix=None, postfix2=None ): + """Convert decimal values to more friendly fractions.""" + if val is None: + return None + frac, val = math.modf( float( val ) ) + if frac >= 0.875: + val = str( int(val) + 1 ) + else: + val = str( int( val ) ) + if frac >= 0.625: + val = val + "¾" + elif frac >= 0.375: + val = val + "½" + elif frac >= 0.125: + val = val + "¼" + if postfix: + if val == "0": + return "0 " + postfix2 + elif val.startswith( "0&" ): + return val[1:] + " " + postfix + elif val == "1": + return "1 " + postfix + val = "{} {}".format( val, postfix2 ) + return val[1:] if val.startswith( "0&" ) else val + +# --------------------------------------------------------------------- + +_MONTH_NAMES = [ # nb: we assume English :-/ + "January", "February", "March", "April", "May", "June", + "July", "August", "September", "October", "November", "December" +] + +_DAY_OF_MONTH_POSTFIXES = { # nb: we assume English :-/ + 0: "th", + 1: "st", 2: "nd", 3: "rd", 4: "th", 5: "th", 6: "th", 7: "th", 8: "th", 9: "th", 10: "th", + 11: "th", 12: "th", 13: "th" +} + +def get_month_name( month ): + """Return a month name.""" + return _MONTH_NAMES[ month-1 ] + +def make_formatted_day_of_month( dom ): + """Generate a formatted day of the month.""" + if dom in _DAY_OF_MONTH_POSTFIXES: + return str(dom) + _DAY_OF_MONTH_POSTFIXES[ dom ] + else: + return str(dom) + _DAY_OF_MONTH_POSTFIXES[ dom % 10 ] + # --------------------------------------------------------------------- class SimpleError( Exception ):