Added integration with the ASL Scenario Archive.

master
Pacman Ghost 4 years ago
parent 48c69927f6
commit 3805e131bb
  1. 3
      .pylintrc
  2. 12
      vasl_templates/main_window.py
  3. 62
      vasl_templates/tools/check_connect_roar.py
  4. 26
      vasl_templates/webapp/__init__.py
  5. 2
      vasl_templates/webapp/config/logging.yaml.example
  6. 46
      vasl_templates/webapp/data/asl-scenario-archive.json
  7. 55
      vasl_templates/webapp/data/default-template-pack/players.j2
  8. 155
      vasl_templates/webapp/downloads.py
  9. 18
      vasl_templates/webapp/main.py
  10. 134
      vasl_templates/webapp/roar.py
  11. 405
      vasl_templates/webapp/scenarios.py
  12. 87
      vasl_templates/webapp/static/css/scenario-card.css
  13. 71
      vasl_templates/webapp/static/css/scenario-search-dialog.css
  14. 5
      vasl_templates/webapp/static/css/select-roar-scenario-dialog.css
  15. 41
      vasl_templates/webapp/static/css/tabs-scenario.css
  16. 2
      vasl_templates/webapp/static/css/tabs.css
  17. 48
      vasl_templates/webapp/static/imageZoom/jquery.imageZoom.css
  18. 1
      vasl_templates/webapp/static/imageZoom/jquery.imageZoom.min.js
  19. BIN
      vasl_templates/webapp/static/imageZoom/jquery.imageZoom.png
  20. BIN
      vasl_templates/webapp/static/images/asl-scenario-archive.png
  21. BIN
      vasl_templates/webapp/static/images/connect-roar.png
  22. 0
      vasl_templates/webapp/static/images/gripper-horz.png
  23. BIN
      vasl_templates/webapp/static/images/gripper-vert.png
  24. BIN
      vasl_templates/webapp/static/images/icons/aslsk.png
  25. BIN
      vasl_templates/webapp/static/images/icons/deluxe.png
  26. BIN
      vasl_templates/webapp/static/images/icons/night.png
  27. BIN
      vasl_templates/webapp/static/images/icons/oba.png
  28. BIN
      vasl_templates/webapp/static/images/map-preview.png
  29. 4
      vasl_templates/webapp/static/lfa.js
  30. 92
      vasl_templates/webapp/static/main.js
  31. 390
      vasl_templates/webapp/static/roar.js
  32. 1201
      vasl_templates/webapp/static/scenarios.js
  33. 27
      vasl_templates/webapp/static/snippets.js
  34. 27
      vasl_templates/webapp/static/timer.js
  35. 56
      vasl_templates/webapp/static/utils.js
  36. 120
      vasl_templates/webapp/templates/check-roar.html
  37. 11
      vasl_templates/webapp/templates/index.html
  38. 153
      vasl_templates/webapp/templates/scenario-card.html
  39. 3
      vasl_templates/webapp/templates/scenario-info-dialog.html
  40. 132
      vasl_templates/webapp/templates/scenario-nat-report.html
  41. 34
      vasl_templates/webapp/templates/scenario-search-dialog.html
  42. 39
      vasl_templates/webapp/templates/tabs-scenario.html
  43. 137
      vasl_templates/webapp/tests/fixtures/asl-scenario-archive.json
  44. 4
      vasl_templates/webapp/tests/fixtures/data/default-template-pack/national-capabilities.json
  45. 9
      vasl_templates/webapp/tests/fixtures/data/default-template-pack/nationalities.json
  46. 4
      vasl_templates/webapp/tests/fixtures/data/default-template-pack/players.j2
  47. 51
      vasl_templates/webapp/tests/fixtures/roar-scenario-index.json
  48. 17
      vasl_templates/webapp/tests/remote.py
  49. 1
      vasl_templates/webapp/tests/test_default_scenario.py
  50. 7
      vasl_templates/webapp/tests/test_ob.py
  51. 11
      vasl_templates/webapp/tests/test_players.py
  52. 148
      vasl_templates/webapp/tests/test_roar.py
  53. 12
      vasl_templates/webapp/tests/test_scenario_persistence.py
  54. 756
      vasl_templates/webapp/tests/test_scenario_search.py
  55. 8
      vasl_templates/webapp/tests/test_snippets.py
  56. 6
      vasl_templates/webapp/tests/test_template_packs.py
  57. 37
      vasl_templates/webapp/tests/test_utils.py
  58. 5
      vasl_templates/webapp/tests/test_vassal.py
  59. 24
      vasl_templates/webapp/tests/utils.py
  60. 59
      vasl_templates/webapp/utils.py

@ -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

@ -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 )

@ -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) ) )

@ -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 )

@ -42,6 +42,6 @@ loggers:
webdriver:
level: "WARNING"
handlers: [ "file" ]
roar:
downloads:
level: "WARNING"
handlers: [ "file" ]

@ -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"
}
}

@ -2,26 +2,51 @@
<head>
<meta charset="utf-8">
<style> {{CSS:common}} </style>
<style>
{{CSS:common}}
table {
border: 1px solid #c0c0c0 ; background: #f0f0f0 ;
{%if PLAYER_1_DESCRIPTION or PLAYER_2_DESCRIPTION%}
{# NOTE: We had to do some heavy-duty hacking to get the layout to work in VASSAL :-/ The idea is that there is a padding column on the far right of the table that expands to fill up the available width, thus pushing the data columns (flags, player, ELR/SAN) to the left. We can then have a cell that spans all these columns for the player descriptions. All this is only necessary when there are player descriptions, which is why the PLAYER_WIDTH variable is only used if there are player descriptions. #}
width: {{PLAYERS_WIDTH or "200px"}} ;
{%endif%}
}
td.player { width: 1px ; white-space: nowrap ; font-weight: bold ; }
td.val { width: 1px ; white-space: nowrap ; }
td.description { font-size: 90% ; font-style: italic ; color: #808080 ; }
{%if PLAYER_1_DESCRIPTION or PLAYER_2_DESCRIPTION%}
td.padding { width: {{PLAYERS_WIDTH or "200px"}} ; }
{%endif%}
</style>
</head>
<table style="
border: 1px solid #c0c0c0 ;
background: #f0f0f0 ;
">
<table>
<td style="font-weight:bold;">
<tr>
{# Some versions of Java require <img> tags to have the width and height specified!?! #}
{%if PLAYER_FLAG_1%}<img src="{{PLAYER_FLAG_1}}" {{PLAYER_FLAG_SIZE}}>&nbsp;{%endif%}{{PLAYER_1_NAME}}: <br>
{%if PLAYER_FLAG_2%}<img src="{{PLAYER_FLAG_2}}" {{PLAYER_FLAG_SIZE}}>&nbsp;{%endif%}{{PLAYER_2_NAME}}:
<td class="flag"> {%if PLAYER_FLAG_1%} <img src="{{PLAYER_FLAG_1}}" {{PLAYER_FLAG_SIZE}}> {%endif%}
<td class="player"> {{PLAYER_1_NAME}}:
<td class="val"> &nbsp; ELR: {{PLAYER_1_ELR}}
<td class="val"> &nbsp; SAN: {{PLAYER_1_SAN}}
{%if PLAYER_1_DESCRIPTION%} <td class="padding"> {%endif%}
{%if PLAYER_1_DESCRIPTION%}
<tr>
<td></td>
<td class="description" colspan=4 valign=top> {{PLAYER_1_DESCRIPTION}}
{%endif%}
<td style="padding-left:2px;">
ELR: {{PLAYER_1_ELR}} <br>
ELR: {{PLAYER_2_ELR}}
<td style="padding-left:5px;">
SAN: {{PLAYER_1_SAN}} <br>
SAN: {{PLAYER_2_SAN}}
<tr>
{# Some versions of Java require <img> tags to have the width and height specified!?! #}
<td class="flag"> {%if PLAYER_FLAG_2%} <img src="{{PLAYER_FLAG_2}}" {{PLAYER_FLAG_SIZE}}> {%endif%}
<td class="player"> {{PLAYER_2_NAME}}:
<td class="val"> &nbsp; ELR: {{PLAYER_2_ELR}}
<td class="val"> &nbsp; SAN: {{PLAYER_2_SAN}}
{%if PLAYER_2_DESCRIPTION%} <td class="padding"> {%endif%}
{%if PLAYER_2_DESCRIPTION%}
<tr>
<td></td>
<td class="description" colspan=4 valign=top> {{PLAYER_2_DESCRIPTION}}
{%endif%}
</table>

@ -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 )

@ -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 )
# ---------------------------------------------------------------------

@ -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" )

@ -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/<scenario_id>" )
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/<scenario_id>" )
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)) ]

@ -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 ; }

@ -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 ; }

@ -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 ; }

@ -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 ; }

@ -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 ; }

@ -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;
}

@ -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('<div><img src="'+d+'" alt=""/></div>').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('<a href="#">Close</a>').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}})};

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 355 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

@ -146,7 +146,7 @@ window.show_lfa_dialog = function( resp )
gutterSize: 3,
onDrag: updateLayout,
} ) ;
var $gripper = $( "<img src='" + gImagesBaseUrl + "/gripper.png'>" ) ;
var $gripper = $( "<img src='" + gImagesBaseUrl + "/gripper-horz.png'>" ) ;
$( "#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 ; }
// --------------------------------------------------------------------

@ -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( "<img src='" + gImagesBaseUrl + "/search.png'>" )
.click( search_roar ) ;
$("#go-to-roar").button( {} ).click( go_to_roar_scenario ) ;
$("#disconnect-roar").button( {} )
.html( "<img src='" + gImagesBaseUrl + "/cross.png'>" )
.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 () {
"</div>" ) ;
}
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( $( "<option value='" + theater + "'>" + theater + "</option>" ) ) ;
} ) ;
// 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 = [ "<table>" ] ;
buf.push( "<tr>", "<th colspan='2' style='font-size:105%;text-align:center;padding:5px;background:#e0e0e0;'> 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( "<tr>" ) ;
var player_nat = $( "select[name='PLAYER_" + player_no + "']" ).val() ;
var display_name = get_nationality_display_name( player_nat ) ;
buf.push( "<td style='font-weight:bold;padding-right:0.5em;white-space:nowrap;'>", display_name+":" ) ;
set_nat_caps_params( player_nat, params ) ;
if ( ! params.NAT_CAPS )
params.NAT_CAPS = { OBA_BLACK: "-", OBA_RED: "-" } ;
buf.push( "<td>" ) ;
var colors = [ "BLACK", "RED" ] ;
for ( var i=0 ; i < colors.length ; ++i ) {
var val = params.NAT_CAPS[ "OBA_"+colors[i] ] || "-" ;
buf.push( "<span style='display:inline-block;width:2em;'>", val, "</span>" ) ;
}
if ( params.NAT_CAPS.OBA_COMMENTS ) {
for ( i=0 ; i < params.NAT_CAPS.OBA_COMMENTS.length ; ++i )
buf.push( "<tr>", "<td>", "<td style='font-size:90%;font-style:italic;color:#404040;'>", params.NAT_CAPS.OBA_COMMENTS[i] ) ;
}
}
buf.push( "</table>" ) ;
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 ;

@ -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:<div class='pre'>" + escapeHTML(errorMsg) + "</div>" ) ;
} ) ;
}
}
( 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:", "<ul>" ] ;
for ( var i=0 ; i < unknown_nats.length ; ++i )
buf.push( "<li>" + unknown_nats[i] ) ;
buf.push( "</ul>" ) ;
showWarningMsg( buf.join("") ) ;
function formatEntry( scenario ) {
// generate the HTML for a scenario
var buf = [ "<div class='scenario' data-roarid='", scenario.roar_id , "'>",
scenario.name
] ;
if ( scenario.scenario_id ) {
buf.push( " <span class='scenario-id'>[",
strReplaceAll( scenario.scenario_id, " ", "&nbsp;" ),
"]</span>"
) ;
}
buf.push(
" <span class='publication'>", scenario.publication, "</span>",
"</div>"
) ;
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( "&quot;", "&quot;" ) ;
name = remove_quotes( "\u2018", "\u2019" ) ;
name = remove_quotes( "\u201c", "\u201d" ) ;
name = removeQuotes( name, '"', '"' ) ;
name = removeQuotes( name, "'", "'" ) ;
name = removeQuotes( name, "&quot;", "&quot;" ) ;
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 <option> text is what gets searched (_format_entry() generates what gets shown),
for ( var i=0 ; i < roarIds.length ; ++i ) {
roarId = roarIds[ i ] ;
scenario = scenarios[ roarId ] ;
// NOTE: The <option> text is what gets searched (formatEntry() generates what gets shown),
// so we include the scenario ID here, so that it also becomes searchable.
buf.push( "<option value='" + roar_id + "'" ) ;
if ( roar_id === curr_roar_id ) {
// FIXME! How can we scroll this into view? Calling scrollIntoView(),
// even in the "open" handler, causes weird problems.
buf.push( " selected" ) ;
}
buf.push( ">" ) ;
buf.push( scenario.name + " " + scenario.scenario_id,
buf.push( "<option value='" + roarId + "'>",
scenario.name + " " + scenario.scenario_id,
"</option>"
) ;
}
$sel.html( buf.join("") ) ;
}
function _format_entry( roar_id ) {
// generate the HTML for a scenario
var scenario = gRoarScenarioIndex[ roar_id ] ;
var buf = [ "<div class='scenario' data-roarid='", roar_id , "'>",
scenario.name,
" <span class='scenario-id'>[", strReplaceAll(scenario.scenario_id," ","&nbsp;"), "]</span>",
" <span class='publication'>", scenario.publication, "</span>",
"</div>"
] ;
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 += "<div class='pre'>" + escapeHTML(resp.message) + "</div>" ;
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:<div class='pre'>" + escapeHTML(errorMsg) + "</div>" ) ;
} ) ;
// 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

File diff suppressed because it is too large Load Diff

@ -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 = [] ;

@ -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 ;
}
}

@ -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 )
{

@ -1,120 +0,0 @@
<!doctype html> <!-- NOTE: For testing porpoises only! -->
<html lang="en">
<head>
<meta charset="utf-8">
<style>
th, td { padding: 2px 5px ; text-align: left ; }
th { background: #eee ; }
</style>
</head>
<body>
<div id="results" style="display:none;"></div>
</body>
<script src="{{url_for('static',filename='jquery/jquery-3.3.1.min.js')}}"></script>
<script src="{{url_for('static',filename='roar.js')}}"></script>
<script>
gRoarScenarioIndex = null ;
gTemplatePack = null ;
</script>
<script>
$(document).ready( function () {
// initialize
var on_load_counter = 2 ;
function on_data_loaded() {
if ( --on_load_counter == 0 ) {
// everything's loaded - generate the report
check_roar() ;
}
}
// get the ROAR scenario index
$.getJSON( "{{url_for('get_roar_scenario_index')}}", function(data) {
gRoarScenarioIndex = data ;
on_data_loaded() ;
} ).fail( function( xhr, status, errorMsg ) {
alert( "Can't get the ROAR scenario index:\n\n" + errorMsg ) ;
} ) ;
// get the template pack
$.getJSON( "{{url_for('get_template_pack')}}", function(data) {
gTemplatePack = data ;
on_data_loaded() ;
} ).fail( function( xhr, status, errorMsg ) {
alert( "Can't get the template pack:\n\n" + errorMsg ) ;
} ) ;
} ) ;
function check_roar()
{
// initialize
var buf = [] ;
// generate the list of nationalities in ROAR
var roar_nats = {} ;
function on_nat( nat ) {
if ( nat in roar_nats )
++ roar_nats[nat] ;
else
roar_nats[nat] = 1 ;
}
for ( var roar_id in gRoarScenarioIndex ) {
if ( roar_id[0] == "_" )
continue ;
var scenario = gRoarScenarioIndex[ roar_id ] ;
on_nat( scenario.results[0][0] ) ;
on_nat( scenario.results[1][0] ) ;
}
// sort the results
var roar_nats_sorted = Object.keys( roar_nats ) ;
roar_nats_sorted.sort( function( lhs, rhs ) {
if ( roar_nats[lhs] < roar_nats[rhs] )
return +1 ;
else if ( roar_nats[lhs] > roar_nats[rhs] )
return -1 ;
else {
if ( lhs < rhs )
return -1 ;
else if ( lhs > rhs )
return +1 ;
}
return 0;
} ) ;
// output the results
buf.push( "<table>" ) ;
buf.push( "<tr>", "<th>ROAR nationality", "<th>Count", "<th><tt>vasl-templates</tt> nationality" ) ;
for ( var i=0 ; i < roar_nats_sorted.length ; ++i ) {
var nat = roar_nats_sorted[i] ;
buf.push( "<tr>", "<td>"+nat, "<td>"+roar_nats[nat] ) ;
nat = convert_roar_nat( nat ) ;
if ( nat )
buf.push( "<td>" + nat ) ;
}
buf.push( "</table>" ) ;
// check for spaces in scenario ID's
buf.push( "<h2>Scenario ID's with spaces</h2>" ) ;
buf.push( "<ul>" ) ;
for ( roar_id in gRoarScenarioIndex ) {
if ( roar_id[0] === "_" )
continue ;
scenario = gRoarScenarioIndex[ roar_id ] ;
if ( scenario.scenario_id.indexOf( " " ) !== -1 )
buf.push( "<li>" + roar_id + ": " + scenario.name + " [" + scenario.scenario_id + "]" ) ;
}
buf.push( "</ul>" ) ;
$("#results").html( buf.join("") ).show() ;
}
</script>
</html>

@ -10,6 +10,7 @@
<link rel="stylesheet" type="text/css" href="{{url_for('static',filename='jquery-ui/jquery-ui.min.css')}}" />
<link rel="stylesheet" type="text/css" href="{{url_for('static',filename='growl/jquery.growl.css')}}" />
<link rel="stylesheet" type="text/css" href="{{url_for('static',filename='popmenu/jquery.popmenu.css')}}" />
<link rel="stylesheet" type="text/css" href="{{url_for('static',filename='imageZoom/jquery.imageZoom.css')}}" />
<link rel="stylesheet" type="text/css" href="{{url_for('static',filename='spectrum/spectrum.css')}}" />
<link rel="stylesheet" type="text/css" href="{{url_for('static',filename='select2/select2.min.css')}}" />
<link rel="stylesheet" type="text/css" href="{{url_for('static',filename='chartjs/Chart.min.css')}}" />
@ -29,6 +30,8 @@
<link rel="stylesheet" type="text/css" href="{{url_for('static',filename='css/lfa.css')}}" />
<link rel="stylesheet" type="text/css" href="{{url_for('static',filename='css/lfa-upload.css')}}" />
<link rel="stylesheet" type="text/css" href="{{url_for('static',filename='css/snippets.css')}}" />
<link rel="stylesheet" type="text/css" href="{{url_for('static',filename='css/scenario-search-dialog.css')}}" />
<link rel="stylesheet" type="text/css" href="{{url_for('static',filename='css/scenario-card.css')}}" />
<link rel="stylesheet" type="text/css" href="{{url_for('static',filename='css/user-settings-dialog.css')}}" />
</head>
@ -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 @@
<script src="{{url_for('static',filename='jinja/jinja.js')}}"></script>
<script src="{{url_for('static',filename='growl/jquery.growl.js')}}"></script>
<script src="{{url_for('static',filename='popmenu/jquery.popmenu-1.0.0.min.js')}}"></script>
<script src="{{url_for('static',filename='imageZoom/jquery.imageZoom.min.js')}}"></script>
<script src="{{url_for('static',filename='hotkey/jquery.hotkey.js')}}"></script>
<script src="{{url_for('static',filename='spectrum/spectrum.js')}}"></script>
<script src="{{url_for('static',filename='download/download.min.js')}}"></script>
@ -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')}}" ;
<script src="{{url_for('static',filename='main.js')}}"></script>
<script src="{{url_for('static',filename='snippets.js')}}"></script>
<script src="{{url_for('static',filename='scenarios.js')}}"></script>
<script src="{{url_for('static',filename='nat_caps.js')}}"></script>
<script src="{{url_for('static',filename='extras.js')}}"></script>
<script src="{{url_for('static',filename='simple_notes.js')}}"></script>
@ -139,6 +149,7 @@ gHelpUrl = "{{url_for('show_help')}}" ;
<script src="{{url_for('static',filename='user_settings.js')}}"></script>
<script src="{{url_for('static',filename='jQueryHandlers.js')}}"></script>
<script src="{{url_for('static',filename='utils.js')}}"></script>
<script src="{{url_for('static',filename='timer.js')}}"></script>
{%include "testing.html"%}

@ -0,0 +1,153 @@
<div class="header"> <!-- begin header -->
<div>
<span class="scenario-name"> {%if SCENARIO_NAME%}
{%if SCENARIO_URL%}<a href="{{SCENARIO_URL}}">{%endif%}{{SCENARIO_NAME}}{%if SCENARIO_URL%}</a>{%endif%}
{%else%}
Untitled scenario
{%endif%} </span>
{%if SCENARIO_DISPLAY_ID%} <span class="scenario-id"> ({{SCENARIO_DISPLAY_ID}}) </span> {%endif%}
</div>
{%if SCENARIO_LOCATION%} <div>
<span class="scenario-location"> {{SCENARIO_LOCATION}} </span>
{%if SCENARIO_DATE%} <span class="scenario-date"> ({{SCENARIO_DATE}}) </span> {%endif%}
</div> {%endif%}
<!-- info box -->
{%if THEATER or TURN_COUNT or PLAYING_TIME or ICONS is defined%} <div class="info" style="display:none;">
{%if THEATER%} <div class="theater"> {{THEATER}} </div> {%endif%}
{%if TURN_COUNT%}<div class="turn-count"> {{TURN_COUNT|safe}} </div> {%endif%}
{%if PLAYING_TIME%} <div class="playing-time"> {{PLAYING_TIME|safe}} </div> {%endif%}
{%if ICONS is defined%} <div class="icons">
{%if ICONS.IS_DELUXE%} <img src="{{url_for('static',filename='images/icons/deluxe.png')}}" title="Deluxe ASL"> {%endif%}
{%if ICONS.IS_ASLSK%} <img src="{{url_for('static',filename='images/icons/aslsk.png')}}" title="Starter Kit"> {%endif%}
{%if ICONS.IS_NIGHT%} <img src="{{url_for('static',filename='images/icons/night.png')}}" title="Night scenario"> {%endif%}
{%if ICONS.DEFENDER_OBA or ICONS.ATTACKER_OBA%} <img src="{{url_for('static',filename='images/icons/oba.png')}}" class="oba" title="Has OBA"> {%endif%}
</div> {%endif%}
</div> {%endif%}
</div> <!-- end header -->
<div class="header2"> <!-- begin header2 -->
{%if DESIGNER%}
<div class="designer"> Designer: {{DESIGNER}} </div>
{%endif%}
{%if PUBLICATION%} <div>
<span class="publication">
{%if PUBLICATION_URL%}<a href="{{PUBLICATION_URL}}">{%endif%}{{PUBLICATION}}{%if PUBLICATION_URL%}</a>{%endif%}
</span>
{%if PUBLISHER%} <span class="publisher">
({%if PUBLISHER_URL%}<a href="{{PUBLISHER_URL}}">{%endif%}{{PUBLISHER}}{%if PUBLISHER_URL%}</a>{%endif%})
</span> {%endif%}
{%if PUBLICATION_DATE%} <span class="publication-date"> ({{PUBLICATION_DATE}}) </span> {%endif%}
</div> {%endif%}
{%if PREV_PUBLICATION%}
<div class="prev-publication"> Previously: {{PREV_PUBLICATION}} </div>
{%endif%}
{%if REVISED_PUBLICATION%}
<div class="revised-publication"> Revised: {{REVISED_PUBLICATION}} </div>
{%endif%}
</div> <!-- end header2 -->
<div class="content"> <!-- begin content -->
{%if MAP_URL%} <div class="map">
{%if MAP_URL.startswith("http") %}
<iframe class="map" src="{{MAP_URL}}"></iframe>
{%else%}
<div style="border:1px dotted #888;padding:0.5em;text-justify:none;"> {{MAP_URL}} </div>
{%endif%}
</div> {%endif%}
{%if OVERVIEW%} <div class="overview">
{%if OVERVIEW_BRIEF%}
<div class="full" style="display:none;"> {{OVERVIEW|safe}} </div>
<div class="brief"> {{OVERVIEW_BRIEF|safe}} </div>
<div class="more"> more... </div>
{%else%}
{{OVERVIEW|safe}}
{%endif%}
</div> {%endif%}
<div class="player-info"> <!-- begin player-info -->
{%if ICONS is defined and (ICONS.DEFENDER_OBA or ICONS.ATTACKER_OBA)%}
<table class="oba" style="display:none;">
<tr> <td class="oba-header" colspan="4"> Off-board Artillery
{%if ICONS.DEFENDER_OBA%}
<tr class="defender"> <td class="name"> {{DEFENDER_NAME or "Defender"}} <td class="count black"> ? <td class="count red"> ? <td class="spacer">
<tr class="defender"> <td class="comments" colspan="4">
{%endif%}
{%if ICONS.ATTACKER_OBA%}
<tr class="attacker"> <td class="name"> {{ATTACKER_NAME or "Attacker"}} <td class="count black"> ? <td class="count red"> ? <td class="spacer">
<tr class="attacker"> <td class="comments" colspan="4">
{%endif%}
<tr> <td colspan="4" class="date-warning" style="display:none;">
<img src="{{url_for('static',filename='images/warning.gif')}}">
Based on a scenario date of <span class="val"></span>.
</table>
{%endif%}
<table class="players">
{%if DEFENDER_NAME%}
<tr class="defender">
<td class="label"> Defender: <td class="flag"> <td class="name"> {{DEFENDER_NAME}}
{%if DEFENDER_DESC%}
<tr class="defender">
<td> <td class="desc" colspan="2"> {{DEFENDER_DESC}}
{%endif%}
{%endif%}
{%if ATTACKER_NAME%}
<tr class="attacker">
<td class="label"> Attacker: <td class="flag"> <td class="name"> {{ATTACKER_NAME}}
{%if ATTACKER_DESC%}
<tr class="attacker">
<td> <td class="desc" colspan="2"> {{ATTACKER_DESC}}
{%endif%}
{%endif%}
</table>
<div class="balance-graphs">
<div class='balance-graph roar'> </div>
<div class='balance-graph asa'> </div>
<div class="connect-roar" style="display:none;">
<img src="{{url_for('static',filename='images/connect-roar.png')}}">
Connect to ROAR
</div>
</div>
</div> <!-- end player-info -->
{%if BOARDS%} <div class="boards">
<b>Boards:</b> {{BOARDS}}
{%if MAP_IMAGES%} {%for map in MAP_IMAGES%}
<a href="{{map}}" class="map-preview"><img src="{{url_for('static',filename='images/map-preview.png')}}" title="Board preview"></a>
{%endfor%} {%endif%}
</div> {%endif%}
{%if OVERLAYS%} <div class="overlays"> <b>Overlays:</b> {{OVERLAYS}} </div> {%endif%}
{%if EXTRA_RULES%} <div class="extra-rules"> <b>Rules:</b> {{EXTRA_RULES}} </div> {%endif%}
{%if ERRATA%} <div class="errata">
{%if ERRATA|length == 1%}
<b>Errata:</b>
<span class="text">{{ERRATA[0].text}}</span>
{%if ERRATA[0].source%} <span class="source">[{{ERRATA[0].source}}]</span> {%endif%}
{%elif ERRATA|length >= 2%}
<b>Errata:</b> <ul>
{%for errata in ERRATA%} <li>
<span class="text">{{errata.text}}</span>
{%if errata.source%}<span class="source">[{{errata.source}}]</span> {%endif%}
{%endfor%}
</ul>
{%endif%}
</div> {%endif%}
</div> <!-- end content -->

@ -0,0 +1,3 @@
<div id="scenario-info-dialog" style="display:none;">
<div class="scenario-card"></div>
</div>

@ -0,0 +1,132 @@
<!doctype html> <!-- NOTE: For testing porpoises only! -->
<html lang="en">
<head>
<meta charset="utf-8">
<title> Scenario nationalities</title>
<style>
table { margin: 0 0 0 2em ; }
th { padding: 2px 5px ; text-align: left ; background: #eee ; }
td { padding: 2px 5px 2px 1em ; text-align: right ; }
</style>
</head>
<body>
<div id="results"></div>
</body>
<script src="{{url_for('static',filename='jquery/jquery-3.3.1.min.js')}}"></script>
<script src="{{url_for('static',filename='scenarios.js')}}"></script>
<script>
var gAppConfig, gTemplatePack, gScenarioIndex ;
$(document).ready( function () {
// get the information we need
var nResponsesPending = 3 ;
function onResponse() {
if ( -- nResponsesPending == 0 )
generateReport() ;
}
$.getJSON( "{{url_for('get_app_config')}}", function( resp ) {
gAppConfig = resp ;
onResponse() ;
} ).fail( function( xhr, status, errorMsg ) {
alert( "Can't get the app config:\n\n" + errorMsg ) ;
} ) ;
$.getJSON( "{{url_for('get_template_pack')}}", function( resp ) {
gTemplatePack = resp ;
onResponse() ;
} ).fail( function( xhr, status, errorMsg ) {
alert( "Can't get the template pack:\n\n" + errorMsg ) ;
} ) ;
$.getJSON( "{{url_for('get_scenario_index')}}", function( resp ) {
gScenarioIndex = resp ;
onResponse() ;
} ).fail( function( xhr, status, errorMsg ) {
alert( "Can't get the scenario index:\n\n" + errorMsg ) ;
} ) ;
} ) ;
</script>
<script>
function generateReport() {
// initialize
var exactMatches={}, mappedMatches={}, unknownNats={} ;
function updateCount( counts, key ) {
if ( counts[key] === undefined )
counts[ key ] = 1 ;
else
++ counts[ key ] ;
}
function checkPlayerNat( playerName ) {
if ( ! playerName )
return ;
var effectiveNat = getEffectivePlayerNat( playerName ) ;
if ( ! effectiveNat ) {
// record an unknown player nationality
updateCount( unknownNats, playerName ) ;
}
else if ( effectiveNat[1] == "exactMatch" || effectiveNat[1] == "partialMatch" ) {
// record an exact match
updateCount( exactMatches, effectiveNat[0] ) ;
} else if ( effectiveNat[1] == "exactMapping" || effectiveNat[1] == "partialMapping" ) {
// record a mapping
updateCount( mappedMatches, effectiveNat[2] ) ;
} else {
alert( "Unknown effective nat type: " + effectiveNat[1] ) ;
}
}
// process each scenario
var $results = $( "#results" ) ;
var maxScenarios = gScenarioIndex.length ;
function processScenario( scenarioNo ) {
if ( scenarioNo >= maxScenarios ) {
// we've processed all the scenarios - output the results
outputResults() ;
return ;
}
// get the next scenario
$results.text( "Generating the report: " + (1+scenarioNo) + "/" + gScenarioIndex.length ) ;
var url = "{{url_for('get_scenario',scenario_id='ID')}}".replace( "ID", gScenarioIndex[scenarioNo].scenario_id ) ;
$.getJSON( url, function( resp ) {
// check the 2 players
checkPlayerNat( resp.defender_name ) ;
checkPlayerNat( resp.attacker_name ) ;
// process the next scenario
processScenario( scenarioNo + 1 ) ;
} ).fail( function( xhr, status, errorMsg ) {
alert( "Can't get the scenario:\n\n" + errorMsg ) ;
} ) ;
}
processScenario( 0 ) ;
function outputResults() {
// output the results
$results.text( "Processed " + gScenarioIndex.length + " scenarios." ) ;
makeTable( "Exact/partial matches", exactMatches ) ;
makeTable( "Mapped matches", mappedMatches ) ;
makeTable( "Unknown", unknownNats ) ;
}
function makeTable( caption, vals, cmp ) {
var rows = [] ;
for ( key in vals )
rows.push( [ key, vals[key] ] ) ;
rows.sort( function( lhs, rhs ) { return lhs[1] < rhs[1] ; } ) ;
var buf = [ "<h2>", caption, "</h2>", "<table>" ] ;
rows.forEach( function( row ) {
buf.push( "<tr>", "<th>", row[0], "<td>", row[1] ) ;
} ) ;
buf.push( "</table>" ) ;
$results.append( $( buf.join("") ) ) ;
}
}
</script>
</html>

@ -0,0 +1,34 @@
<div id="scenario-search" style="display:none;">
<div class="split left"> <!-- left panel -->
<div class="scenarios"> <select></select> </div>
<div class="footer">
<a href="https://aslscenarioarchive.com">
<img class="logo" src="{{url_for('static',filename='images/asl-scenario-archive.png')}}">
<div class="caption"> Powered by the ASL Scenario Archive </div>
</a>
</div>
</div> <!-- end left panel -->
<div class="split right"> <!-- right panel -->
<div class="scenario-card"> </div>
<div class="import-control">
<div class="warnings" style="display:none;"></div>
<div class="buttons">
<button class="import" title="Import this scenario and link it to the ASL Scenario Archive.">
<img class="logo" src="{{url_for('static',filename='images/sortable-add.png')}}">
Import
</button>
<button class="cancel-import"> Cancel </button>
<button class="confirm-import ok"> OK </button>
</div>
</div>
</div> <!-- end right panel -->
</div>

@ -8,18 +8,12 @@
<label for="SCENARIO_NAME">Name:</label>
<input name="SCENARIO_NAME" type="text" class="param">
<input name="SCENARIO_ID" type="text" class="param" title="Scenario ID">
<button id="search-roar" title="Search ROAR"></button>
<button class="scenario-search" title="Search for scenarios"></button>
</div>
<div class="row">
<label for="SCENARIO_LOCATION">Location:</label>
<input name="SCENARIO_LOCATION" type="text" class="param">
<select name="SCENARIO_THEATER" class="param" title="Scenario theater">
<option value="ETO">ETO</option>
<option value="PTO">PTO</option>
<option value="Korea">Korea</option>
<option value="Burma">Burma</option>
<option value="other">other</option>
</select>
<select name="SCENARIO_THEATER" class="param" title="Scenario theater"> </select>
</div>
<div class="row">
<label for="SCENARIO_DATE">Date:</label>
@ -32,8 +26,9 @@
</div>
<div class="row" style="margin-top:0.5em;">
<label></label>
<span style='width:13em'></span>
<span style='width:12.9em'></span>
<label class="header" for="ELR">ELR</label>
<span style='width:0.2em'></span>
<label class="header" for="SAN">SAN</label>
</div>
<div class="row">
@ -43,28 +38,28 @@
<select name="PLAYER_1_SAN" class="param"></select>
<button class="generate" data-id="nat_caps_1" style="margin-left:8px;"></button>
</div>
<div class="row">
<input name="PLAYER_1_DESCRIPTION" type="text" class="param" title="Description of the player's OB.">
</div>
<div class="row">
<label for="PLAYER_2">Player 2:</label>
<select name="PLAYER_2" class="param"></select>
<select name="PLAYER_2_ELR" class="param"></select>
<select name="PLAYER_2_SAN" class="param"></select>
<button class="generate" data-id="nat_caps_2" style="margin-left:8px;"></button>
&nbsp;
</div>
<div class="row">
<img id="oba-info" src="{{url_for('static',filename='images/oba-info.png')}}" title="">
<button class="generate" data-id="players">Snippet</button>
<input name="PLAYER_2_DESCRIPTION" type="text" class="param" title="Description of the player's OB.">
</div>
<div id="roar-info" class="row" style="display:none;">
<button id="go-to-roar">ROAR</button>
<input name="ROAR_ID" type="text" size="2" class="param" style="display:none;"> &nbsp;
<span class="name player1"></span>&nbsp;<span class="count player1"></span>
&nbsp;
<div class="progressbar player1"><div class="label"></div></div>
<div class="progressbar player2"><div class="label"></div></div>
&nbsp;
<span class="name player2"></span>&nbsp;<span class="count player2"></span>
<button id="disconnect-roar" title="Unlink from this ROAR scenario."></button>
<div class="row">
<input name="ASA_ID" type="text" size="1" class="param" style="display:none;">
<input name="ROAR_ID" type="text" size="1" class="param" style="display:none;">
<span class="spacer"></span>
<span class="small">
Width:
<input type="text" class="param" name="PLAYERS_WIDTH" size="5">
<button class="generate" data-id="players">Snippet</button>
</span>
</div>
</div>
</fieldset>

@ -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"
}
]
}

@ -24,6 +24,10 @@
]
},
"burmese": {
"oba": null
},
"allied-minor": {
"oba": [ "1B", "1R" ]
}

@ -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" ]

@ -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}}]

@ -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 ] ]
}
}

@ -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

@ -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" )

@ -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()

@ -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

@ -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

@ -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": "" },

@ -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()

@ -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!" ],
)

@ -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!"

@ -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, "&frac14;", singular=True )
do_test( 0.374, "&frac14;", singular=True )
do_test( 0.375, "&frac12;", singular=True )
do_test( 0.624, "&frac12;", singular=True )
do_test( 0.625, "&frac34;", singular=True )
do_test( 0.874, "&frac34;", singular=True )
do_test( 0.875, "1", singular=True )
do_test( 1.124, "1", singular=True )
do_test( 1.125, "1&frac14;" )
do_test( 1.374, "1&frac14;" )
do_test( 1.375, "1&frac12;" )
do_test( 1.624, "1&frac12;" )
do_test( 1.625, "1&frac34;" )
do_test( 1.874, "1&frac34;" )
do_test( 1.875, "2" )
do_test( 2.125, "2&frac14;" )
do_test( 2.5, "2&frac12;" )
do_test( 2.75, "2&frac34;" )

@ -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": "" },

@ -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 )

@ -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 + "&frac34;"
elif frac >= 0.375:
val = val + "&frac12;"
elif frac >= 0.125:
val = val + "&frac14;"
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 ):

Loading…
Cancel
Save