Automatically insert/update labels in a VASSAL save file.

master
Pacman Ghost 5 years ago
parent 035aa4922a
commit a933aa4541
  1. 1
      .gitignore
  2. 3
      .pylintrc
  3. 1
      _freeze.py
  4. 9
      conftest.py
  5. 2
      setup.py
  6. 83
      vasl_templates/file_dialog.py
  7. 6
      vasl_templates/main.py
  8. 26
      vasl_templates/main_window.py
  9. 84
      vasl_templates/server_settings.py
  10. 329
      vasl_templates/ui/server_settings.ui
  11. 96
      vasl_templates/web_channel.py
  12. 1
      vasl_templates/webapp/__init__.py
  13. 2
      vasl_templates/webapp/config/constants.py
  14. 8
      vasl_templates/webapp/config/site.cfg.example
  15. 2
      vasl_templates/webapp/data/default-template-pack/atmm.j2
  16. 2
      vasl_templates/webapp/data/default-template-pack/baz.j2
  17. 2
      vasl_templates/webapp/data/default-template-pack/extras/blank-space.j2
  18. 2
      vasl_templates/webapp/data/default-template-pack/extras/hip-guns.j2
  19. 2
      vasl_templates/webapp/data/default-template-pack/extras/kgs/grenade-bundles.j2
  20. 2
      vasl_templates/webapp/data/default-template-pack/extras/kgs/molotov-cocktails.j2
  21. 2
      vasl_templates/webapp/data/default-template-pack/extras/pf-count.j2
  22. 2
      vasl_templates/webapp/data/default-template-pack/extras/turn-track-shading.j2
  23. 2
      vasl_templates/webapp/data/default-template-pack/mol-p.j2
  24. 2
      vasl_templates/webapp/data/default-template-pack/mol.j2
  25. 2
      vasl_templates/webapp/data/default-template-pack/ob_note.j2
  26. 2
      vasl_templates/webapp/data/default-template-pack/ob_ordnance.j2
  27. 2
      vasl_templates/webapp/data/default-template-pack/ob_setup.j2
  28. 2
      vasl_templates/webapp/data/default-template-pack/ob_vehicles.j2
  29. 2
      vasl_templates/webapp/data/default-template-pack/pf.j2
  30. 2
      vasl_templates/webapp/data/default-template-pack/piat.j2
  31. 2
      vasl_templates/webapp/data/default-template-pack/players.j2
  32. 2
      vasl_templates/webapp/data/default-template-pack/psk.j2
  33. 2
      vasl_templates/webapp/data/default-template-pack/scenario.j2
  34. 2
      vasl_templates/webapp/data/default-template-pack/scenario_note.j2
  35. 2
      vasl_templates/webapp/data/default-template-pack/ssr.j2
  36. 2
      vasl_templates/webapp/data/default-template-pack/victory_conditions.j2
  37. 4
      vasl_templates/webapp/file_server/vasl_mod.py
  38. 5
      vasl_templates/webapp/static/css/main.css
  39. 9
      vasl_templates/webapp/static/css/vassal.css
  40. 18
      vasl_templates/webapp/static/extras.js
  41. 10
      vasl_templates/webapp/static/help/index.html
  42. 17
      vasl_templates/webapp/static/main.js
  43. 38
      vasl_templates/webapp/static/simple_notes.js
  44. 154
      vasl_templates/webapp/static/snippets.js
  45. 2
      vasl_templates/webapp/static/utils.js
  46. 291
      vasl_templates/webapp/static/vassal.js
  47. 6
      vasl_templates/webapp/templates/index.html
  48. 1
      vasl_templates/webapp/templates/testing.html
  49. 9
      vasl_templates/webapp/templates/vassal.html
  50. 57
      vasl_templates/webapp/tests/fixtures/dump-vsav/labels.txt
  51. BIN
      vasl_templates/webapp/tests/fixtures/dump-vsav/labels.vsav
  52. BIN
      vasl_templates/webapp/tests/fixtures/update-vsav/empty.vsav
  53. 1
      vasl_templates/webapp/tests/fixtures/update-vsav/full.json
  54. BIN
      vasl_templates/webapp/tests/fixtures/update-vsav/full.vsav
  55. 1
      vasl_templates/webapp/tests/fixtures/update-vsav/hill621-legacy.json
  56. BIN
      vasl_templates/webapp/tests/fixtures/update-vsav/hill621-legacy.vsav
  57. BIN
      vasl_templates/webapp/tests/fixtures/update-vsav/latw-legacy.vsav
  58. BIN
      vasl_templates/webapp/tests/fixtures/update-vsav/latw.vsav
  59. 9
      vasl_templates/webapp/tests/test_capabilities.py
  60. 4
      vasl_templates/webapp/tests/test_counters.py
  61. 21
      vasl_templates/webapp/tests/test_ob.py
  62. 21
      vasl_templates/webapp/tests/test_scenario_persistence.py
  63. 62
      vasl_templates/webapp/tests/test_snippets.py
  64. 574
      vasl_templates/webapp/tests/test_vassal.py
  65. 22
      vasl_templates/webapp/tests/utils.py
  66. 117
      vasl_templates/webapp/utils.py
  67. 349
      vasl_templates/webapp/vassal.py
  68. 3
      vassal-shim/.gitignore
  69. 27
      vassal-shim/Makefile
  70. 208
      vassal-shim/data/boardNames.txt
  71. BIN
      vassal-shim/release/vassal-shim.jar
  72. 51
      vassal-shim/src/vassal_shim/GamePieceLabelFields.java
  73. 107
      vassal-shim/src/vassal_shim/LabelArea.java
  74. 62
      vassal-shim/src/vassal_shim/Main.java
  75. 17
      vassal-shim/src/vassal_shim/ModuleManagerMenuManager.java
  76. 20
      vassal-shim/src/vassal_shim/ReportNode.java
  77. 57
      vassal-shim/src/vassal_shim/Snippet.java
  78. 45
      vassal-shim/src/vassal_shim/Utils.java
  79. 884
      vassal-shim/src/vassal_shim/VassalShim.java

1
.gitignore vendored

@ -1,4 +1,5 @@
_work_/
_releases_/
.venv*
*.pyc

@ -139,7 +139,8 @@ disable=print-statement,
invalid-name,
wrong-import-position,
global-statement,
too-few-public-methods
too-few-public-methods,
duplicate-code, # can't get it to shut up about @pytest.mark.skipif's :-/
# 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

@ -73,6 +73,7 @@ args = [
"--onefile",
"--name", target_name,
]
args.extend( [ "--add-data", "vassal-shim/release/vassal-shim.jar" + os.pathsep + "vasl_templates/webapp" ] )
# NOTE: We also need to include the config/ and data/ subdirectories, but we would like to
# make them available to the user, so we include them ourself in the final release archive.
def map_dir( src, dest ): #pylint: disable=missing-docstring

@ -43,12 +43,21 @@ def pytest_addoption( parser ):
"--short-tests", action="store_true", dest="short_tests", default=False,
help="Run a shorter version of the test suite."
)
# NOTE: Some tests require the VASL module file(s). We don't want to put these into source control,
# so we provide this option to allow the caller to specify where they live.
parser.addoption(
"--vasl-mods", action="store", dest="vasl_mods", default=None,
help="Directory containing the VASL .vmod file(s)."
)
# NOTE: Some tests require VASSAL to be installed. This option allows the caller to specify
# where it is (multiple installations can be placed in sub-directories).
parser.addoption(
"--vassal", action="store", dest="vassal", default=None,
help="Directory containing VASSAL installation(s)."
)
# NOTE: It's not good to have the code run differently to how it will normally,
# but using the clipboard to retrieve snippets causes more trouble than it's worth :-/
# since any kind of clipboard activity while the tests are running could cause them to fail

@ -22,13 +22,13 @@ setup(
"PyQT5==5.10.0",
"pyyaml==3.13",
"pillow==5.3.0",
"selenium==3.12.0",
"click==6.7",
],
extras_require = {
"dev": [
"pytest==3.6.0",
"tabulate==0.8.2",
"selenium==3.12.0",
"lxml==4.2.4",
"pylint==1.9.2",
"pytest-pylint==0.9.0",

@ -0,0 +1,83 @@
""" Manage loading and saving files. """
import os
from PyQt5.QtWidgets import QFileDialog
# ---------------------------------------------------------------------
# NOTE: While loading/saving files works fine when handled by the embedded browser,
# we can't get the full path of the file loaded (because of browser security).
# This means that we can't do things like default to saving a scenario to the same file
# it was loaded from, or retrying a failed save. This is such a lousy UX,
# we handle load/save operations ourself, where we can manage things like this.
class FileDialog:
"""Manage loading and saving files."""
def __init__( self, parent, object_name, default_extn, filters, default_fname ):
self.parent = parent
self.object_name = object_name
self.default_extn = default_extn
self.filters = filters
self.curr_fname = default_fname
def load_file( self, binary ):
"""Load a file."""
# ask the user which file to load
fname, _ = QFileDialog.getOpenFileName(
self.parent, "Load {}".format( self.object_name ),
self.curr_fname,
self.filters
)
if not fname:
return None
# load the file
try:
with open( fname, "rb" ) as fp:
data = fp.read()
except Exception as ex: #pylint: disable=broad-except
self.parent.showErrorMsg( "Can't load the {}:\n\n{}".format( self.object_name, ex ) )
return None
if not binary:
data = data.decode( "utf-8" )
self.curr_fname = fname
return data
def save_file( self, data ):
"""Save data to a file."""
# initialize
if isinstance( data, str ):
data = data.encode( "utf-8" )
while True: # nb: keep trying until the save succeeds or the user cancels the operation
# ask the user where to save the file
fname, _ = QFileDialog.getSaveFileName(
self.parent, "Save {}".format( self.object_name),
self.curr_fname,
self.filters
)
if not fname:
return False
# check the file extension
extn = os.path.splitext( fname )[1]
if not extn:
fname += self.default_extn
elif fname.endswith( "." ):
fname = fname[:-1]
# save the file
try:
with open( fname, "wb", ) as fp:
fp.write( data )
except Exception as ex: #pylint: disable=broad-except
self.parent.showErrorMsg( "Can't save the {}:\n\n{}".format( self.object_name, ex ) )
continue
self.curr_fname = fname
return True

@ -15,6 +15,8 @@ from PyQt5.QtCore import Qt, QSettings, QDir
import PyQt5.QtCore
import click
from vasl_templates.webapp.utils import SimpleError
# FUDGE! This needs to be created before showing any UI elements e.g. an error message box.
qt_app = QApplication( sys.argv )
@ -86,7 +88,7 @@ def _do_main( template_pack, default_scenario, remote_debugging, debug ): #pylin
# configure the default scenario
if default_scenario:
if not os.path.isfile( default_scenario ):
raise RuntimeError( "Can't find the default scenario file." )
raise SimpleError( "Can't find the default scenario file." )
webapp_main.default_scenario = default_scenario
# configure remote debugging
@ -134,7 +136,7 @@ def _do_main( template_pack, default_scenario, remote_debugging, debug ): #pylin
except: #pylint: disable=bare-except
resp = None
if resp:
raise RuntimeError( "The application is already running." )
raise SimpleError( "The application is already running." )
# start the webapp server
def webapp_thread():

@ -5,15 +5,16 @@ import os
import re
import json
import io
import base64
import logging
from PyQt5.QtWidgets import QWidget, QVBoxLayout, QMenuBar, QAction, QLabel, QMessageBox
from PyQt5.QtWebEngineWidgets import QWebEngineView, QWebEngineProfile, QWebEnginePage
from PyQt5.QtWebChannel import QWebChannel
from PyQt5.QtGui import QDesktopServices, QIcon
from PyQt5.QtCore import Qt, QUrl, QMargins, pyqtSlot
from PyQt5.QtCore import Qt, QUrl, QMargins, pyqtSlot, QVariant
from vasl_templates.webapp.config.constants import APP_NAME
from vasl_templates.webapp.config.constants import APP_NAME, IS_FROZEN
from vasl_templates.main import app_settings
from vasl_templates.web_channel import WebChannelHandler
from vasl_templates.utils import log_exceptions
@ -55,7 +56,7 @@ class MainWindow( QWidget ):
# initialize the main window
self.setWindowTitle( APP_NAME )
if getattr( sys, "frozen", False ):
if IS_FROZEN:
dname = sys._MEIPASS #pylint: disable=no-member,protected-access
else:
dname = os.path.join( os.path.split(__file__)[0], "webapp" )
@ -249,6 +250,25 @@ class MainWindow( QWidget ):
"""Called when the user wants to save a scenario."""
return self._web_channel_handler.save_scenario( data )
@pyqtSlot( result=QVariant )
@log_exceptions( caption="SLOT EXCEPTION" )
def load_vsav( self ):
"""Called when the user wants to update a VASL scenario."""
fname, data = self._web_channel_handler.load_vsav()
if data is None:
return None
return QVariant( {
"filename": fname,
"data": base64.b64encode( data ).decode( "utf-8" )
} )
@pyqtSlot( str, str, result=bool )
@log_exceptions( caption="SLOT EXCEPTION" )
def save_updated_vsav( self, fname, data ):
"""Called when a VASL scenario has been updated and is ready to be saved."""
data = base64.b64decode( data )
return self._web_channel_handler.save_updated_vsav( fname, data )
@pyqtSlot( str )
@log_exceptions( caption="SLOT EXCEPTION" )
def on_user_settings_change( self, user_settings ): #pylint: disable=no-self-use

@ -9,6 +9,7 @@ from PyQt5.QtGui import QIcon
from vasl_templates.main import app_settings
from vasl_templates.main_window import MainWindow
from vasl_templates.webapp.config.constants import DATA_DIR
from vasl_templates.webapp.vassal import SUPPORTED_VASSAL_VERSIONS_DISPLAY
from vasl_templates.webapp.file_server.vasl_mod import VaslMod, SUPPORTED_VASL_MOD_VERSIONS_DISPLAY
from vasl_templates.webapp.files import install_vasl_mod
@ -26,39 +27,96 @@ class ServerSettingsDialog( QDialog ):
base_dir = os.path.split( __file__ )[0]
dname = os.path.join( base_dir, "ui/server_settings.ui" )
uic.loadUi( dname, self )
self.select_vasl_mod_button.setIcon(
QIcon( os.path.join( base_dir, "resources/file_browser.png" ) )
)
for btn in ["vassal_dir","vasl_mod","boards_dir","java","webdriver"]:
getattr( self, "select_{}_button".format(btn) ).setIcon(
QIcon( os.path.join( base_dir, "resources/file_browser.png" ) )
)
self.setMinimumSize( self.size() )
# initialize handlers
self.select_vassal_dir_button.clicked.connect( self.on_select_vassal_dir )
self.select_vasl_mod_button.clicked.connect( self.on_select_vasl_mod )
self.select_boards_dir_button.clicked.connect( self.on_select_boards_dir )
self.select_java_button.clicked.connect( self.on_select_java )
self.select_webdriver_button.clicked.connect( self.on_select_webdriver )
self.ok_button.clicked.connect( self.on_ok )
self.cancel_button.clicked.connect( self.on_cancel )
# load the current server settings
self.vassal_dir.setText( app_settings.value( "ServerSettings/vassal-dir" ) )
self.vassal_dir.setToolTip(
"Supported versions: {}".format( SUPPORTED_VASSAL_VERSIONS_DISPLAY )
)
self.vasl_mod.setText( app_settings.value( "ServerSettings/vasl-mod" ) )
self.vasl_mod.setToolTip(
"Supported versions: {}".format( SUPPORTED_VASL_MOD_VERSIONS_DISPLAY )
)
self.boards_dir.setText( app_settings.value( "ServerSettings/boards-dir" ) )
self.java_path.setText( app_settings.value( "ServerSettings/java-path" ) )
self.webdriver_path.setText( app_settings.value( "ServerSettings/webdriver-path" ) )
self.webdriver_path.setToolTip( "Configure either geckodriver or chromedriver here." )
def on_select_vassal_dir( self ):
"""Let the user locate the VASSAL installation directory."""
dname = QFileDialog.getExistingDirectory(
self, "Select VASSAL installation directory",
self.vassal_dir.text(),
QFileDialog.ShowDirsOnly
)
if dname:
self.vassal_dir.setText( dname )
def on_select_vasl_mod( self ):
"""Let the user select a VASL module."""
fname = QFileDialog.getOpenFileName(
self, "Select VASL module",
app_settings.value( "ServerSettings/vasl-mod" ),
"VASL module files (*.vmod)|All files (*.*)"
self.vasl_mod.text(),
"VASL module files (*.vmod);;All files (*.*)"
)[0]
if fname:
self.vasl_mod.setText( fname )
def on_select_boards_dir( self ):
"""Let the user locate the VASL boards directory."""
dname = QFileDialog.getExistingDirectory(
self, "Select VASL boards directory",
self.boards_dir.text(),
QFileDialog.ShowDirsOnly
)
if dname:
self.boards_dir.setText( dname )
def on_select_java( self ):
"""Let the user locate the Java executable."""
fname = QFileDialog.getOpenFileName(
self, "Select Java executable",
self.java_path.text(),
_make_exe_filter_string()
)[0]
if fname:
self.java_path.setText( fname )
def on_select_webdriver( self ):
"""Let the user locate the webdriver executable."""
fname = QFileDialog.getOpenFileName(
self, "Select webdriver",
self.webdriver_path.text(),
_make_exe_filter_string()
)[0]
if fname:
self.webdriver_path.setText( fname )
def on_ok( self ):
"""Accept the new server settings."""
# save the new settings
app_settings.setValue( "ServerSettings/vassal-dir", self.vassal_dir.text() )
fname = self.vasl_mod.text().strip()
vasl_mod_changed = fname != app_settings.value( "ServerSettings/vasl-mod" )
app_settings.setValue( "ServerSettings/vasl-mod", fname )
app_settings.setValue( "ServerSettings/boards-dir", self.boards_dir.text() )
app_settings.setValue( "ServerSettings/java-path", self.java_path.text() )
app_settings.setValue( "ServerSettings/webdriver-path", self.webdriver_path.text() )
# install the new settings
# NOTE: We should really do this before saving the new settings, but that's more trouble
@ -80,11 +138,27 @@ class ServerSettingsDialog( QDialog ):
"""Cancel the dialog."""
self.close()
def _make_exe_filter_string():
"""Make a file filter string for executables."""
buf = []
if os.name == "nt":
buf.append( "Executable files (*.exe)" )
buf.append( "All files (*.*)" )
return ";;".join( buf )
# ---------------------------------------------------------------------
def install_server_settings():
"""Install the server settings."""
# install the server settings
from vasl_templates.webapp import app as app
app.config["VASSAL_DIR"] = app_settings.value( "ServerSettings/vassal-dir" )
app.config["VASL_MOD"] = app_settings.value( "ServerSettings/vasl-mod" )
app.config["BOARDS_DIR"] = app_settings.value( "ServerSettings/boards-dir" )
app.config["JAVA_PATH"] = app_settings.value( "ServerSettings/java-path" )
app.config["WEBDRIVER_PATH"] = app_settings.value( "ServerSettings/webdriver-path" )
# load the VASL module
fname = app_settings.value( "ServerSettings/vasl-mod" )
if fname:

@ -10,7 +10,7 @@
<x>0</x>
<y>0</y>
<width>500</width>
<height>90</height>
<height>199</height>
</rect>
</property>
<property name="windowTitle">
@ -21,100 +21,227 @@
</property>
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="QWidget" name="widget_2" native="true">
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="minimumSize">
<size>
<width>0</width>
<height>30</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>16777215</width>
<height>30</height>
</size>
</property>
<layout class="QHBoxLayout" name="horizontalLayout_2">
<property name="spacing">
<number>5</number>
</property>
<property name="leftMargin">
<number>0</number>
</property>
<property name="topMargin">
<number>0</number>
</property>
<property name="rightMargin">
<number>0</number>
</property>
<property name="bottomMargin">
<number>0</number>
</property>
<item>
<widget class="QLabel" name="label">
<property name="text">
<string>&amp;VASL module:</string>
</property>
<property name="buddy">
<cstring>vasl_mod</cstring>
</property>
</widget>
</item>
<item>
<widget class="QLineEdit" name="vasl_mod"/>
</item>
<item>
<widget class="QPushButton" name="select_vasl_mod_button">
<property name="sizePolicy">
<sizepolicy hsizetype="Fixed" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="minimumSize">
<size>
<width>25</width>
<height>25</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>25</width>
<height>25</height>
</size>
</property>
<property name="text">
<string/>
</property>
<property name="autoDefault">
<bool>true</bool>
</property>
</widget>
</item>
</layout>
<zorder>vasl_mod</zorder>
<zorder>label</zorder>
<zorder>select_vasl_mod_button</zorder>
</widget>
</item>
<item>
<spacer name="verticalSpacer">
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>20</width>
<height>40</height>
</size>
<layout class="QFormLayout" name="formLayout">
<property name="verticalSpacing">
<number>2</number>
</property>
</spacer>
<item row="0" column="0">
<widget class="QLabel" name="label_2">
<property name="text">
<string>VA&amp;SSAL installation:</string>
</property>
<property name="buddy">
<cstring>vassal_dir</cstring>
</property>
</widget>
</item>
<item row="0" column="1">
<layout class="QHBoxLayout" name="horizontalLayout_3">
<property name="spacing">
<number>2</number>
</property>
<item>
<widget class="QLineEdit" name="vassal_dir"/>
</item>
<item>
<widget class="QPushButton" name="select_vassal_dir_button">
<property name="minimumSize">
<size>
<width>22</width>
<height>22</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>22</width>
<height>22</height>
</size>
</property>
<property name="text">
<string/>
</property>
</widget>
</item>
</layout>
</item>
<item row="1" column="0">
<widget class="QLabel" name="label">
<property name="text">
<string>&amp;VASL module:</string>
</property>
<property name="buddy">
<cstring>vasl_mod</cstring>
</property>
</widget>
</item>
<item row="1" column="1">
<layout class="QHBoxLayout" name="horizontalLayout_7">
<property name="spacing">
<number>2</number>
</property>
<item>
<widget class="QLineEdit" name="vasl_mod"/>
</item>
<item>
<widget class="QPushButton" name="select_vasl_mod_button">
<property name="sizePolicy">
<sizepolicy hsizetype="Fixed" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="minimumSize">
<size>
<width>22</width>
<height>22</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>22</width>
<height>22</height>
</size>
</property>
<property name="text">
<string/>
</property>
<property name="autoDefault">
<bool>true</bool>
</property>
</widget>
</item>
</layout>
</item>
<item row="2" column="0">
<widget class="QLabel" name="label_3">
<property name="text">
<string>VASL &amp;boards:</string>
</property>
<property name="buddy">
<cstring>boards_dir</cstring>
</property>
</widget>
</item>
<item row="2" column="1">
<layout class="QHBoxLayout" name="horizontalLayout_4">
<property name="spacing">
<number>2</number>
</property>
<item>
<widget class="QLineEdit" name="boards_dir"/>
</item>
<item>
<widget class="QPushButton" name="select_boards_dir_button">
<property name="sizePolicy">
<sizepolicy hsizetype="Fixed" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="minimumSize">
<size>
<width>22</width>
<height>22</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>22</width>
<height>22</height>
</size>
</property>
<property name="text">
<string/>
</property>
</widget>
</item>
</layout>
</item>
<item row="3" column="0">
<widget class="QLabel" name="label_4">
<property name="text">
<string>&amp;Java:</string>
</property>
<property name="buddy">
<cstring>java_path</cstring>
</property>
</widget>
</item>
<item row="3" column="1">
<layout class="QHBoxLayout" name="horizontalLayout_5">
<property name="spacing">
<number>2</number>
</property>
<item>
<widget class="QLineEdit" name="java_path"/>
</item>
<item>
<widget class="QPushButton" name="select_java_button">
<property name="sizePolicy">
<sizepolicy hsizetype="Fixed" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="minimumSize">
<size>
<width>22</width>
<height>22</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>22</width>
<height>22</height>
</size>
</property>
<property name="text">
<string/>
</property>
</widget>
</item>
</layout>
</item>
<item row="4" column="0">
<widget class="QLabel" name="label_5">
<property name="text">
<string>&amp;Web driver:</string>
</property>
<property name="buddy">
<cstring>webdriver_path</cstring>
</property>
</widget>
</item>
<item row="4" column="1">
<layout class="QHBoxLayout" name="horizontalLayout_6">
<property name="spacing">
<number>2</number>
</property>
<item>
<widget class="QLineEdit" name="webdriver_path"/>
</item>
<item>
<widget class="QPushButton" name="select_webdriver_button">
<property name="minimumSize">
<size>
<width>22</width>
<height>22</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>22</width>
<height>22</height>
</size>
</property>
<property name="text">
<string/>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</item>
<item>
<widget class="QWidget" name="widget" native="true">
@ -199,6 +326,20 @@
</item>
</layout>
</widget>
<tabstops>
<tabstop>vassal_dir</tabstop>
<tabstop>select_vassal_dir_button</tabstop>
<tabstop>vasl_mod</tabstop>
<tabstop>select_vasl_mod_button</tabstop>
<tabstop>boards_dir</tabstop>
<tabstop>select_boards_dir_button</tabstop>
<tabstop>java_path</tabstop>
<tabstop>select_java_button</tabstop>
<tabstop>webdriver_path</tabstop>
<tabstop>select_webdriver_button</tabstop>
<tabstop>ok_button</tabstop>
<tabstop>cancel_button</tabstop>
</tabstops>
<resources/>
<connections/>
</ui>

@ -2,87 +2,57 @@
import os
from PyQt5.QtWidgets import QFileDialog
from vasl_templates.webapp.config.constants import APP_NAME
from vasl_templates.file_dialog import FileDialog
# ---------------------------------------------------------------------
class WebChannelHandler:
"""Handle web channel requests."""
_FILE_FILTERS = "Scenario files (*.json);;All files (*)"
def __init__( self, window ):
# initialize
self._window = window
# NOTE: While loading/saving scenarios works fine when handled by the embedded browser,
# we can't get the full path of the file saved loaded (because of browser security).
# This means that we can't e.g. default saving a scenario to the same file it was loaded from.
# This is such a lousy UX, we handle load/save operations ourself, where we can manage this.
self._curr_scenario_fname = None
def __init__( self, parent ):
self.parent = parent
self.scenario_file_dialog = FileDialog(
self.parent,
"scenario", ".json",
"Scenario files (*.json);;All files (*)",
"scenario.json"
)
self.updated_vsav_file_dialog = FileDialog(
self.parent,
"VASL scenario", ".vsav",
"VASL scenario files (*.vsav);;All files (*)",
"scenario.vsav"
)
def on_new_scenario( self ):
"""Called when the scenario is reset."""
self._curr_scenario_fname = None
self.scenario_file_dialog.curr_fname = None
def load_scenario( self ):
"""Called when the user wants to load a scenario."""
# ask the user which file to load
fname, _ = QFileDialog.getOpenFileName(
self._window, "Load scenario",
os.path.split(self._curr_scenario_fname)[0] if self._curr_scenario_fname else None,
WebChannelHandler._FILE_FILTERS
)
if not fname:
return None
# load the scenario
try:
with open( fname, "r", encoding="utf-8" ) as fp:
data = fp.read()
except Exception as ex: #pylint: disable=broad-except
self._window.showErrorMsg( "Can't load the scenario:\n\n{}".format( ex ) )
return None
self._curr_scenario_fname = fname
return data
return self.scenario_file_dialog.load_file( False )
def save_scenario( self, data ):
"""Called when the user wants to save a scenario."""
# ask the user where to save the scenario
fname, _ = QFileDialog.getSaveFileName(
self._window, "Save scenario",
self._curr_scenario_fname,
WebChannelHandler._FILE_FILTERS
)
if not fname:
return False
# check the file extension
extn = os.path.splitext( fname )[1]
if not extn:
fname += ".json"
elif fname.endswith( "." ):
fname = fname[:-1]
# save the file
try:
with open( fname, "w", encoding="utf-8" ) as fp:
fp.write( data )
except Exception as ex: #pylint: disable=broad-except
self._window.showErrorMsg( "Can't save the scenario:\n\n{}".format( ex ) )
return False
self._curr_scenario_fname = fname
return True
return self.scenario_file_dialog.save_file( data )
def on_scenario_name_change( self, val ):
"""Update the main window title to show the scenario name."""
self._window.setWindowTitle(
self.parent.setWindowTitle(
"{} - {}".format( APP_NAME, val ) if val else APP_NAME
)
def load_vsav( self ):
"""Called when the user wants to load a VASL scenario to update."""
data = self.updated_vsav_file_dialog.load_file( True )
if data is None:
return None, None
fname = os.path.split( self.updated_vsav_file_dialog.curr_fname )[1]
return fname, data
def save_updated_vsav( self, fname, data ):
"""Called when a VASL scenario has been updated and is ready to be saved."""
dname = os.path.split( self.updated_vsav_file_dialog.curr_fname )[0]
self.updated_vsav_file_dialog.curr_fname = os.path.join( dname, fname )
return self.updated_vsav_file_dialog.save_file( data )

@ -59,6 +59,7 @@ import vasl_templates.webapp.main #pylint: disable=cyclic-import
import vasl_templates.webapp.vo #pylint: disable=cyclic-import
import vasl_templates.webapp.snippets #pylint: disable=cyclic-import
import vasl_templates.webapp.files #pylint: disable=cyclic-import
import vasl_templates.webapp.vassal #pylint: disable=cyclic-import
# ---------------------------------------------------------------------

@ -8,7 +8,9 @@ APP_VERSION = "v0.5" # nb: also update setup.py
APP_DESCRIPTION = "Generate HTML for use in VASL scenarios."
if getattr( sys, "frozen", False ):
IS_FROZEN = True
BASE_DIR = os.path.split( sys.executable )[0]
else:
IS_FROZEN = False
BASE_DIR = os.path.abspath( os.path.join( os.path.split(__file__)[0], ".." ) )
DATA_DIR = os.path.join( BASE_DIR, "data" )

@ -1,4 +1,10 @@
[Site Config]
; Enable VASL counter images in the UI by configuring a VASL .vmod file here.
VASL_MOD = ...
VASL_MOD = ...configure the VASL module (e.g. vasl-6.4.3.vmod)...
; Configure VASSAL to be able to automatically update labels in a VASL scenario.
VASSAL_DIR = ...configure the VASSAL installation directory...
BOARDS_DIR = ...configure the VASL boards directory...
WEBDRIVER_PATH = ...configure either geckodriver or chromedriver here...
; JAVA_PATH = ...configure the Java executable here (optional, must be in the PATH otherwise)...

@ -1,4 +1,4 @@
<html>
<html> <!-- vasl-templates:id {{SNIPPET_ID}} -->
<head>
<meta charset="utf-8">

@ -1,4 +1,4 @@
<html>
<html> <!-- vasl-templates:id {{SNIPPET_ID}} -->
<head>
<meta charset="utf-8">

@ -1,4 +1,4 @@
<html>
<html> <!-- vasl-templates:id {{SNIPPET_ID}} -->
<!-- vasl-templates:name Blank space -->
<!-- vasl-templates:description Generates a white label that can be used to cover up and hide things in your scenario. -->

@ -1,4 +1,4 @@
<html>
<html> <!-- vasl-templates:id {{SNIPPET_ID}} -->
<!-- vasl-templates:name Hidden Guns -->
<!-- vasl-templates:description HIP Guns for Solo Play, taken from <a href="http://vftt.co.uk/vfttpdfs.asp"><i>View From The Trenches</i></a>, Issue 34/35. -->

@ -1,4 +1,4 @@
<html>
<html> <!-- vasl-templates:id {{SNIPPET_ID}} -->
<!-- vasl-templates:name KGS Grenade Bundles -->
<!-- vasl-templates:description Data chart for Grenade Bundles in <i>Kampfgruppe Scherer</i>. -->

@ -1,4 +1,4 @@
<html>
<html> <!-- vasl-templates:id {{SNIPPET_ID}} -->
<!-- vasl-templates:name KGS Molotov Cocktails -->
<!-- vasl-templates:description Data chart for Molotov Cocktails in <i>Kampfgruppe Scherer</i>. -->

@ -1,4 +1,4 @@
<html>
<html> <!-- vasl-templates:id {{SNIPPET_ID}} -->
<!-- vasl-templates:name PF count -->
<!-- vasl-templates:description Add the snippet as the label of a Panzerfaust counter, then press <i>Ctrl-L</i> when you need to update the number of remaining shots. -->

@ -1,4 +1,4 @@
<html>
<html> <!-- vasl-templates:id {{SNIPPET_ID}} -->
<!-- vasl-templates:name Turn Track shading -->
<!-- vasl-templates:description Generates a shaded square that you can place behind the Turn Track to indicate an LV Hindrance e.g. because of dusk/dawn. -->

@ -1,4 +1,4 @@
<html>
<html> <!-- vasl-templates:id {{SNIPPET_ID}} -->
<head>
<meta charset="utf-8">

@ -1,4 +1,4 @@
<html>
<html> <!-- vasl-templates:id {{SNIPPET_ID}} -->
<head>
<meta charset="utf-8">

@ -1,4 +1,4 @@
<html>
<html> <!-- vasl-templates:id {{SNIPPET_ID}} -->
<head>
<meta charset="utf-8">

@ -1,4 +1,4 @@
<html>
<html> <!-- vasl-templates:id {{SNIPPET_ID}} -->
<head>
<meta charset="utf-8">

@ -1,4 +1,4 @@
<html>
<html> <!-- vasl-templates:id {{SNIPPET_ID}} -->
<head>
<meta charset="utf-8">

@ -1,4 +1,4 @@
<html>
<html> <!-- vasl-templates:id {{SNIPPET_ID}} -->
<head>
<meta charset="utf-8">

@ -1,4 +1,4 @@
<html>
<html> <!-- vasl-templates:id {{SNIPPET_ID}} -->
<head>
<meta charset="utf-8">

@ -1,4 +1,4 @@
<html>
<html> <!-- vasl-templates:id {{SNIPPET_ID}} -->
<head>
<meta charset="utf-8">

@ -1,4 +1,4 @@
<html>
<html> <!-- vasl-templates:id {{SNIPPET_ID}} -->
<head>
<meta charset="utf-8">

@ -1,4 +1,4 @@
<html>
<html> <!-- vasl-templates:id {{SNIPPET_ID}} -->
<head>
<meta charset="utf-8">

@ -1,4 +1,4 @@
<html>
<html> <!-- vasl-templates:id {{SNIPPET_ID}} -->
<head>
<meta charset="utf-8">

@ -1,4 +1,4 @@
<html>
<html> <!-- vasl-templates:id {{SNIPPET_ID}} -->
<head>
<meta charset="utf-8">

@ -1,4 +1,4 @@
<html>
<html> <!-- vasl-templates:id {{SNIPPET_ID}} -->
<head>
<meta charset="utf-8">

@ -1,4 +1,4 @@
<html>
<html> <!-- vasl-templates:id {{SNIPPET_ID}} -->
<head>
<meta charset="utf-8">

@ -11,8 +11,8 @@ _logger = logging.getLogger( "vasl_mod" )
from vasl_templates.webapp.file_server.utils import get_vo_gpids, get_effective_gpid
SUPPORTED_VASL_MOD_VERSIONS = [ "6.3.3", "6.4.0", "6.4.1", "6.4.2", "6.4.3" ]
SUPPORTED_VASL_MOD_VERSIONS_DISPLAY = "6.3.3, 6.4.0-6.4.3"
SUPPORTED_VASL_MOD_VERSIONS = [ "6.4.0", "6.4.1", "6.4.2", "6.4.3" ]
SUPPORTED_VASL_MOD_VERSIONS_DISPLAY = "6.4.0-6.4.3"
# ---------------------------------------------------------------------

@ -14,7 +14,7 @@ label { height: 1.25em ; margin-top: -3px ; }
#menu { position: absolute ; top: 15px ; right: 8px ; z-index: 1 ; }
#menu input[type='image'] { height: 30px ; }
.PopMenu-Item { width: 11em ; }
.PopMenu-Item { width: 12em ; }
.PopMenu-Item a { padding: 5px 10px 5px 10px ; }
.PopMenu-Icon { display: none ; }
@ -26,7 +26,8 @@ label { height: 1.25em ; margin-top: -3px ; }
.select2-dropdown { color: #444 ; }
.snippet-control button.generate { height: 26px ; padding: 2px 10px 2px 5px ; }
.snippet-control button.generate { height: 26px ; padding: 2px 10px 2px 5px ; color: #000 ; }
.snippet-control button.generate.inactive { color: #aaa ; }
.snippet-control button.generate img { height: 20px ; margin-right: 5px ; vertical-align: middle ; }
.snippet-control .ui-selectmenu-button { padding: 2px 10px ; }
.snippet-control-menu-item { font-size: 75% ; font-style: italic ; }

@ -0,0 +1,9 @@
.ui-dialog.update-vsav .ui-dialog-titlebar { display: none ; }
#update-vsav { display: flex ; align-items: center ; }
#update-vsav img { margin-right: 1em ; }
#vassal-shim-error textarea { width: 100% ; height: 15em ; min-height: 5em ; resize: none ; padding: 2px ; font-family: monospace ; font-size: 80% ; }
.ui-dialog.vassal-shim-error .ui-dialog-titlebar { background: #f5af41 ; }
.ui-dialog.vassal-shim-error .ui-dialog-content { display: flex ; flex-direction: column ; }
.ui-dialog.vassal-shim-error .ui-dialog-content textarea { flex-grow: 1 ; }
.ui-dialog.vassal-shim-error .ui-dialog-buttonpane { border: none ; margin-top: 0 !important ; padding-top: 0 !important ; }

@ -147,20 +147,30 @@ function _parse_extra_template( template_id, template )
function fixup_template_parameters( template )
{
// identify any non-standard template parameters
var matches = [] ;
var regex = /\{\{([A-Z0-9_]+?):.*?\}\}/g ;
var matches = [] ;
var match ;
while( (match = regex.exec( template )) !== null )
matches.push( [ regex.lastIndex-match[0].length, match[0].length, match[1] ] ) ;
// fix them up
var i ;
if ( matches.length > 0 ) {
for ( var i=matches.length-1 ; i >= 0 ; --i )
for ( i=matches.length-1 ; i >= 0 ; --i )
template = template.substr(0,matches[i][0]) + "{{"+matches[i][2]+"}}" + template.substr(matches[i][0]+matches[i][1]) ;
}
// remove comments
template = template.replace( /<!-- vasl-templates:.*? -->\n*/g, "" ) ;
// remove all our special comments, except for the snippet ID
regex = /<!-- vasl-templates:(.*?) .*? -->\n*/g ;
matches = [] ;
while( (match = regex.exec( template )) !== null ) {
if ( match[1] !== "id" )
matches.push( [ regex.lastIndex-match[0].length, match[0].length ] ) ;
}
if ( matches.length > 0 ) {
for ( i=matches.length-1 ; i >= 0 ; --i )
template = template.substr(0,matches[i][0]) + template.substr(matches[i][0]+matches[i][1]) ;
}
return template ;
}

@ -296,6 +296,16 @@ pytest --webdriver chrome --headless
<p> <small><em>NOTE: Internet Explorer is also supported as a WebDriver, but due to differences in the way it works, some tests are currently failing for this.</em></small>
<h2> Compiling the VASSAL shim </h2>
<p> The program uses VASSAL to update VASL scenarios (<tt>.vsav</tt> files), and since this is written in Java, a helper program has been written in Java to do this.
<p> To compile the program, go to the <tt>$/vassal-shim</tt> directory and type:
<div class="code">
make all VASSAL_DIR=...
</div>
where <tt>VASSAL_DIR</tt> points to VASSAL's <tt>lib/</tt> directory (the program needs <tt>Vengine.jar</tt>).
<p> Since this program doesn't change very often, the resulting artifact (<tt>vassal-shim.jar</tt>) is checked into source control, so that it can be used without needing to install a JDK and compiling it first.
<h2> Code lint'ing </h2>
<p> Python code is checked using <a href="https://pylint.readthedocs.io/en/latest/"><tt>pylint</tt></a> (installed during the <tt>pip install</tt> above), which should be run from the root directory of the repo.

@ -9,7 +9,7 @@ gVaslPieceInfo = {} ;
gWebChannelHandler = null ;
gEmSize = null ;
var _NATIONALITY_SPECIFIC_BUTTONS = {
var NATIONALITY_SPECIFIC_BUTTONS = {
"russian": [ "mol", "mol-p" ],
"german": [ "pf", "psk", "atmm" ],
"american": [ "baz" ],
@ -39,6 +39,7 @@ $(document).ready( function () {
new_scenario: { label: "New scenario", action: function() { on_new_scenario() ; } },
load_scenario: { label: "Load scenario", action: on_load_scenario },
save_scenario: { label: "Save scenario", action: on_save_scenario },
update_vsav: { label: "Update VASL scenario", action: on_update_vsav },
separator: { type: "separator" },
template_pack: { label: "Load template pack", action: on_template_pack },
separator2: { type: "separator" },
@ -63,10 +64,10 @@ $(document).ready( function () {
} ) ;
}
} ) ;
// add a handler for when the "load scenario" file has been selected
// add handlers
$("#load-scenario").change( on_load_scenario_file_selected ) ;
// add a handler for when the "load template pack" file has been selected
$("#load-template-pack").change( on_template_pack_file_selected ) ;
$("#load-vsav").change( on_load_vsav_file_selected ) ;
// all done - we can show the menu now
$("#menu").show() ;
@ -421,10 +422,10 @@ function update_page_load_status( id )
// check if the vehicle/ordnance listings have finished loading
if ( gPageLoadStatus.indexOf( "vehicle-listings" ) === -1 && gPageLoadStatus.indexOf( "ordnance-listings" ) === -1 ) {
// NOTE: If the default scanerio contains any vehicles or ordnance, it will look up the V/O listings,
// NOTE: If the default scenario contains any vehicles or ordnance, it will look up the V/O listings,
// so we need to wait until those have arrived. Note that while the default scenario will normally
// be empty, having stuff in it is very useful during development.
do_on_new_scenario() ;
do_on_new_scenario( false ) ;
}
// check if the page has finished loading
@ -555,9 +556,9 @@ function on_player_change( player_no )
var player_nat = update_ob_tab_header( player_no ) ;
// show/hide the nationality-specific buttons
for ( var nat in _NATIONALITY_SPECIFIC_BUTTONS ) {
for ( var i=0 ; i < _NATIONALITY_SPECIFIC_BUTTONS[nat].length ; ++i ) {
var button_id = _NATIONALITY_SPECIFIC_BUTTONS[nat][i] ;
for ( var nat in NATIONALITY_SPECIFIC_BUTTONS ) {
for ( var i=0 ; i < NATIONALITY_SPECIFIC_BUTTONS[nat].length ; ++i ) {
var button_id = NATIONALITY_SPECIFIC_BUTTONS[nat][i] ;
var $elem = $( "#panel-ob_notes_" + player_no + " div.snippet-control[data-id='" + button_id + "']" ) ;
$elem.css( "display", nat == player_nat ? "inline-block" : "none" ) ;
}

@ -88,6 +88,13 @@ function _do_edit_simple_note( $sortable2, $entry, default_width )
// create a new note
if ( caption !== "" ) {
data = { caption: caption, width: width } ;
if ( note_type === "scenario_notes" || note_type === "ob_setups" || note_type === "ob_notes" ) {
var usedIds = {} ;
$sortable2.find( "li" ).each( function() {
usedIds[ $(this).data("sortable2-data").id ] = true ;
} ) ;
data.id = auto_assign_id( usedIds ) ;
}
_do_add_simple_note( $sortable2, data ) ;
}
}
@ -132,23 +139,32 @@ function _make_simple_note( note_type, caption )
// add a handler for the snippet button
$content.children("img.snippet").click( function() {
var data = $(this).parent().parent().data( "sortable2-data" ) ;
var key ;
if ( note_type === "scenario_notes" )
key = "SCENARIO_NOTE" ;
else if ( note_type === "ob_setups" )
key = "OB_SETUP" ;
else if ( note_type == "ob_notes" )
key = "OB_NOTE" ;
var extra_params = {} ;
extra_params[key] = data.caption ;
extra_params[key+"_WIDTH"] = data.width ;
var extra_params = get_simple_note_snippet_extra_params( $(this) ) ;
generate_snippet( $(this), extra_params ) ;
} ) ;
return $content ;
}
function get_simple_note_snippet_extra_params( $img )
{
// get the extra parameters needed to generate the simple note's snippet
var extra_params = {} ;
var $sortable2 = $img.closest( ".sortable" ) ;
var note_type = _get_note_type_for_sortable( $sortable2 ) ;
var key ;
if ( note_type === "scenario_notes" )
key = "SCENARIO_NOTE" ;
else if ( note_type === "ob_setups" )
key = "OB_SETUP" ;
else if ( note_type == "ob_notes" )
key = "OB_NOTE" ;
var data = $img.parent().parent().data( "sortable2-data" ) ;
extra_params[key] = data.caption ;
extra_params[key+"_WIDTH"] = data.width ;
return extra_params ;
}
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
function _get_note_type_for_sortable( $sortable2 )

@ -22,29 +22,48 @@ var gLastSavedScenarioFilename = null;
function generate_snippet( $btn, extra_params )
{
// unload the template parameters
// generate the snippet
var snippet = make_snippet( $btn, extra_params, true ) ;
// copy the snippet to the clipboard
try {
copyToClipboard( snippet ) ;
}
catch( ex ) {
showErrorMsg( "Can't copy to the clipboard:<div class'pre'>" + escapeHTML(ex) + "</div>" ) ;
return ;
}
showInfoMsg( "The HTML snippet has been copied to the clipboard." ) ;
}
function make_snippet( $btn, extra_params, show_date_warnings )
{
// initialize
var template_id = $btn.data( "id" ) ;
var params = unload_snippet_params( true, template_id ) ;
// set player-specific parameters
var curr_tab = $("#tabs .ui-tabs-active a").attr( "href" ) ;
var colors ;
if ( curr_tab === "#tabs-ob1" ) {
params.PLAYER_NAME = get_nationality_display_name( params.PLAYER_1 ) ;
colors = get_player_colors( 1 ) ;
params.OB_COLOR = colors[0] ;
params.OB_COLOR_2 = colors[2] ;
if ( gUserSettings["include-flags-in-snippets"] )
params.PLAYER_FLAG = make_player_flag_url( get_player_nat( 1 ) ) ;
} else if ( curr_tab === "#tabs-ob2" ) {
params.PLAYER_NAME = get_nationality_display_name( params.PLAYER_2 ) ;
colors = get_player_colors( 2 ) ;
var player_no = get_player_no_for_element( $btn ) ;
if ( player_no ) {
params.PLAYER_NAME = get_nationality_display_name( params["PLAYER_"+player_no] ) ;
var colors = get_player_colors( player_no ) ;
params.OB_COLOR = colors[0] ;
params.OB_COLOR_2 = colors[2] ;
if ( gUserSettings["include-flags-in-snippets"] )
params.PLAYER_FLAG = make_player_flag_url( get_player_nat( 2 ) ) ;
params.PLAYER_FLAG = make_player_flag_url( get_player_nat( player_no ) ) ;
}
// set the snippet ID
var data ;
if ( template_id === "ob_setup" || template_id === "ob_note" ) {
data = $btn.parent().parent().data( "sortable2-data" ) ;
params.SNIPPET_ID = template_id + "_" + player_no + "." + data.id ;
} else if ( template_id === "scenario_note" ) {
data = $btn.parent().parent().data( "sortable2-data" ) ;
params.SNIPPET_ID = template_id + "." + data.id ;
} else
params.SNIPPET_ID = template_id ;
// set player-specific parameters
if ( template_id == "ob_vehicles_1" ) {
template_id = "ob_vehicles" ;
@ -129,14 +148,16 @@ function generate_snippet( $btn, extra_params )
}
// check for date-specific parameters
if ( template_id === "pf" && ! is_pf_available() )
showWarningMsg( "PF are only available after September 1943." ) ;
if ( template_id === "psk" && ! is_psk_available() )
showWarningMsg( "PSK are only available after September 1943." ) ;
if ( template_id === "baz" && ! is_baz_available() )
showWarningMsg( "BAZ are only available from November 1942." ) ;
if ( template_id === "atmm" && ! is_atmm_available() )
showWarningMsg( "ATMM are only available from 1944." ) ;
if ( show_date_warnings ) {
if ( template_id === "pf" && ! is_pf_available() )
showWarningMsg( "PF are only available after September 1943." ) ;
if ( template_id === "psk" && ! is_psk_available() )
showWarningMsg( "PSK are only available after September 1943." ) ;
if ( template_id === "baz" && ! is_baz_available() )
showWarningMsg( "BAZ are only available from November 1942." ) ;
if ( template_id === "atmm" && ! is_atmm_available() )
showWarningMsg( "ATMM are only available from 1944." ) ;
}
// add in any extra parameters
if ( extra_params )
@ -149,44 +170,38 @@ function generate_snippet( $btn, extra_params )
// get the template to generate the snippet from
var templ = get_template( template_id, true ) ;
if ( templ === null )
return ;
return "" ;
var func ;
try {
func = jinja.compile( templ ).render ;
}
catch( ex ) {
showErrorMsg( "Can't compile template:<div class='pre'>" + escapeHTML(ex) + "</div>" ) ;
return ;
return "[error: can't compile template]" ;
}
// process the template
var val ;
var snippet ;
try {
// NOTE: While it's generally not a good idea to disable auto-escaping, the whole purpose
// of this application is to generate HTML snippets, and so virtually every single
// template parameter would have to be piped through the "safe" filter :-/ We never render
// any of the generated HTML, so any risk exists only when the user pastes the HTML snippet
// into a VASL scenario, which uses an ancient HTML engine (with probably no Javascript)...
val = func( params, {
snippet = func( params, {
autoEscape: false,
filters: {
join: function(val,sep) { return val.join(sep) ; }
join: function(snippet,sep) { return snippet.join(sep) ; }
} ,
} ) ;
val = val.trim() ;
snippet = snippet.trim() ;
}
catch( ex ) {
showErrorMsg( "Can't process template: <span class='pre'>" + template_id + "</span><div class='pre'>" + escapeHTML(ex) + "</div>" ) ;
return ;
return "[error: can't process template'" ;
}
try {
copyToClipboard( val ) ;
}
catch( ex ) {
showErrorMsg( "Can't copy to the clipboard:<div class'pre'>" + escapeHTML(ex) + "</div>" ) ;
return ;
}
showInfoMsg( "The HTML snippet has been copied to the clipboard." ) ;
return snippet ;
}
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
@ -696,7 +711,7 @@ function do_load_scenario( data, fname )
{
// NOTE: We reset the scenario first, in case the loaded scenario is missing fields,
// so that those fields will be reset to their default values (instead of just staying unchanged).
do_on_new_scenario() ;
do_on_new_scenario( false ) ;
// load the scenario
try {
@ -715,6 +730,14 @@ function do_load_scenario_data( params )
// reset the scenario
reset_scenario() ;
// auto-assign ID's to the OB setup notes and notes
// NOTE: We do this here to handle scenarios that were created before these ID's were implemented.
auto_assign_ids( params.SCENARIO_NOTES ) ;
auto_assign_ids( params.OB_SETUPS_1 ) ;
auto_assign_ids( params.OB_NOTES_1 ) ;
auto_assign_ids( params.OB_SETUPS_2 ) ;
auto_assign_ids( params.OB_NOTES_2 ) ;
// load the scenario parameters
var params_loaded = {} ;
var warnings = [] ;
@ -860,6 +883,50 @@ function do_load_scenario_data( params )
on_scenario_date_change() ;
}
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
function auto_assign_ids( vals )
{
if ( ! vals )
return ;
// NOTE: These ID's are used to uniquely identify OB setup notes and OB notes, since they are generated
// from the same template ("ob_setup" and "ob_note") and so the template_id alone won't be enough. We need
// to be able to uniquely identify each snippet so that we can match them with labels in the VASL scenario.
// However, we need to be able to handle the following situation:
// - the scenario has, say, 5 OB notes, with ID's 1-5
// - the user deletes #3, and creates a new one
// If we track the highest ID ever used across the life of the scenario, the new snippet will be assigned ID #6,
// but when we inject the snippets into the VASL scenario, the label corresponding to snippet #3 will be left
// as it is, and a new label created for snippet #6, which is not what the user will want. Instead, we re-use
// ID 3 and give it to the new snippet, so that when we inject snippets, the old label corresponding to snippet #3
// will simply be updated with the contents of the new snippet #6.
// identify which ID's are currently in use
var usedIds = {} ;
for ( var i=0 ; i < vals.length ; ++i ) {
if ( vals[i].id )
usedIds[ vals[i].id ] = true ;
}
// assign ID's to entries that don't have one
for ( i=0 ; i < vals.length ; ++i ) {
if ( ! vals[i].id )
vals[i].id = auto_assign_id( usedIds ) ;
}
}
function auto_assign_id( usedIds )
{
// assign the next available ID
for ( var i=1 ; ; ++i ) {
if ( ! usedIds[i] ) {
usedIds[i] = true ;
return i ;
}
}
}
// --------------------------------------------------------------------
function on_save_scenario()
@ -959,7 +1026,7 @@ function on_new_scenario()
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
function do_on_new_scenario( verbose ) {
function do_on_new_scenario( user_requested ) {
// load the default scenario
if ( gDefaultScenario )
do_load_scenario_data( gDefaultScenario ) ;
@ -976,11 +1043,11 @@ function do_on_new_scenario( verbose ) {
// flag that we have a new scenario
gLastSavedScenarioFilename = null ;
if ( gWebChannelHandler )
if ( gWebChannelHandler && user_requested )
gWebChannelHandler.on_new_scenario() ;
// provide some feedback to the user
if ( verbose )
if ( user_requested )
showInfoMsg( "The scenario was reset." ) ;
}
@ -1218,7 +1285,10 @@ function on_scenario_date_change()
// (by SSR) even outside the normal time.
function update_ui( id, is_available ) {
var $btn = $( "button.generate[data-id='" + id + "']" ) ;
$btn.css( "color", is_available?"#000":"#aaa" ) ;
if ( is_available )
$btn.removeClass( "inactive" ) ;
else
$btn.addClass( "inactive" ) ;
$btn.children( "img" ).each( function() {
$(this).attr( "src", gImagesBaseUrl + (is_available?"/snippet.png":"/snippet-disabled.png") ) ;
} ) ;

@ -37,7 +37,7 @@ function make_player_flag_url( player_nat ) {
function get_player_no_for_element( $elem )
{
// get the player colors (if any) for the specified element
// get the player that owns the specified element
if ( $.contains( $("#tabs-ob1")[0], $elem[0] ) )
return 1 ;
if ( $.contains( $("#tabs-ob2")[0], $elem[0] ) )

@ -0,0 +1,291 @@
// --------------------------------------------------------------------
function on_update_vsav()
{
// FOR TESTING PORPOISES! We can't control a file upload from Selenium (since
// the browser will use native controls), so we get the data from a <textarea>).
if ( getUrlParam( "vsav_persistence" ) ) {
var $elem = $( "#_vsav-persistence_" ) ;
var vsav_data = $elem.val() ;
$elem.val( "" ) ; // nb: let the test suite know we've received the data
do_update_vsav( vsav_data, "test.vsav" ) ;
return ;
}
// if we are running inside the PyQt wrapper, let it handle everything
if ( gWebChannelHandler ) {
gWebChannelHandler.load_vsav( function( data ) {
if ( ! data )
return ;
do_update_vsav( data.data, data.filename ) ;
} ) ;
return ;
}
// ask the user to upload the VSAV file
$("#load-vsav").trigger( "click" ) ; // nb: this will call on_load_vsav_file_selected() when a file has been selected
}
function on_load_vsav_file_selected()
{
// read the selected file
var fileReader = new FileReader() ;
var file = $("#load-vsav").prop( "files" )[0] ;
fileReader.onload = function() {
vsav_data = fileReader.result ;
if ( vsav_data.substring(0,5) === "data:" )
vsav_data = vsav_data.split( "," )[1] ;
do_update_vsav( vsav_data, file.name ) ;
} ;
fileReader.readAsDataURL( file ) ;
}
function do_update_vsav( vsav_data, fname )
{
// show the progress dialog
var $dlg = $( "#update-vsav" ).dialog( {
dialogClass: "update-vsav",
modal: true,
width: 300,
height: 60,
resizable: false,
closeOnEscape: false,
} ) ;
// generate all the snippets
var snippets = _generate_snippets() ;
// send a request to update the VSAV
var data = { "filename": fname, vsav_data: vsav_data, snippets: snippets } ;
$.ajax( {
url: gUpdateVsavUrl,
type: "POST",
data: JSON.stringify( data ),
contentType: "application/json",
} ).done( function( data ) {
$dlg.dialog( "close" ) ;
data = JSON.parse( data ) ;
// check if there was an error
if ( data.error ) {
if ( getUrlParam( "vsav_persistence" ) ) {
$("#_vsav-persistence_").val(
"ERROR: " + data.error + "\n\n=== STDOUT ===\n" + data.stdout + "\n=== STDERR ===\n" + data.stderr
) ;
return ;
}
$("#vassal-shim-error").dialog( {
dialogClass: "vassal-shim-error",
title: "Scenario update error",
modal: true,
width: 600, height: "auto",
open: function() {
$( "#vassal-shim-error .message" ).html( data.error ) ;
var log = "" ;
if ( data.stdout && data.stderr )
log = "=== STDOUT ===" + data.stdout + "\n=== STDERR ===\n" + data.stderr ;
else if ( data.stdout )
log = data.stdout ;
else if ( data.stderr )
log = data.stderr ;
if ( log )
$( "#vassal-shim-error .log" ).val( log ).show() ;
else
$( "#vassal-shim-error .log" ).hide() ;
},
buttons: {
Close: function() { $(this).dialog( "close" ) ; },
},
} ) ;
return ;
}
// check if anything was changed
if ( ! data.report.was_modified ) {
showInfoMsg( "No changes were made to the VASL scenario." ) ;
if ( getUrlParam( "vsav_persistence" ) )
$("#_vsav-persistence_").val( btoa( "No changes." ) ) ;
return ;
}
// save the updated VSAV file
if ( gWebChannelHandler ) {
gWebChannelHandler.save_updated_vsav( data.filename, data.vsav_data, function( resp ) {
if ( resp )
_show_label_report_msg( data.report ) ;
} ) ;
return ;
}
_show_label_report_msg( data.report ) ;
if ( getUrlParam( "vsav_persistence" ) ) {
// FOR TESTING PORPOISES! We can't control a file download from Selenium (since
// the browser will use native controls), so we store the result in a <textarea>
// and the test suite will collect it from there).
$("#_vsav-persistence_").val( data.vsav_data ) ;
return ;
}
download( atob(data.vsav_data), data.filename, "application/octet-stream" ) ;
} ).fail( function( xhr, status, errorMsg ) {
$dlg.dialog( "close" ) ;
showErrorMsg( "Can't update the VASL scenario:<div class='pre'>" + escapeHTML(errorMsg) + "</div>" ) ;
} ) ;
}
function _show_label_report_msg( report )
{
// generate a message summarizing what the VASSAL shim did
var buf = [ "The VASL scenario was updated:", "<ul>" ] ;
var actions = [ "created", "updated", "deleted" ] ; // nb: we ignore "unchanged"
for ( var i=0 ; i < actions.length ; ++i ) {
var action = actions[i] ;
var n = parseInt( report[ "labels_"+action ] ) ;
if ( n == 1 )
buf.push( "<li>1 label was " + action + "." ) ;
else if ( n > 1 )
buf.push( "<li>" + n + " labels were " + action + "." ) ;
}
buf.push( "</ul>" ) ;
var msg = buf.join( "" ) ;
// show the message
if ( report.labels_deleted > 0 )
showWarningMsg( msg ) ;
else
showInfoMsg( msg ) ;
}
// --------------------------------------------------------------------
function _generate_snippets()
{
// initialize
var snippets = {} ;
// figure out which templates we don't want to auto-create labels for
var no_autocreate = {} ;
for ( var nat in NATIONALITY_SPECIFIC_BUTTONS ) {
for ( var i=0 ; i < NATIONALITY_SPECIFIC_BUTTONS[nat].length ; ++i ) {
var template_id = NATIONALITY_SPECIFIC_BUTTONS[nat][i] ;
if ( ["pf","atmm"].indexOf( template_id ) !== -1 ) {
// NOTE: PF and ATMM are always available as an inherent part of a squad's capabilities (subject to date restrictions),
// so we always auto-create these labels, unlike, say MOL or BAZ, which are only present by SSR or OB counter).
continue ;
}
no_autocreate[template_id] = true ;
}
}
function on_snippet_button( $btn, inactive ) {
var template_id = $btn.attr( "data-id" ) ;
if ( template_id.substr(0,7) === "extras/" ) {
// NOTE: We don't handle extras templates, since they can be parameterized. We would need to store
// the parameter values in the generated snippet, and extract them here so that we can re-generate
// the snippet, which is more trouble than it's worth, at this point.
return ;
}
var snippet_id = template_id ;
var extra_params = {} ;
var player_no = get_player_no_for_element( $btn ) ;
if ( ["scenario_note","ob_setup","ob_note"].indexOf( template_id ) !== -1 ) {
var data = $btn.parent().parent().data( "sortable2-data" ) ;
if ( player_no )
snippet_id = template_id + "_" + player_no + "." + data.id ;
else
snippet_id = template_id + "." + data.id ;
extra_params = get_simple_note_snippet_extra_params( $btn ) ;
}
var raw_content = _get_raw_content( snippet_id, $btn ) ;
if ( ["scenario","players","victory_conditions"].indexOf( snippet_id ) === -1 ) {
// NOTE: We don't pass through a snippet for things that have no content,
// except for important stuff, such as the scenario name and victory conditions.
if ( raw_content === null || raw_content.length === 0 ) {
return ;
}
}
snippets[snippet_id] = {
content: make_snippet( $btn, extra_params, false ),
auto_create: ! no_autocreate[template_id] && ! inactive,
raw_content: raw_content,
} ;
if ( player_no )
snippets[snippet_id].label_area = "player" + player_no ;
}
$("button.generate").each( function() {
if ( $(this).parent().css( "display" ) === "none" )
return ;
on_snippet_button( $(this), $(this).hasClass("inactive") ) ;
} ) ;
$("img.snippet").each( function() {
on_snippet_button( $(this) ) ;
} ) ;
return snippets ;
}
function _get_raw_content( snippet_id, $btn )
{
// NOTE: We pass the raw content, as entered by the user into the UI, through to the VASSAL shim,
// so that it can locate legacy labels, that were created before we added snippet ID's to the templates.
var raw_content = [] ;
function get_values( names ) {
for ( var i=0 ; i < names.length ; ++i ) {
var val = $( ".param[name='" + names[i] + "']" ).val().trim() ;
if ( val )
raw_content.push( val ) ;
}
return raw_content ;
}
// handle special cases
if ( snippet_id === "scenario" )
return get_values([ "SCENARIO_NAME", "SCENARIO_ID", "SCENARIO_LOCATION" ]) ;
if ( snippet_id === "victory_conditions" )
return get_values([ "VICTORY_CONDITIONS" ]) ;
if ( snippet_id === "players" ) {
return [
"ELR:", "SAN:",
get_nationality_display_name( get_player_nat( 1 ) ) + ":",
get_nationality_display_name( get_player_nat( 2 ) ) + ":",
] ;
}
if ( snippet_id === "ssr" ) {
$( "#ssr-sortable > li" ).each( function() {
var data = $(this).data( "sortable2-data" ) ;
raw_content.push( data.caption ) ;
} ) ;
return raw_content ;
}
// handle simple cases
if ( snippet_id === "mol" )
return [ "Molotov Cocktail", "MOL check:", "IFT DR original colored dr:" ] ;
if ( snippet_id === "mol-p" )
return [ "MOL Projector", "TH#", "X#", "B#" ] ;
if ( snippet_id === "pf" )
return [ "Panzerfaust", "PF check:", "non-AFV target", "TH#" ] ;
if ( snippet_id === "psk" )
return [ "Panzerschrek", "Range", "TH#", "X#", "TK#" ] ;
if ( snippet_id === "atmm" )
return [ "Anti-Tank Magnetic Mines", "ATMM check:", "vs. non-armored vehicle" ] ;
if ( snippet_id === "piat" )
return [ "PIAT", "Range", "TH#", "B#", "TK#" ] ;
if ( snippet_id === "baz" )
return [ "Bazooka", "Range", "TH#" ] ;
// handle simple notes
if ( $btn.prop( "tagName" ).toLowerCase() == "img" ) {
var data = $btn.parent().parent().data( "sortable2-data" ) ;
return [ data.caption ] ;
}
// handle vehicles/ordnance
if ( snippet_id.substring(0,11) === "ob_vehicles" || snippet_id.substring(0,11) === "ob_ordnance" ) {
var id = snippet_id.substring(0,11) + "-sortable" + snippet_id.substring(11) ;
$( "#"+id + " > li" ).each( function() {
var vo_entry = $(this).data( "sortable2-data" ).vo_entry ;
raw_content.push( vo_entry.name ) ;
} ) ;
return raw_content ;
}
return null ;
}

@ -22,6 +22,7 @@
<link rel="stylesheet" type="text/css" href="{{url_for('static',filename='css/ask-dialog.css')}}" />
<link rel="stylesheet" type="text/css" href="{{url_for('static',filename='css/select-vo-dialog.css')}}" />
<link rel="stylesheet" type="text/css" href="{{url_for('static',filename='css/edit-vo-dialog.css')}}" />
<link rel="stylesheet" type="text/css" href="{{url_for('static',filename='css/vassal.css')}}" />
<link rel="stylesheet" type="text/css" href="{{url_for('static',filename='css/user-settings-dialog.css')}}" />
</head>
@ -42,6 +43,7 @@
<input type="image" src="{{url_for('static',filename='images/menu.png')}}" value="actions">
<input id="load-scenario" type="file" accept=".json" style="display:none;">
<input id="load-template-pack" type="file" accept=".zip,.j2" style="display:none;">
<input id="load-vsav" type="file" accept=".vsav" style="display:none;">
</div>
{%include "tabs.html"%}
@ -61,6 +63,8 @@
{%include "select-vo-image-dialog.html"%}
{%include "edit-vo-dialog.html"%}
{%include "vassal.html"%}
{%include "user-settings-dialog.html"%}
{%include "ask-dialog.html"%}
@ -89,6 +93,7 @@ gGetDefaultScenarioUrl = "{{url_for('get_default_scenario')}}" ;
gVehicleListingsUrl = "{{url_for('get_vehicle_listings',merge_common=1)}}" ;
gOrdnanceListingsUrl = "{{url_for('get_ordnance_listings',merge_common=1)}}" ;
gGetVaslPieceInfoUrl = "{{url_for('get_vasl_piece_info')}}" ;
gUpdateVsavUrl = "{{url_for('update_vsav')}}" ;
gHelpUrl = "{{url_for('show_help')}}" ;
</script>
@ -98,6 +103,7 @@ gHelpUrl = "{{url_for('show_help')}}" ;
<script src="{{url_for('static',filename='simple_notes.js')}}"></script>
<script src="{{url_for('static',filename='vo.js')}}"></script>
<script src="{{url_for('static',filename='vo2.js')}}"></script>
<script src="{{url_for('static',filename='vassal.js')}}"></script>
<script src="{{url_for('static',filename='sortable.js')}}"></script>
<script src="{{url_for('static',filename='user_settings.js')}}"></script>
<script src="{{url_for('static',filename='utils.js')}}"></script>

@ -5,3 +5,4 @@
<textarea id="_clipboard_" style="display:none;"></textarea>
<textarea id="_template-pack-persistence_" style="display:none;"></textarea>
<textarea id="_scenario-persistence_" style="display:none;"></textarea>
<textarea id="_vsav-persistence_" style="display:none;"></textarea>

@ -0,0 +1,9 @@
<div id="update-vsav" style="display:none;">
<img src="{{url_for('static',filename='images/loader.gif')}}">
Updating your VASL scenario...
</div>
<div id="vassal-shim-error" style="display:none;">
<div class="message"></div>
<textarea class="log"></textarea>
</div>

@ -0,0 +1,57 @@
SetupCommand: starting=false
AddPiece: DynamicProperty/81* MTR GrW 34 ca3
AddPiece: DynamicProperty/3-3-8 Ehs
AddPiece: DynamicProperty/PzKw VG vca3 tca3 BU
AddPiece: DynamicProperty/HMG
AddPiece: Stack
AddPiece: DynamicProperty/User-Labeled
- test<09>global
- null
- <empty>
- Label
- Line 2
- Map0;84;76;6292
AddPiece: Stack
AddPiece: DynamicProperty/User-Labeled
- test<09>global
- null
- <empty>
- Label
- no background
- Map0;231;79;6293
AddPiece: Stack
AddPiece: DynamicProperty/User-Labeled
- test<09>global
- null
- <empty>
- Label
- Line 2
- Map0;366;79;6294
AddPiece: Stack
AddPiece: DynamicProperty/User-Labeled
- test<09>global
- null
- <empty>
- Label
- no background
- Map0;498;82;6295
AddPiece: DynamicProperty/User-Labeled
- test<09>global
- null
- <empty>
- Label
- Line 2
- Map0;648;85;6296
AddPiece: DynamicProperty/User-Labeled
- test<09>global
- null
- <empty>
- Label
- no background
- Map0;777;90;6297
SetInfo
SetAllowed: []
SetBoards
SetScenarioNote
SetPublicNote
SetupCommand: starting=true

@ -0,0 +1 @@
{"SCENARIO_NAME":"test scenario","SCENARIO_ID":"TEST-01","SCENARIO_LOCATION":"Somewhere","SCENARIO_DATE":"2001-02-03","SCENARIO_WIDTH":"123","VICTORY_CONDITIONS_WIDTH":"300px","SSR_WIDTH":"300px","OB_VEHICLES_WIDTH_1":"","OB_ORDNANCE_WIDTH_1":"","OB_VEHICLES_WIDTH_2":"","OB_ORDNANCE_WIDTH_2":"","VICTORY_CONDITIONS":"Make the other guy die for his country!","SCENARIO_THEATER":"Burma","PLAYER_1":"american","PLAYER_1_ELR":"1","PLAYER_1_SAN":"2","PLAYER_2":"belgian","PLAYER_2_ELR":"3","PLAYER_2_SAN":"4","SSR":["SSR #1","SSR #2","SSR #3"],"OB_VEHICLES_1":[{"id":"am/v:008","name":"M4A1"},{"id":"am/v:021","name":"Sherman Crab"}],"OB_VEHICLES_2":[{"id":"alc/v:006","name":"R-35(f)"},{"id":"alc/v:012","name":"Medium Truck"}],"OB_ORDNANCE_1":[{"id":"am/o:002","name":"M1 81mm Mortar"}],"OB_ORDNANCE_2":[{"id":"alc/o:006","name":"Bofors M34"},{"id":"be/o:000","name":"DBT"}],"SCENARIO_NOTES":[{"caption":"scenario note #1","width":"200px","id":1}],"OB_SETUPS_1":[{"caption":"U.S. setup #1","id":1,"width":"111"},{"caption":"U.S. setup #2","id":2,"width":""},{"caption":"U.S. setup #3","id":3,"width":""}],"OB_SETUPS_2":[{"caption":"Belgian setup #1","id":1,"width":""},{"caption":"Belgian setup #2","id":2,"width":"222"},{"caption":"Belgian setup #3","id":3,"width":""}],"OB_NOTES_1":[{"caption":"U.S. note #1","id":1,"width":"111"},{"caption":"U.S. note #2","id":2,"width":""}],"OB_NOTES_2":[{"caption":"Belgian note #1","id":1,"width":""},{"caption":"Belgian note #2","id":2,"width":"222"}]}

@ -0,0 +1 @@
{"SCENARIO_NAME":"Hill 621","SCENARIO_ID":"ASL E","SCENARIO_LOCATION":"Near Minsk, Russia","SCENARIO_DATE":"1944-06-29","SCENARIO_WIDTH":"","VICTORY_CONDITIONS_WIDTH":"240px","SSR_WIDTH":"300px","OB_VEHICLES_WIDTH_1":"","OB_ORDNANCE_WIDTH_1":"","OB_VEHICLES_WIDTH_2":"","OB_ORDNANCE_WIDTH_2":"","VICTORY_CONDITIONS":"The Russians win at Game End if they Control &ge; five Level 3 hill hexes on Board 2.","PLAYER_1":"russian","PLAYER_1_ELR":"4","PLAYER_1_SAN":"3","PLAYER_2":"german","PLAYER_2_ELR":"3","PLAYER_2_SAN":"4","SSR":["EC are Moderate, with no wind at start.","After \"At Start\" placement, each German infantry unit must take a TC. The only possible consequence of failure is that the unit must begin the scenario broken. Those units which break during this pre-game TC are not subject to DM in the initial German RPh.","The Germans receive one module of 80+mm Battalion Mortar OBA (HE and Smoke) with the radio in the initial OB.","The Germans receive one module of 100+mm OBA (HE and Smoke) with the Turn 4 reinforcements."],"OB_VEHICLES_1":[{"name":"T-34 M43"},{"name":"SU-152"},{"name":"SU-122"},{"name":"ZIS-5"}],"OB_VEHICLES_2":[{"name":"PzKpfw IVH"},{"name":"PzKpfw IIIN"},{"name":"StuG IIIG (L)"},{"name":"StuH 42"},{"name":"SPW 250/1"},{"name":"SPW 251/1"},{"name":"SPW 251/sMG"}],"OB_ORDNANCE_2":[{"name":"7.5cm PaK 40"},{"name":"5cm PaK 38"}],"SCENARIO_NOTES":[{"caption":"Download the scenario card from <a href=\"http://www.multimanpublishing.com/Support/ASLASLSK/ASLOfficialDownloads/tabid/109/Default.aspx\">Multi-Man Publishing</a> (ASL Classic pack).","width":"300px"}],"OB_SETUPS_1":[{"caption":"Set up on any whole hex of Board 3","width":""},{"caption":"Enter on Turn 2 on any single road hex <br>\non the east edge of Board 3","width":""},{"caption":"Enter on Turn 5 on any single road hex <br>\non the east edge of Board 3","width":""}],"OB_SETUPS_2":[{"caption":"Set up in any whole hex of Board 4","width":""},{"caption":"Enter on Turn 1 on any single road hex <br>\non any edge of Board 2","width":""},{"caption":"Enter on Turn 2 on any single road hex <br>\non the north <i>or</i> south edge of Board 4","width":""},{"caption":"Enter on Turn 4 on any single road hex <br>\non the west edge of Board 2","width":""},{"caption":"Enter on Turn 5 on any single road hex <br>\nalong the north, south or west edge of Board 2","width":""},{"caption":"Enter on Turn 8 along <br>\nthe west edge of Board 2","width":""}],"OB_NOTES_1":[],"OB_NOTES_2":[{"caption":"80+mm Battalion Mortar <br> OBA (HE/Smoke)","width":""},{"caption":"100+mm OBA (HE/Smoke)","width":""}]}

@ -8,7 +8,8 @@ from selenium.webdriver.common.keys import Keys
from vasl_templates.webapp.tests.utils import \
init_webapp, select_menu_option, select_tab, click_dialog_button, \
load_vasl_mod, find_child, find_children, wait_for_clipboard
load_vasl_mod, find_child, find_children, wait_for_clipboard, \
set_scenario_date
from vasl_templates.webapp.tests.test_vo_reports import get_vo_report
from vasl_templates.webapp.tests.test_vehicles_ordnance import add_vo
from vasl_templates.webapp.tests.test_scenario_persistence import save_scenario, load_scenario
@ -583,11 +584,7 @@ def test_capability_updates_in_ui( webapp, webdriver, monkeypatch ):
def check_capabilities( scenario_date, expected ):
"""Get the vehicle/ordnance capabilities from the UI."""
# set the scenario date
if scenario_date:
elem = find_child( "input[name='SCENARIO_DATE']" )
elem.clear()
elem.send_keys( scenario_date )
elem.send_keys( Keys.TAB )
set_scenario_date( scenario_date )
# check the vehicle/ordnance capabilities
results = []
for sortable in sortables:

@ -20,7 +20,7 @@ from vasl_templates.webapp.tests.test_scenario_persistence import load_scenario
@pytest.mark.skipif(
not pytest.config.option.vasl_mods, #pylint: disable=no-member
reason = "--vasl-mods-tests not specified"
reason = "--vasl-mods not specified"
)
@pytest.mark.skipif(
pytest.config.option.short_tests, #pylint: disable=no-member
@ -94,7 +94,7 @@ def _dump_pieces( vasl_mod, out ):
@pytest.mark.skipif(
not pytest.config.option.vasl_mods, #pylint: disable=no-member
reason = "--vasl-mods-tests not specified"
reason = "--vasl-mods not specified"
)
def test_gpid_remapping( webapp, webdriver, monkeypatch ):
"""Test GPID remapping."""

@ -9,7 +9,7 @@ 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, \
select_droplist_val, init_webapp, wait_for, adjust_html
select_droplist_val, init_webapp, wait_for, adjust_html, set_scenario_date
# ---------------------------------------------------------------------
@ -96,19 +96,18 @@ def test_nationality_specific( webapp, webdriver ): #pylint: disable=too-many-lo
init_webapp( webapp, webdriver )
nationalities = get_nationalities( webapp )
# initialize
scenario_date = find_child( "#panel-scenario input[name='SCENARIO_DATE']" )
def set_scenario_date( date ):
"""Set the scenario date."""
select_tab( "scenario" )
scenario_date.clear()
scenario_date.send_keys( "{:02}/01/{:04}".format( date[1], date[0] ) )
def do_check_snippets( btn, date, expected, warning ):
"""Check that snippets are being generated correctly."""
# test snippet generation
set_scenario_date( date )
# change the scenario date, check that the button is displayed correctly
set_scenario_date( "{:02}/01/{:04}".format( date[1], date[0] ) )
select_tab( "ob1" )
classes = btn.get_attribute( "class" )
classes = classes.split() if classes else []
if warning:
assert "inactive" in classes
else:
assert "inactive" not in classes
# test snippet generation
marker = set_stored_msg_marker( "_last-warning_" )
btn.click()
wait_for_clipboard( 2, expected )

@ -38,7 +38,7 @@ ALL_SCENARIO_PARAMS = {
# ---------------------------------------------------------------------
def test_scenario_persistence( webapp, webdriver ): #pylint: disable=too-many-statements,too-many-locals
def test_scenario_persistence( webapp, webdriver ): #pylint: disable=too-many-statements,too-many-locals,too-many-branches
"""Test loading/saving scenarios."""
# initialize
@ -104,11 +104,7 @@ def test_scenario_persistence( webapp, webdriver ): #pylint: disable=too-many-st
load_scenario_params( SCENARIO_PARAMS )
check_window_title( "my test scenario" )
check_ob_tabs( "russian", "german" )
# make sure that our test scenario includes everything
lhs = { k: set(v) for k,v in SCENARIO_PARAMS.items() }
rhs = { k: set(v) for k,v in ALL_SCENARIO_PARAMS.items() }
assert lhs == rhs
assert_scenario_params_complete( SCENARIO_PARAMS )
# save the scenario and check the results
saved_scenario = save_scenario()
@ -124,6 +120,13 @@ def test_scenario_persistence( webapp, webdriver ): #pylint: disable=too-many-st
for key in expected:
if re.search( r"^OB_(VEHICLES|ORDNANCE)_\d$", key ):
expected[key] = [ { "name": name } for name in expected[key] ]
for player_no in (1,2):
for vo_type in ("OB_SETUPS","OB_NOTES"):
entries = expected[ "{}_{}".format( vo_type, player_no ) ]
for i,entry in enumerate(entries):
entry["id"] = 1+i
for i,entry in enumerate(expected["SCENARIO_NOTES"]):
entry["id"] = 1+i
assert saved_scenario == expected
# make sure that our list of scenario parameters is correct
@ -190,6 +193,12 @@ def test_scenario_persistence( webapp, webdriver ): #pylint: disable=too-many-st
assert get_sortable_vo_names(vehicles2) == SCENARIO_PARAMS["ob2"]["OB_VEHICLES_2"]
assert get_sortable_vo_names(ordnance2) == SCENARIO_PARAMS["ob2"]["OB_ORDNANCE_2"]
def assert_scenario_params_complete( scenario_params ):
"""Check that a set of scenario parameters is complete."""
lhs = { k: set(v) for k,v in scenario_params.items() }
rhs = { k: set(v) for k,v in ALL_SCENARIO_PARAMS.items() }
assert lhs == rhs
# ---------------------------------------------------------------------
def test_loading_ssrs( webapp, webdriver ):

@ -2,11 +2,67 @@
from selenium.webdriver.common.keys import Keys
from vasl_templates.webapp.config.constants import DATA_DIR as REAL_DATA_DIR
from vasl_templates.webapp.tests.utils import \
init_webapp, select_tab, set_template_params, wait_for_clipboard, \
get_stored_msg, set_stored_msg_marker, find_child, adjust_html, \
init_webapp, select_tab, select_tab_for_elem, set_template_params, wait_for_clipboard, \
get_stored_msg, set_stored_msg_marker, find_child, find_children, adjust_html, \
for_each_template, add_simple_note, edit_simple_note, \
get_sortable_entry_count, generate_sortable_entry_snippet, drag_sortable_entry_to_trash
get_sortable_entry_count, generate_sortable_entry_snippet, drag_sortable_entry_to_trash, \
new_scenario, set_scenario_date
from vasl_templates.webapp.tests.test_scenario_persistence import load_scenario, load_scenario_params
# ---------------------------------------------------------------------
def test_snippet_ids( webapp, webdriver, monkeypatch ):
"""Check that snippet ID's are generated correctly."""
# initialize
monkeypatch.setitem( webapp.config, "DATA_DIR", REAL_DATA_DIR )
init_webapp( webapp, webdriver, scenario_persistence=1 )
# load a scenario (so that we get some sortable's)
scenario_data = {
"SCENARIO_NOTES": [ { "caption": "Scenario note #1" } ],
"OB_SETUPS_1": [ { "caption": "OB setup note #1" } ],
"OB_NOTES_1": [ { "caption": "OB note #1" } ],
"OB_SETUPS_2": [ { "caption": "OB setup note #2" } ],
"OB_NOTES_2": [ { "caption": "OB note #2" } ],
}
load_scenario( scenario_data )
def check_snippet( btn ):
"""Generate a snippet and check that it has an ID."""
select_tab_for_elem( btn )
if not btn.is_displayed():
# FUDGE! All nationality-specific buttons are created on each OB tab, and the ones not relevant
# to each player are just hidden. This is not real good since we have multiple elements with the same ID :-/
# but we work around this by checking if the button is visible. Sigh...
return
btn.click()
wait_for_clipboard( 2, "<!-- vasl-templates:id ", contains=True )
def do_test( scenario_date ):
"""Check each generated snippet has an ID."""
# configure the scenario
set_scenario_date( scenario_date )
# check each snippet
for btn in find_children( "button.generate" ):
check_snippet( btn )
for btn in find_children( "img.snippet" ):
check_snippet( btn )
# test snippets with German/Russian
do_test( "" )
do_test( "10/01/1943" )
do_test( "01/01/1944" )
# test snippets with British/American
new_scenario()
load_scenario_params( { "scenario": { "PLAYER_1": "british", "PLAYER_2": "american" } } )
do_test( "" )
do_test( "11/01/1942" )
# ---------------------------------------------------------------------

@ -0,0 +1,574 @@
""" Test VASSAL integration. """
import os
import glob
import re
import json
import base64
import random
import typing.re #pylint: disable=import-error
import pytest
from vasl_templates.webapp.config.constants import DATA_DIR as REAL_DATA_DIR
from vasl_templates.webapp.vassal import VassalShim
from vasl_templates.webapp.utils import TempFile, change_extn
from vasl_templates.webapp.tests.utils import \
init_webapp, select_menu_option, get_stored_msg, set_stored_msg, set_stored_msg_marker, wait_for
from vasl_templates.webapp.tests.test_scenario_persistence import load_scenario, load_scenario_params, \
assert_scenario_params_complete
# ---------------------------------------------------------------------
@pytest.mark.skipif( not pytest.config.option.vasl_mods, reason="--vasl-mods not specified" ) #pylint: disable=no-member
@pytest.mark.skipif( not pytest.config.option.vassal, reason="--vassal not specified" ) #pylint: disable=no-member
@pytest.mark.skipif( pytest.config.option.short_tests, reason="--short-tests specified" ) #pylint: disable=no-member
def test_full_update( webapp, webdriver, monkeypatch ):
"""Test updating a scenario that contains the full set of snippets."""
# initialize
monkeypatch.setitem( webapp.config, "DATA_DIR", REAL_DATA_DIR )
init_webapp( webapp, webdriver, vsav_persistence=1 )
# NOTE: We disable this for speed, since we don't care about label positioning.
monkeypatch.setitem( webapp.config, "DISABLE_UPDATE_VSAV_SCREENSHOTS", True )
# load the scenario fields
SCENARIO_PARAMS = {
"scenario": {
"SCENARIO_NAME": "Modified scenario name (<>{}\"'\\)",
"SCENARIO_ID": "xyz123",
"SCENARIO_LOCATION": "Right here",
"SCENARIO_THEATER": "PTO",
"SCENARIO_DATE": "12/31/1945",
"SCENARIO_WIDTH": "101",
"PLAYER_1": "russian", "PLAYER_1_ELR": "5", "PLAYER_1_SAN": "4",
"PLAYER_2": "german", "PLAYER_2_ELR": "3", "PLAYER_2_SAN": "2",
"VICTORY_CONDITIONS": "Just do it!", "VICTORY_CONDITIONS_WIDTH": "102",
"SCENARIO_NOTES": [
{ "caption": "Modified scenario note #1", "width": "" },
{ "caption": "Modified scenario note #2", "width": "100px" }
],
"SSR": [ "Modified SSR #1", "Modified SSR #2" ],
"SSR_WIDTH": "103",
},
"ob1": {
"OB_SETUPS_1": [
{ "caption": "Modified Russian setup #1", "width": "" },
{ "caption": "Modified Russian setup #2", "width": "200px" },
{ "caption": "Modified Russian setup #3", "width": "" },
{ "caption": "Modified Russian setup #4", "width": "" },
{ "caption": "Modified Russian setup #5", "width": "" },
],
"OB_NOTES_1": [
{ "caption": "Modified Russian note #1", "width": "10em" },
],
"OB_VEHICLES_1": [ "T-34/85 (MT)" ],
"OB_VEHICLES_WIDTH_1": "202",
"OB_ORDNANCE_1": [ "82mm BM obr. 37 (MTR)" ],
"OB_ORDNANCE_WIDTH_1": "203",
},
"ob2": {
"OB_SETUPS_2": [ { "caption": "Modified German setup #1", "width": "" } ],
"OB_NOTES_2": [
{ "caption": "Modified German note #1", "width": "" },
{ "caption": "Modified German note #2", "width": "" },
{ "caption": "Modified German note #3", "width": "" },
{ "caption": "Modified German note #4", "width": "" },
{ "caption": "Modified German note #5", "width": "" },
],
"OB_VEHICLES_2": [ "PzKpfw VG (MT)" ],
"OB_VEHICLES_WIDTH_2": "302",
"OB_ORDNANCE_2": [ "3.7cm PaK 35/36 (AT)" ],
"OB_ORDNANCE_WIDTH_2": "303",
},
}
load_scenario_params( SCENARIO_PARAMS )
assert_scenario_params_complete( SCENARIO_PARAMS )
def do_test(): #pylint: disable=missing-docstring
# dump the original VASL scenario
# NOTE: We could arguably only do this once, but updating scenarios is the key functionality of the VASSAL shim,
# and so it's worth checking that every VASSAL+VASL combination understands its input correctly.
fname = os.path.join( os.path.split(__file__)[0], "fixtures/update-vsav/full.vsav" )
vassal_shim = VassalShim()
vsav_dump = vassal_shim.dump_scenario( fname )
_check_vsav_dump( vsav_dump, {
"scenario": "Somewhere",
"players": re.compile( r"American:.*Belgian:" ),
"victory_conditions": "Make the other guy",
"ssr": re.compile( r"SSR #1.*SSR #2.*SSR #3" ),
"scenario_note.1": "scenario note #1",
"ob_setup_1.1": "U.S. setup #1", "ob_setup_1.2": "U.S. setup #2", "ob_setup_1.3": "U.S. setup #3",
"ob_note_1.1": "U.S. note #1", "ob_note_1.2": "U.S. note #2",
"ob_vehicles_1": re.compile( r"M4A1.*Sherman Crab" ),
"ob_ordnance_1": "M1 81mm Mortar",
"baz": "Bazooka",
"ob_setup_2.1": "Belgian setup #1", "ob_setup_2.2": "Belgian setup #2", "ob_setup_2.3": "Belgian setup #3",
"ob_note_2.1": "Belgian note #1", "ob_note_2.2": "Belgian note #2",
"ob_vehicles_2": re.compile( r"R-35\(f\).*Medium Truck" ),
"ob_ordnance_2": re.compile( r"Bofors M34.*DBT" ),
} )
# update the VASL scenario with the new snippets
updated_vsav_data = _update_vsav( fname, { "created": 8, "updated": 16, "deleted": 4 } )
with TempFile() as temp_file:
# check the results
temp_file.write( updated_vsav_data )
temp_file.close()
updated_vsav_dump = vassal_shim.dump_scenario( temp_file.name )
_check_vsav_dump( updated_vsav_dump, {
"scenario": "Modified scenario name (<>{}\"'\\)",
"players": re.compile( r"Russian:.*German:" ),
"victory_conditions": "Just do it!",
"ssr": re.compile( r"Modified SSR #1.*Modified SSR #2" ),
"scenario_note.1": "Modified scenario note #1",
"scenario_note.2": "Modified scenario note #2",
"ob_setup_1.1": "Modified Russian setup #1", "ob_setup_1.2": "Modified Russian setup #2",
"ob_setup_1.3": "Modified Russian setup #3", "ob_setup_1.4": "Modified Russian setup #4",
"ob_setup_1.5": "Modified Russian setup #5",
"ob_note_1.1": "Modified Russian note #1",
"ob_vehicles_1": "T-34/85",
"ob_ordnance_1": "82mm BM obr. 37",
"ob_setup_2.1": "Modified German setup #1",
"ob_note_2.1": "Modified German note #1", "ob_note_2.2": "Modified German note #2",
"ob_note_2.3": "Modified German note #3", "ob_note_2.4": "Modified German note #4",
"ob_note_2.5": "Modified German note #5",
"pf": "Panzerfaust", "atmm": "Anti-Tank Magnetic Mines",
"ob_vehicles_2": "PzKpfw VG",
"ob_ordnance_2": "3.7cm PaK 35/36",
} )
# update the VASL scenario again (nothing should change)
updated_vsav_data = _update_vsav( temp_file.name, {} )
assert updated_vsav_data == b"No changes."
# run the test against all versions of VASSAL+VASL
_run_tests( webapp, monkeypatch, do_test, True )
# ---------------------------------------------------------------------
@pytest.mark.skipif( not pytest.config.option.vasl_mods, reason="--vasl-mods not specified" ) #pylint: disable=no-member
@pytest.mark.skipif( not pytest.config.option.vassal, reason="--vassal not specified" ) #pylint: disable=no-member
@pytest.mark.skipif( pytest.config.option.short_tests, reason="--short-tests specified" ) #pylint: disable=no-member
def test_latw_autocreate( webapp, webdriver, monkeypatch ):
"""Test auto-creation of LATW labels."""
# initialize
monkeypatch.setitem( webapp.config, "DATA_DIR", REAL_DATA_DIR )
init_webapp( webapp, webdriver, vsav_persistence=1 )
# NOTE: We disable this for speed, since we don't care about label positioning.
monkeypatch.setitem( webapp.config, "DISABLE_UPDATE_VSAV_SCREENSHOTS", True )
# NOTE: We're only interested in what happens with the LATW labels, we ignore everything else.
ignore_labels = [ "scenario", "players", "victory_conditions" ]
def do_test(): #pylint: disable=missing-docstring
# check the VASL scenario
fname = os.path.join( os.path.split(__file__)[0], "fixtures/update-vsav/empty.vsav" )
vassal_shim = VassalShim()
vsav_dump = vassal_shim.dump_scenario( fname )
_check_vsav_dump( vsav_dump, {}, ignore_labels )
# update the scenario (German/Russian, no date)
load_scenario_params( { "scenario": { "PLAYER_1": "german", "PLAYER_2": "russian", "SCENARIO_DATE": "" } } )
updated_vsav_dump = _update_vsav_and_dump( fname, { "created": 3 } )
_check_vsav_dump( updated_vsav_dump, {
# nb: no LATW labels should have been created
}, ignore_labels )
# update the scenario (German/Russian, OCT/43)
load_scenario_params( {
"scenario": { "PLAYER_1": "german", "PLAYER_2": "russian", "SCENARIO_DATE": "10/01/1943" }
} )
updated_vsav_dump = _update_vsav_and_dump( fname, { "created": 4 } )
_check_vsav_dump( updated_vsav_dump, {
"pf": "Panzerfaust",
}, ignore_labels )
# update the scenario (German/Russian, JAN/44)
load_scenario_params( {
"scenario": { "PLAYER_1": "german", "PLAYER_2": "russian", "SCENARIO_DATE": "01/01/1944" }
} )
updated_vsav_dump = _update_vsav_and_dump( fname, { "created": 5 } )
_check_vsav_dump( updated_vsav_dump, {
"pf": "Panzerfaust", "atmm": "ATMM check:",
}, ignore_labels )
# update the scenario (British/American, no date)
load_scenario_params( { "scenario": { "PLAYER_1": "british", "PLAYER_2": "american", "SCENARIO_DATE": "" } } )
updated_vsav_dump = _update_vsav_and_dump( fname, { "created": 3 } )
_check_vsav_dump( updated_vsav_dump, {
# nb: no LATW labels should have been created
}, ignore_labels )
# update the scenario (British/American, DEC/45)
load_scenario_params( {
"scenario": { "PLAYER_1": "british", "PLAYER_2": "american", "SCENARIO_DATE": "12/31/1945" }
} )
updated_vsav_dump = _update_vsav_and_dump( fname, { "created": 3 } )
_check_vsav_dump( updated_vsav_dump, {
# nb: no LATW labels should have been created
}, ignore_labels )
# run the test
# NOTE: We're testing the logic in the front/back-ends that determine whether LATW labels
# get created/updated/deleted, not the interaction with VASSAL, so we don't need to test
# against every VASSAL+VASL combination (although we can, if we want, but it'll be slow!)
_run_tests( webapp, monkeypatch, do_test, False )
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
@pytest.mark.skipif( not pytest.config.option.vasl_mods, reason="--vasl-mods not specified" ) #pylint: disable=no-member
@pytest.mark.skipif( not pytest.config.option.vassal, reason="--vassal not specified" ) #pylint: disable=no-member
@pytest.mark.skipif( pytest.config.option.short_tests, reason="--short-tests specified" ) #pylint: disable=no-member
def test_latw_update( webapp, webdriver, monkeypatch ):
"""Test updating of LATW labels."""
# initialize
monkeypatch.setitem( webapp.config, "DATA_DIR", REAL_DATA_DIR )
init_webapp( webapp, webdriver, vsav_persistence=1 )
# NOTE: We disable this for speed, since we don't care about label positioning.
monkeypatch.setitem( webapp.config, "DISABLE_UPDATE_VSAV_SCREENSHOTS", True )
# NOTE: We're only interested in what happens with the LATW labels, we ignore everything else.
ignore_labels = [ "scenario", "players", "victory_conditions" ]
def do_test(): #pylint: disable=missing-docstring
# check the VASL scenario
fname = os.path.join( os.path.split(__file__)[0], "fixtures/update-vsav/latw.vsav" )
vassal_shim = VassalShim()
vsav_dump = vassal_shim.dump_scenario( fname )
_check_vsav_dump( vsav_dump, {
"psk": "Panzerschrek", "atmm": "ATMM check:", # nb: the PF label has no snippet ID
"mol-p": "TH#", # nb: the MOL label has no snippet ID
"piat": "TH#",
"baz": "Bazooka",
}, ignore_labels )
# update the scenario (German/Russian, no date)
load_scenario_params( { "scenario": { "PLAYER_1": "german", "PLAYER_2": "russian", "SCENARIO_DATE": "" } } )
updated_vsav_dump = _update_vsav_and_dump( fname, { "created": 3, "updated": 2, "deleted": 2 } )
_check_vsav_dump( updated_vsav_dump, {
"pf": "Panzerfaust", "psk": "Panzerschrek", "atmm": "ATMM check:", # nb: the PF label now has a snippet ID
"mol": "Kindling Attempt:", "mol-p": "TH#", # nb: the MOL label now has a snippet ID
# nb: the PIAT and BAZ labels are now gone
}, ignore_labels )
# update the scenario (British/American, DEC/1943)
load_scenario_params( {
"scenario": { "PLAYER_1": "british", "PLAYER_2": "american", "SCENARIO_DATE": "12/31/1943" }
} )
updated_vsav_dump = _update_vsav_and_dump( fname, { "created": 3, "updated": 1, "deleted": 3 } )
_check_vsav_dump( updated_vsav_dump, {
# nb: the PSK/ATMM and MOL-P label are now gone
"piat": "TH#",
"baz": "Bazooka ('43)", # nb: this has changed from '45
}, ignore_labels )
# run the test
# NOTE: We're testing the logic in the front/back-ends that determine whether LATW labels
# get created/updated/deleted, not the interaction with VASSAL, so we don't need to test
# against every VASSAL+VASL combination (although we can, if we want, but it'll be slow!)
_run_tests( webapp, monkeypatch, do_test, False )
# ---------------------------------------------------------------------
@pytest.mark.skipif( not pytest.config.option.vasl_mods, reason="--vasl-mods not specified" ) #pylint: disable=no-member
@pytest.mark.skipif( not pytest.config.option.vassal, reason="--vassal not specified" ) #pylint: disable=no-member
@pytest.mark.skipif( pytest.config.option.short_tests, reason="--short-tests specified" ) #pylint: disable=no-member
def test_dump_vsav( webapp, webdriver, monkeypatch ):
"""Test dumping a scenario."""
# initialize
init_webapp( webapp, webdriver )
def do_test(): #pylint: disable=missing-docstring
# dump the VASL scenario
fname = os.path.join( os.path.split(__file__)[0], "fixtures/dump-vsav/labels.vsav" )
vassal_shim = VassalShim()
vsav_dump = vassal_shim.dump_scenario( fname )
# check the result
fname = change_extn( fname, ".txt" )
expected = open( fname, "r" ).read()
assert vsav_dump == expected
# run the test against all versions of VASSAL+VASL
_run_tests( webapp, monkeypatch, do_test, True )
# ---------------------------------------------------------------------
@pytest.mark.skipif( not pytest.config.option.vasl_mods, reason="--vasl-mods not specified" ) #pylint: disable=no-member
@pytest.mark.skipif( not pytest.config.option.vassal, reason="--vassal not specified" ) #pylint: disable=no-member
@pytest.mark.skipif( pytest.config.option.short_tests, reason="--short-tests specified" ) #pylint: disable=no-member
def test_legacy_labels( webapp, webdriver, monkeypatch ):
"""Test detection and updating of legacy labels."""
# initialize
monkeypatch.setitem( webapp.config, "DATA_DIR", REAL_DATA_DIR )
init_webapp( webapp, webdriver, vsav_persistence=1, scenario_persistence=1 )
# NOTE: We disable this for speed, since we don't care about label positioning.
monkeypatch.setitem( webapp.config, "DISABLE_UPDATE_VSAV_SCREENSHOTS", True )
def do_test(): #pylint: disable=missing-docstring
# dump the VASL scenario
# NOTE: We implemented snippet ID's in v0.5, this scenario is the "Hill 621" example from v0.4.
fname = os.path.join( os.path.split(__file__)[0], "fixtures/update-vsav/hill621-legacy.vsav" )
vassal_shim = VassalShim()
vsav_dump = vassal_shim.dump_scenario( fname )
labels = _get_vsav_labels( vsav_dump )
assert len( [ lbl for lbl in labels if "vasl-templates:id" not in lbl ] ) == 20
assert len( [ lbl for lbl in labels if "vasl-templates:id" in lbl ] ) == 0 #pylint: disable=len-as-condition
# load the scenario into the UI and update the VSAV
fname2 = change_extn( fname, ".json" )
saved_scenario = json.load( open( fname2, "r" ) )
load_scenario( saved_scenario )
updated_vsav_dump = _update_vsav_and_dump( fname, { "created": 1, "updated": 20 } )
# check the results
# nb: the update process should create 1 new label (the "Download from MMP" scenario note)
labels = _get_vsav_labels( updated_vsav_dump )
assert len( [ lbl for lbl in labels if "vasl-templates:id" not in lbl ] ) == 0 #pylint: disable=len-as-condition
assert len( [ lbl for lbl in labels if "vasl-templates:id" in lbl ] ) == 21
_check_vsav_dump( updated_vsav_dump, {
"scenario": "Near Minsk",
"players": re.compile( r"Russian:.*German:" ),
"victory_conditions": "five Level 3 hill hexes",
"ssr": re.compile( r"no wind at start.*must take a TC" ),
"scenario_note.1": "Download the scenario card",
"ob_setup_1.1": "whole hex of Board 3",
"ob_setup_1.2": "Enter on Turn 2", "ob_setup_1.3": "Enter on Turn 5",
"ob_vehicles_1": re.compile( r"T-34 M43.*SU-152.*SU-122.*ZIS-5" ),
"ob_setup_2.1": "whole hex of Board 4",
"ob_setup_2.2": "Enter on Turn 1", "ob_setup_2.3": "Enter on Turn 2", "ob_setup_2.4": "Enter on Turn 4",
"ob_setup_2.5": "Enter on Turn 5", "ob_setup_2.6": "Enter on Turn 8",
"ob_note_2.1": "80+mm Battalion Mortar",
"ob_note_2.2": "100+mm OBA",
"ob_vehicles_2": re.compile(
r"PzKpfw IVH.*PzKpfw IIIN.*StuG IIIG \(L\).*StuH 42.*SPW 250/1.*SPW 251/1.*SPW 251/sMG"
),
"ob_ordnance_2": re.compile( r"7.5cm PaK 40.*5cm PaK 38" ),
"pf": "Panzerfaust", "atmm": "Anti-Tank Magnetic Mines",
} )
# run the test
_run_tests( webapp, monkeypatch, do_test, False )
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
@pytest.mark.skipif( not pytest.config.option.vasl_mods, reason="--vasl-mods not specified" ) #pylint: disable=no-member
@pytest.mark.skipif( not pytest.config.option.vassal, reason="--vassal not specified" ) #pylint: disable=no-member
@pytest.mark.skipif( pytest.config.option.short_tests, reason="--short-tests specified" ) #pylint: disable=no-member
def test_legacy_latw_labels( webapp, webdriver, monkeypatch ):
"""Test detection and updating of legacy LATW labels."""
# initialize
monkeypatch.setitem( webapp.config, "DATA_DIR", REAL_DATA_DIR )
init_webapp( webapp, webdriver, vsav_persistence=1, scenario_persistence=1 )
# NOTE: We disable this for speed, since we don't care about label positioning.
monkeypatch.setitem( webapp.config, "DISABLE_UPDATE_VSAV_SCREENSHOTS", True )
def do_test(): #pylint: disable=missing-docstring
# dump the VASL scenario
# NOTE: This scenario contains LATW labels created using v0.4 i.e. they have no snippet ID's.
fname = os.path.join( os.path.split(__file__)[0], "fixtures/update-vsav/latw-legacy.vsav" )
vassal_shim = VassalShim()
vsav_dump = vassal_shim.dump_scenario( fname )
labels = _get_vsav_labels( vsav_dump )
assert len( [ lbl for lbl in labels if "vasl-templates:id" not in lbl ] ) == 8
assert len( [ lbl for lbl in labels if "vasl-templates:id" in lbl ] ) == 0 #pylint: disable=len-as-condition
# NOTE: We're only interested in what happens with the LATW labels, ignore everything else
ignore_labels = [ "scenario", "players", "victory_conditions" ]
# update the VSAV (all LATW are active)
load_scenario_params( {
"scenario": { "PLAYER_1": "german", "PLAYER_2": "russian", "SCENARIO_DATE": "12/31/1945" }
} )
updated_vsav_dump = _update_vsav_and_dump( fname, { "created": 3, "updated": 5 } )
_check_vsav_dump( updated_vsav_dump, {
"pf": "Panzerfaust", "psk": "Panzerschrek", "atmm": "ATMM check:",
"mol": "Kindling Attempt:", "mol-p": "TH#",
}, ignore_labels )
labels = _get_vsav_labels( updated_vsav_dump )
# nb: the legacy labels left in place: the scenario comment, and the PIAT/BAZ labels
assert len( [ lbl for lbl in labels if "vasl-templates:id" not in lbl ] ) == 3
# update the VSAV (all LATW are active)
load_scenario_params( {
"scenario": { "PLAYER_1": "british", "PLAYER_2": "american", "SCENARIO_DATE": "12/31/1945" }
} )
updated_vsav_dump = _update_vsav_and_dump( fname, { "created": 3, "updated": 2 } )
_check_vsav_dump( updated_vsav_dump, {
"piat": "PIAT",
"baz": "Bazooka ('45)",
}, ignore_labels )
labels = _get_vsav_labels( updated_vsav_dump )
# nb: the legacy labels left in place: the scenario comment, the PF/PSK/ATMM and MOL/MOL-P labels
assert len( [ lbl for lbl in labels if "vasl-templates:id" not in lbl ] ) == 6
# update the VSAV (some LATW are active)
load_scenario_params( { "scenario": { "PLAYER_1": "german", "PLAYER_2": "russian", "SCENARIO_DATE": "" } } )
updated_vsav_dump = _update_vsav_and_dump( fname, { "created": 3, "updated": 5 } )
_check_vsav_dump( updated_vsav_dump, {
"pf": "Panzerfaust", "psk": "Panzerschrek", "atmm": "ATMM check:",
"mol": "Kindling Attempt:", "mol-p": "TH#",
}, ignore_labels )
labels = _get_vsav_labels( updated_vsav_dump )
# nb: the legacy labels left in place: the scenario comment, the PIAT/BAZ labels
assert len( [ lbl for lbl in labels if "vasl-templates:id" not in lbl ] ) == 3
# update the VSAV (some LATW are active)
load_scenario_params( { "scenario": { "PLAYER_1": "british", "PLAYER_2": "american", "SCENARIO_DATE": "" } } )
updated_vsav_dump = _update_vsav_and_dump( fname, { "created": 3, "updated": 2 } )
_check_vsav_dump( updated_vsav_dump, {
"piat": "PIAT",
"baz": "Bazooka",
}, ignore_labels )
labels = _get_vsav_labels( updated_vsav_dump )
# nb: the legacy labels left in place: the scenario comment, the PF/PSK/ATMM, MOL/MOL-P and BAZ labels
assert len( [ lbl for lbl in labels if "vasl-templates:id" not in lbl ] ) == 6
# run the test
_run_tests( webapp, monkeypatch, do_test, False )
# ---------------------------------------------------------------------
def _run_tests( webapp, monkeypatch, func, test_all ):
"""Run the test function for each combination of VASSAL + VASL.
This is, of course, going to be insanely slow, since we need to spin up a JVM
and initialize VASSAL/VASL each time :-/
"""
# locate all VASL modules
vasl_mods_dir = pytest.config.option.vasl_mods #pylint: disable=no-member
fspec = os.path.join( vasl_mods_dir, "*.vmod" )
vasl_mods = glob.glob( fspec )
# locate all VASSAL engines
vassal_engines = []
vassal_dir = pytest.config.option.vassal #pylint: disable=no-member
for root,_,fnames in os.walk( vassal_dir ):
for fname in fnames:
if fname == "Vengine.jar":
vassal_engines.append( root )
# check if we want to test all VASSAL+VASL combinations (nb: if not, we test against only one combination,
# and since they all should give the same results, it doesn't matter which one.
if not test_all:
vasl_mods = [ random.choice( vasl_mods ) ]
vassal_engines = [ random.choice( vassal_engines ) ]
# run the test for each VASSAL+VASL
for vassal_engine in vassal_engines:
monkeypatch.setitem( webapp.config, "VASSAL_DIR", vassal_engine )
for vasl_mod in vasl_mods:
monkeypatch.setitem( webapp.config, "VASL_MOD", vasl_mod )
func()
# ---------------------------------------------------------------------
def _update_vsav( fname, expected ):
"""Update a VASL scenario."""
# read the VSAV data
vsav_data = open( fname, "rb" ).read()
# send the VSAV data to the front-end to be updated
set_stored_msg( "_vsav-persistence_", base64.b64encode( vsav_data ).decode( "utf-8" ) )
_ = set_stored_msg_marker( "_last-info_" )
_ = set_stored_msg_marker( "_last-warning_" )
select_menu_option( "update_vsav" )
# wait for the results to come back
wait_for( 2, lambda: get_stored_msg( "_vsav-persistence_" ) == "" ) # nb: wait for the front-end to receive the data
timeout = 120 if os.name == "nt" else 60
wait_for( timeout, lambda: get_stored_msg( "_vsav-persistence_" ) != "" ) # nb: wait for the updated data to arrive
updated_vsav_data = get_stored_msg( "_vsav-persistence_" )
if updated_vsav_data.startswith( "ERROR: " ):
raise RuntimeError( updated_vsav_data )
updated_vsav_data = base64.b64decode( updated_vsav_data )
# parse the VASSAL shim report
if expected:
report = {}
msg = get_stored_msg( "_last-warning_" if "deleted" in expected else "_last-info_" )
assert "The VASL scenario was updated:" in msg
for mo2 in re.finditer( "<li>([^<]+)", msg ):
mo3 = re.search( r"^(\d+) labels? (were|was) ([a-z]+)", mo2.group(1) )
report[ mo3.group(3) ] = int( mo3.group(1) )
assert report == expected
else:
assert "No changes were made" in get_stored_msg( "_last-info_" )
return updated_vsav_data
def _update_vsav_and_dump( fname, expected ):
"""Update a VASL scenario and dump the result."""
# update the VASL
updated_vsav_data = _update_vsav( fname, expected )
# dump the updated VSAV
with TempFile() as temp_file:
temp_file.write( updated_vsav_data )
temp_file.close()
vassal_shim = VassalShim()
updated_vsav_dump = vassal_shim.dump_scenario( temp_file.name )
return updated_vsav_dump
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
def _check_vsav_dump( vsav_dump, expected, ignore=None ):
""""Check that a VASL scenario dump contains what we expect."""
# extract the information of interest from the dump
labels = {}
for label in _get_vsav_labels(vsav_dump):
mo2 = re.search( r"<!-- vasl-templates:id (.*?) -->", label, re.DOTALL )
if not mo2:
continue # nb: this is not one of ours
snippet_id = mo2.group( 1 )
if snippet_id.startswith( "extras/" ):
continue
labels[snippet_id] = label
# compare what we extracted from the dump with what's expected
for snippet_id in expected:
if isinstance( expected[snippet_id], typing.re.Pattern ):
rc = expected[snippet_id].search( labels[snippet_id] ) is not None
else:
assert isinstance( expected[snippet_id], str )
rc = expected[snippet_id] in labels[snippet_id]
if not rc:
print( "Can't find {} in label: {}".format( expected[snippet_id], labels[snippet_id] ) )
assert False
del labels[snippet_id]
# check for unexpected extra labels in the VASL scenario
if ignore:
labels = [ lbl for lbl in labels if lbl not in ignore ]
if labels:
for snippet_id in labels:
print( "Extra label in the VASL scenario: {}".format( snippet_id ) )
assert False
def _get_vsav_labels( vsav_dump ):
"""Extract the labels from a VSAV dump."""
matches = re.finditer( r"AddPiece: DynamicProperty/User-Labeled.*?- Map", vsav_dump, re.DOTALL )
labels = [ mo.group() for mo in matches ]
regex = re.compile( r"<html>.*?</html>" )
matches = [ regex.search(label) for label in labels ]
return [ mo.group() if mo else "<???>" for mo in matches ]

@ -125,6 +125,16 @@ def select_tab( tab_id ):
elem = find_child( "#tabs .ui-tabs-nav a[href='#tabs-{}']".format( tab_id ) )
elem.click()
def select_tab_for_elem( elem ):
"""Select the tab that contains the specified element."""
while True:
elem = elem.find_element_by_xpath( ".." )
if elem.tag_name == "div":
div_id = elem.get_attribute( "id" )
if div_id.startswith( "tabs-" ):
select_tab( div_id[5:] )
break
def select_menu_option( menu_id ):
"""Select a menu option."""
elem = find_child( "#menu" )
@ -214,6 +224,18 @@ def set_template_params( params ): #pylint: disable=too-many-branches
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
def set_scenario_date( scenario_date ):
"""Set the scenario date."""
if scenario_date is None:
return
select_tab( "scenario" )
elem = find_child( "input[name='SCENARIO_DATE']" )
elem.clear()
elem.send_keys( scenario_date )
elem.send_keys( Keys.TAB ) # nb: force the calendar popup to close :-/
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
_nationalities = None
def get_nationality_display_name( nat_id ):

@ -0,0 +1,117 @@
""" Miscellaneous utilities. """
import os
import tempfile
import pathlib
from selenium import webdriver
from PIL import Image, ImageChops
from vasl_templates.webapp import app
# ---------------------------------------------------------------------
class TempFile:
"""Manage a temp file that can be closed while it's still being used."""
def __init__( self, mode="wb", extn=None ):
self.mode = mode
self.extn = extn
self.temp_file = None
self.name = None
def __enter__( self ):
"""Allocate a temp file."""
self.temp_file = tempfile.NamedTemporaryFile( mode=self.mode, suffix=self.extn, delete=False )
self.name = self.temp_file.name
return self
def __exit__( self, exc_type, exc_val, exc_tb ):
"""Clean up the temp file."""
self.close()
os.unlink( self.temp_file.name )
def write( self, data ):
"""Write data to the temp file."""
self.temp_file.write( data )
def close( self ):
"""Close the temp file."""
self.temp_file.close()
# ---------------------------------------------------------------------
class HtmlScreenshots:
"""Generate preview screenshots of HTML."""
def __init__( self ):
self.webdriver = None
def __enter__( self ):
"""Initialize the HTML screenshot engine."""
webdriver_path = app.config.get( "WEBDRIVER_PATH" )
if not webdriver_path:
raise SimpleError( "No webdriver has been configured." )
# NOTE: If we are being run on Windows without a console (e.g. the frozen PyQt desktop app),
# Selenium will launch the webdriver in a visible DOS box :-( There's no way to turn this off,
# but it can be disabled by modifying the Selenium source code. Find the subprocess.Popen() call
# in $/site-packages/selenium/webdriver/common/service.py and add the following parameter:
# creationflags = 0x8000000 # win32process.CREATE_NO_WINDOW
# It's pretty icky to have to do this, but since we're in a virtualenv, it's not too bad...
kwargs = { "executable_path": webdriver_path }
if "chromedriver" in webdriver_path:
options = webdriver.ChromeOptions()
options.set_headless( headless=True )
kwargs["chrome_options"] = options
self.webdriver = webdriver.Chrome( **kwargs )
elif "geckodriver" in webdriver_path:
options = webdriver.FirefoxOptions()
options.set_headless( headless=True )
kwargs["firefox_options"] = options
kwargs["log_path"] = app.config.get( "GECKODRIVER_LOG",
os.path.join( tempfile.gettempdir(), "geckodriver.log" )
)
self.webdriver = webdriver.Firefox( **kwargs )
else:
raise SimpleError( "Can't identify webdriver: {}".format( webdriver_path ) )
return self
def __exit__( self, exc_type, exc_val, exc_tb ):
"""Clean up."""
if self.webdriver:
self.webdriver.quit()
def get_screenshot( self, html, window_size ):
"""Get a preview screenshot of the specified HTML."""
self.webdriver.set_window_size( window_size[0], window_size[1] )
with TempFile( extn=".html", mode="w" ) as html_tempfile:
# take a screenshot of the HTML
# NOTE: We could do some funky Javascript stuff to load the browser directly from the string,
# but using a temp file is straight-forward and pretty much guaranteed to work :-/
html_tempfile.write( html )
html_tempfile.close()
self.webdriver.get( "file://{}".format( html_tempfile.name ) )
with TempFile( extn=".png" ) as screenshot_tempfile:
screenshot_tempfile.close()
self.webdriver.save_screenshot( screenshot_tempfile.name )
img = Image.open( screenshot_tempfile.name )
# trim the screenshot (nb: we assume a white background)
bgd = Image.new( img.mode, img.size, (255,255,255,255) )
diff = ImageChops.difference( img, bgd )
bbox = diff.getbbox()
return img.crop( bbox )
# ---------------------------------------------------------------------
def change_extn( fname, extn ):
"""Change a filename's extension."""
return pathlib.Path( fname ).with_suffix( extn )
# ---------------------------------------------------------------------
class SimpleError( Exception ):
"""Represents a simple error that doesn't require a stack trace (e.g. bad configuration)."""
pass

@ -0,0 +1,349 @@
""" Webapp handlers. """
# Kathmandu, Nepal (NOV/18).
import sys
import os
import subprocess
import traceback
import json
import re
import logging
import base64
import time
import xml.etree.cElementTree as ET
from flask import request
from vasl_templates.webapp import app
from vasl_templates.webapp.config.constants import BASE_DIR, IS_FROZEN
from vasl_templates.webapp.utils import TempFile, HtmlScreenshots, SimpleError
_logger = logging.getLogger( "update_vsav" )
SUPPORTED_VASSAL_VERSIONS = [ "3.2.15" ,"3.2.16", "3.2.17" ]
SUPPORTED_VASSAL_VERSIONS_DISPLAY = "3.2.15-.17"
# ---------------------------------------------------------------------
@app.route( "/update-vsav", methods=["POST"] )
def update_vsav(): #pylint: disable=too-many-statements
"""Update labels in a VASL scenario file."""
# parse the request
start_time = time.time()
vsav_data = request.json[ "vsav_data" ]
vsav_filename = request.json[ "filename" ]
snippets = request.json[ "snippets" ]
# update the VASL scenario file
try:
# get the VSAV data (we do this inside the try block so that the user gets shown
# a proper error dialog if there's a problem decoding the base64 data)
vsav_data = base64.b64decode( vsav_data )
_logger.info( "Updating VSAV (#bytes=%d): %s", len(vsav_data), vsav_filename )
with TempFile() as input_file:
# save the VSAV data in a temp file
input_file.write( vsav_data )
input_file.close()
fname = app.config.get( "UPDATE_VSAV_INPUT" ) # nb: for diagnosing problems
if fname:
_logger.debug( "Saving a copy of the VSAV data: %s", fname )
with open( fname, "wb" ) as fp:
fp.write( vsav_data )
with TempFile() as snippets_file:
# save the snippets in a temp file
xml = _save_snippets( snippets, snippets_file )
snippets_file.close()
fname = app.config.get( "UPDATE_VSAV_SNIPPETS" ) # nb: for diagnosing problems
if fname:
_logger.debug( "Saving a copy of the snippets: %s", fname )
with open( fname, "wb" ) as fp:
ET.ElementTree( xml ).write( fp )
# run the VASSAL shim to update the VSAV file
with TempFile() as output_file, TempFile() as report_file:
output_file.close()
report_file.close()
vassal_shim = VassalShim()
vassal_shim.update_scenario(
input_file.name, snippets_file.name, output_file.name, report_file.name
)
# read the updated VSAV data
with open( output_file.name, "rb" ) as fp:
vsav_data = fp.read()
fname = app.config.get( "UPDATE_VSAV_RESULT" ) # nb: for diagnosing problems
if fname:
_logger.debug( "Saving a copy of the update VSAV: %s", fname )
with open( app.config.get("UPDATE_VSAV_RESULT"), "wb" ) as fp:
fp.write( vsav_data )
# read the report
label_report = _parse_label_report( report_file.name )
except VassalShimError as ex:
_logger.error( "VASSAL shim error: rc=%d", ex.retcode )
if ex.retcode != 0:
return json.dumps( {
"error": "Unexpected return code from the VASSAL shim: {}".format( ex.retcode ),
"stdout": ex.stdout,
"stderr": ex.stderr,
} )
return json.dumps( {
"error": "Unexpected error output from the VASSAL shim.",
"stdout": ex.stdout,
"stderr": ex.stderr,
} )
except subprocess.TimeoutExpired:
return json.dumps( {
"error": "<p>The updater took too long to run, please try again." \
"<p>If this problem persists, try configuring a longer timeout."
} )
except SimpleError as ex:
_logger.error( "VSAV update error: %s", ex )
return json.dumps( { "error": str(ex) } )
except Exception as ex: #pylint: disable=broad-except
_logger.error( "Unexpected VSAV update error: %s", ex )
return json.dumps( {
"error": str(ex),
"stdout": traceback.format_exc(),
} )
# return the results
_logger.debug( "Updated the VSAV file OK: elapsed=%.3fs", time.time()-start_time )
# NOTE: We adjust the recommended save filename to encourage users to not overwrite the original file :-/
vsav_filename = os.path.split( vsav_filename )[1]
fname, extn = os.path.splitext( vsav_filename )
return json.dumps( {
"vsav_data": base64.b64encode(vsav_data).decode( "utf-8" ),
"filename": fname+" (updated)" + extn,
"report": {
"was_modified": label_report["was_modified"],
"labels_created": len(label_report["created"]),
"labels_updated": len(label_report["updated"]),
"labels_deleted": len(label_report["deleted"]),
"labels_unchanged": len(label_report["unchanged"]),
},
} )
def _save_snippets( snippets, fp ):
"""Save the snippets in a file.
NOTE: We save the snippets as XML because Java :-/
"""
def get_html_size( snippet_id, html, window_size ):
"""Get the size of the specified HTML."""
start_time = time.time()
img = html_screenshots.get_screenshot( html, window_size )
elapsed_time = time.time() - start_time
width, height = img.size
_logger.debug( "Generated screenshot for %s (%.3fs): %dx%d", snippet_id, elapsed_time, width, height )
return width, height
def do_save_snippets( html_screenshots ):
"""Save the snippets."""
root = ET.Element( "snippets" )
for key,val in snippets.items():
# add the next snippet
auto_create = "true" if val["auto_create"] else "false"
elem = ET.SubElement( root, "snippet", id=key, autoCreate=auto_create )
elem.text = val["content"]
label_area = val.get( "label_area" )
if label_area:
elem.set( "labelArea", label_area )
# add the raw content
elem2 = ET.SubElement( elem, "rawContent" )
for node in val["raw_content"]:
ET.SubElement( elem2, "phrase" ).text = node
# include the size of the snippet
if html_screenshots:
try:
# NOTE: Screenshots take significantly longer for larger window sizes. Since most of our snippets
# will be small, we first try with a smaller window, and switch to a larger one if necessary.
width, height = get_html_size( key, val["content"], (500,500) )
if width >= 450 or height >= 450:
# NOTE: While it's tempting to set the browser window really large here, if the label ends up
# filling/overflowing the available space (e.g. because its width/height has been set to 100%),
# then the auto-created label will push any subsequent labels far down the map, possibly to
# somewhere unreachable. So, we set it somewhat more conservatively, so that if this happens,
# the user still has a chance to recover from it. Note that this doesn't mean that they can't
# have really large labels, it just affects the positioning of auto-created labels.
width, height = get_html_size( key, val["content"], (1500,1500) )
# FUDGE! There's something weird going on in VASSAL e.g. "<table width=300>" gives us something
# very different to "<table style='width:300px;'>" :-/ Changing the font size also causes problems.
# The following fudging seems to give us something that's somewhat reasonable... :-/
if re.search( r"width:\s*?\d+?px", val["content"] ):
width = int( width * 140 / 100 )
elem.set( "width", str(width) )
elem.set( "height", str(height) )
except Exception as ex: #pylint: disable=broad-except
# NOTE: Don't let an error here stop the process.
logging.error( "Can't get snippet screenshot: %s", ex )
logging.error( traceback.format_exc() )
ET.ElementTree( root ).write( fp )
return root
# save the snippets
if app.config.get( "DISABLE_UPDATE_VSAV_SCREENSHOTS" ):
return do_save_snippets( None )
else:
with HtmlScreenshots() as html_screenshots:
return do_save_snippets( html_screenshots )
def _parse_label_report( fname ):
"""Read the label report generated by the VASSAL shim."""
doc = ET.parse( fname )
report = {
"was_modified": doc.getroot().attrib["wasModified"] == "true"
}
for action in doc.getroot():
nodes = []
for node in action:
nodes.append( { "id": node.attrib["id"] } )
if "x" in node.attrib and "y" in node.attrib:
nodes[-1]["pos"] = ( node.attrib["x"], node.attrib["y"] )
report[ action.tag ] = nodes
return report
# ---------------------------------------------------------------------
class VassalShim:
"""Provide access to VASSAL via the Java shim."""
def __init__( self ):
# locate the VASSAL engine
vassal_dir = app.config.get( "VASSAL_DIR" )
if not vassal_dir:
raise SimpleError( "The VASSAL installation directory has not been configured." )
self.vengine_jar = None
for root,_,fnames in os.walk( vassal_dir ):
for fname in fnames:
if fname == "Vengine.jar":
self.vengine_jar = os.path.join( root, fname )
break
if not self.vengine_jar:
raise SimpleError( "Can't find Vengine.jar: {}".format( vassal_dir ) )
# locate the boards
self.boards_dir = app.config.get( "BOARDS_DIR" )
if not self.boards_dir:
raise SimpleError( "The VASL boards directory has not been configured." )
if not os.path.isdir( self.boards_dir ):
raise SimpleError( "Can't find the VASL boards: {}".format( self.boards_dir ) )
# locate the VASL module
self.vasl_mod = app.config.get( "VASL_MOD" )
if not self.vasl_mod:
raise SimpleError( "The VASL module has not been configured." )
if not os.path.isfile( self.vasl_mod ):
raise SimpleError( "Can't find VASL module: {}".format( self.vasl_mod ) )
# locate the VASSAL shim JAR
if IS_FROZEN:
meipass = sys._MEIPASS #pylint: disable=no-member,protected-access
self.shim_jar = os.path.join( meipass, "vasl_templates/webapp/vassal-shim.jar" )
else:
self.shim_jar = os.path.join( os.path.split(__file__)[0], "../../vassal-shim/release/vassal-shim.jar" )
if not os.path.isfile( self.shim_jar ):
raise SimpleError( "Can't find the VASSAL shim JAR." )
def dump_scenario( self, fname ):
"""Dump a scenario file."""
return self._run_vassal_shim( "dump", fname )
def update_scenario( self, vsav_fname, snippets_fname, output_fname, report_fname ):
"""Update a scenario file."""
return self._run_vassal_shim(
"update", self.boards_dir, vsav_fname, snippets_fname, output_fname, report_fname
)
def _run_vassal_shim( self, *args ): #pylint: disable=too-many-locals
"""Run the VASSAL shim."""
# prepare the command
java_path = app.config.get( "JAVA_PATH" )
if not java_path:
java_path = "java" # nb: this must be in the PATH
class_path = app.config.get( "JAVA_CLASS_PATH" )
if not class_path:
class_path = [ self.vengine_jar, self.shim_jar ]
class_path.append( os.path.split( self.shim_jar )[0] ) # nb: to find logback(-test).xml
if IS_FROZEN:
class_path.append( BASE_DIR ) # nb: also to find logback(-test).xml
sep = ";" if os.name == "nt" else ":"
class_path = sep.join( class_path )
args2 = [
java_path, "-classpath", class_path, "vassal_shim.Main",
args[0], self.vasl_mod
]
args2.extend( args[1:] )
# figure out how long to the let the VASSAL shim run
timeout = int( app.config.get( "VASSAL_SHIM_TIMEOUT", 120 ) )
if timeout <= 0:
timeout = None
# run the VASSAL shim
_logger.debug( "Running VASSAL shim (timeout=%s): %s", str(timeout), " ".join(args2) )
start_time = time.time()
# NOTE: We can't use pipes to capture the output here when we're frozen on Windows ("invalid handle" errors),
# I suspect because we freeze the application using --noconsole, which causes problems when
# the child process tries to inherit handles. Capturing the output in temp files also fails (!),
# as does using subprocess.DEVNULL (!!!) Setting close_fds when calling Popen() also made no difference.
# The only thing that worked was removing "--noconsole" when freezing the application, but that causes
# a DOS box to appear when we are run :-/
# However, we can also not specify any stdout/stderr, and since we don't actually check the output,
# we can get away with this, even if it is a bit icky :-/ However, if the VASSAL shim throws an error,
# we won't be able to show the stack trace, just a generic "VASSAL shim failed" message :-(
with TempFile() as buf1, TempFile() as buf2:
kwargs = {}
if not IS_FROZEN:
kwargs = { "stdout": buf1.temp_file, "stderr": buf2.temp_file }
if os.name == "nt":
# NOTE: Using CREATE_NO_WINDOW doesn't fix the problem of VASSAL's UI sometimes appearing,
# but it does hide the DOS box if the user has configured java.exe instead of javaw.exe.
kwargs["creationflags"] = 0x8000000 # nb: win32process.CREATE_NO_WINDOW
proc = subprocess.Popen( args2, **kwargs )
try:
proc.wait( timeout )
except subprocess.TimeoutExpired:
proc.kill()
raise
buf1.close()
stdout = open( buf1.name, "r", encoding="utf-8" ).read()
buf2.close()
stderr = open( buf2.name, "r", encoding="utf-8" ).read()
elapsed_time = time.time() - start_time
_logger.debug( "- Completed OK: %.3fs", elapsed_time )
# check the result
stderr = stderr.replace( "Warning: Could not get charToByteConverterClass!", "" ).strip()
# NOTE: VASSAL's internal representation of a scenario seems to be tightly coupled with its UI,
# which means that when we load a scenario, bits of the UI sometimes start appearing (although not always,
# presumably because there's a race between how fast we can make our changes and save the scenario
# vs. how fast the UI can start up :-/). When the UI does start to appear, it fails, presumably because
# we haven't performed the necessary startup incantations, and dumps a stack trace to stderr.
# The upshot is that the only thing we look for is an exit code of 0, which means that the VASSAL shim
# saved the scenario successfully and exited cleanly; any output on stderr means that some part
# of VASSAL barfed as it was trying to start up and can (hopefully) be safely ignored.
if stderr:
_logger.info( "VASSAL shim stderr output:\n%s", stderr )
if proc.returncode != 0:
raise VassalShimError( proc.returncode, stdout, stderr )
return stdout
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
class VassalShimError( Exception ):
"""Represents an error returned by the VASSAL shim."""
def __init__( self, retcode, stdout, stderr ):
super().__init__()
self.retcode = retcode
self.stdout = stdout
self.stderr = stderr

@ -0,0 +1,3 @@
output/
release/vassal-shim.properties
logback-test.xml

@ -0,0 +1,27 @@
# Define VASSAL_DIR in the command line arguments to point to the directory that contains Vengine.jar e.g.
# make all VASSAL_DIR=...
SRC_DIR:=src
DATA_DIR:=data
OUTPUT_DIR:=output
RELEASE_DIR:=release
JAVAC:=javac
JAR:=jar
CLASSPATH:=$(VASSAL_DIR)/Vengine.jar:$(OUTPUT_DIR)
JAVAC_FLAGS:=-d $(OUTPUT_DIR) -classpath $(CLASSPATH) -sourcepath $(SRC_DIR) -Xlint:unchecked
all: init compile
init:
mkdir -p $(OUTPUT_DIR)
mkdir -p $(RELEASE_DIR)
compile: init
$(JAVAC) $(JAVAC_FLAGS) $(shell find $(SRC_DIR) -name '*.java')
cp -r $(DATA_DIR) $(OUTPUT_DIR)
$(JAR) cfe $(RELEASE_DIR)/vassal-shim.jar vassal_shim.Main -C $(OUTPUT_DIR) .
clean:
rm -r $(OUTPUT_DIR)
rm -r $(RELEASE_DIR)

@ -0,0 +1,208 @@
; This file defines VASL board names.
; nb: bd00-99 are handled automatically in the code.
bd1a
bd1b
bd2a
bd2b
bd3a
bd3b
bd4a
bd4b
bd5a
bd5b
bd6a
bd6b
bd7a
bd7b
bd8a
bd8b
bd9a
bd9b
bd10z
bd17z
bda
bdb
bdc
bdd
bdd1
bdd2
bdd3
bdd4
bdd5
bdd6
bdd7
bde
bdf
bdg
bdh
bdp
bdq
bdr
bds
bdt
bdu
bdv
bdw
bdx
bdy
bdz
bdABTF
bdASLNews
bdB&I
bdbatisse02
bdbatisse03
bdbatisse04
bdBB
bdBDF
bdBF1
bdBFPA
bdBFPB
bdBFPC
bdBFPD
bdBFPDW1a
bdBFPDW1b
bdBFPDW2a
bdBFPDW2b
bdBFPDW3a
bdBFPDW3b
bdBFPDW4a
bdBFPDW4b
bdBFPDW5a
bdBFPDW5b
bdBFPDW6a
bdBFPDW6b
bdBFPE
bdBFPF
bdBFPG
bdBFPH
bdBFPI
bdBFPJ
bdBFPK
bdBFPL
bdBFPM
bdBFPN
bdBFPO
bdBFPP
bdBFPQ
bdBFPR
bdBlank0
bdBlank1
bdBRT
bdBRV
bdCemHill
bdCH
bdCH1
bdCH2
bdCH3
bdCH4
bdCM
bdCRS
bdDaE
bdDBP
bdER
bdFB_CG2
bdFB_CG3
bdFB_NE
bdFB_NW
bdFB_SE
bdFB_SW
bdFC
bdfe3
bdfe4
bdfe5
bdfe6
bdfe8
bdfe9
bdff1
bdFF10
bdFF11
bdFF12
bdFF13
bdFF14
bdFF15
bdff2
bdff3
bdff4
bdff5
bdff6
bdff9
bdFrFA
bdGB1
bdGB2
bdGB3
bdGB4
bdGB5
bdGT
bdHH
bdHOBI
bdHOBII
bdHoBIII
bdHoBIV
bdJ1
bdKB
bdkholmS
bdkholmW
bdkholmWO
bdKOTH
bdKR
bdKreta
bdLCP-Caslo
bdLCP-Crossroads
bdLCP-GYMO
bdLCP-JAVA09
bdLCP-JAVA10
bdLCP-JAVA11
bdLCP-JAVA12
bdLCP-JAVA13
bdLCP-JAVA14
bdLCPDtO
bdLFT1
bdLFT2
bdLG
bdNG
bdNUL
bdNULV
bdOTO
bdoto2
bdOzB
bdPB
bdPBR
bdPdH
bdPHD
bdRaM
bdRB
bdRBv2
bdRees
bdRileys
bdRR
bdSC
bdSCW0
bdSCW1
bdSCW2
bdSenno
bdSG
bdSH
bdST
bdStN
bdTauroggen
bdTCRS
bdThinLine
bdTO
bdTR1
bdTR2
bdTR3
bdTR4
bdTR5
bdTR6
bdTR7
bdTR8
bdTRBH
bdVotG
bdWittLastBattle
KB
RR
SH
TRBH

@ -0,0 +1,51 @@
package vassal_shim ;
import java.util.ArrayList ;
import VASSAL.counters.GamePiece ;
// --------------------------------------------------------------------
public class GamePieceLabelFields
{
// Holds the individual fields in a GamePiece label.
// A GamePiece's state is a string, consisting of a number of separated fields.
// We parse the string into its constituent parts, so that we can make changes
// to some fields (i.e. the label content), and re-constitute the state string.
// These fields contain label #1 and #2.
public static final int FIELD_INDEX_LABEL1 = 3 ;
public static final int FIELD_INDEX_LABEL2 = 4 ;
private GamePiece gamePiece ;
private ArrayList<String> fields ;
private ArrayList<String> separators ;
private int fieldIndex ;
public GamePiece gamePiece() { return gamePiece ; }
public String getLabelContent() { return getLabelContent( this.fieldIndex ) ; }
public String getLabelContent( int fieldIndex ) { return fieldIndex < fields.size() ? fields.get(fieldIndex) : null ; }
public void setFieldIndex( int fieldIndex ) { this.fieldIndex = fieldIndex ; }
public GamePieceLabelFields( GamePiece gamePiece, ArrayList<String> separators, ArrayList<String> fields, int fieldIndex )
{
this.gamePiece = gamePiece ;
this.separators = separators ;
this.fields = fields ;
this.fieldIndex = fieldIndex ;
}
public String getNewGamePieceState( String newField )
{
// get the GamePiece's state wih the new field
fields.set( fieldIndex, newField ) ;
StringBuilder buf = new StringBuilder() ;
for ( int i=0 ; i < fields.size() ; ++i ) {
buf.append( fields.get( i ) ) ;
if ( i < separators.size() )
buf.append( separators.get( i ) ) ;
}
return buf.toString() ;
}
}

@ -0,0 +1,107 @@
package vassal_shim ;
import java.awt.Point ;
import org.slf4j.Logger ;
import org.slf4j.LoggerFactory ;
// --------------------------------------------------------------------
public class LabelArea
{
// Represents a rectangular area on the map in which we will put labels.
private static final Logger logger = LoggerFactory.getLogger( LabelArea.class ) ;
private String labelAreaName ;
private Point topLeft ;
private int areaWidth, areaHeight ;
private int xMargin, yMargin ;
private Point currPos ;
private int currRowHeight ;
public String getName() { return labelAreaName ; }
public LabelArea( String name, Point topLeft, int width, int height, int xMargin, int yMargin )
{
logger.info( "Creating LabelArea '{}': topLeft=[{},{}] ; size={}x{} ; xMargin={} ; yMargin={}",
name, topLeft.x, topLeft.y, width, height, xMargin, yMargin
) ;
this.labelAreaName = name ;
this.topLeft = topLeft ;
this.areaWidth = width ;
this.areaHeight = height ;
this.xMargin = xMargin ;
this.yMargin = yMargin ;
this.currPos = new Point( 0, 0 ) ; // nb: relative to topLeft
this.currRowHeight = 0 ;
}
public Point getNextPosition( String snippet_id, int labelWidth, int labelHeight )
{
// NOTE: When trying to position the label, we allow overflow of up to 40% of the label's width,
// since that will still put the label's centre (which is the click target) in a clear part of the map.
// check if the label will fit in the next available position
logger.debug( "Getting next label position ({}): label={}x{}, currPos=[{},{}]",
labelAreaName, labelWidth, labelHeight, currPos.x, currPos.y
) ;
int overflow = (currPos.x + labelWidth) - areaWidth ;
logger.debug( "- h.overflow = {}", overflow ) ;
if ( overflow < 0.4 * labelWidth ) {
// we have enough horizontal space to place the label, check vertically
overflow = (currPos.y + labelHeight) - areaHeight ;
logger.debug( "- can use current row, v.overflow={}", overflow ) ;
if ( overflow < 0.4 * labelHeight ) {
// we have enough vertical space as well, put the label in the next available position
logger.debug( "- can use next available position: [{},{}]", currPos.x, currPos.y ) ;
Point assignedPos = new Point( topLeft.x+currPos.x, topLeft.y+currPos.y ) ;
currPos.x += labelWidth + xMargin ;
currRowHeight = Math.max( currRowHeight, labelHeight ) ;
return assignedPos ;
} else {
// the LabelArea is full - notify the caller
logger.debug( "- LabelArea is full!" ) ;
return null ;
}
} else {
// there isn't enough horizontal space to place the label, start a new row
doStartNewRow() ;
logger.debug( "- starting a new row: y={}",currPos.y ) ;
// put the label at the start of the new row
if ( labelWidth > areaWidth ) {
// the label is wider than the available width- centre it
currPos.x = (areaWidth - labelWidth) / 2 ;
}
overflow = (currPos.y + labelHeight) - areaHeight ;
logger.debug( "- v.overflow = {}", overflow ) ;
if ( overflow >= 0.4 * labelHeight ) {
// the LabelArea is full - notify the caller
logger.debug( "- LabelArea is full!" ) ;
return null ;
}
logger.debug( "- assigning position: [{},{}]", currPos.x, currPos.y ) ;
Point assignedPos = new Point( topLeft.x+currPos.x, topLeft.y+currPos.y ) ;
currPos.x += labelWidth + xMargin ;
currRowHeight = Math.max( currRowHeight, labelHeight ) ;
return assignedPos ;
}
}
public void startNewRow( String snippetId )
{
// start a new row
doStartNewRow() ;
logger.debug( "Started a new row for '{}': y={}", snippetId, currPos.y ) ;
}
private void doStartNewRow()
{
// start a new row
if ( currPos.x == 0 )
return ;
currPos.x = 0 ;
currPos.y += currRowHeight + yMargin ;
currRowHeight = 0 ;
}
}

@ -0,0 +1,62 @@
package vassal_shim ;
import vassal_shim.VassalShim ;
// --------------------------------------------------------------------
public class Main
{
public static void main( String[] args )
{
// parse the command line arguments
if ( args.length == 0 ) {
printHelp() ;
System.exit( 0 ) ;
}
// execute the specified command
try {
String cmd = args[0].toLowerCase() ;
if ( cmd.equals( "dump" ) ) {
checkArgs( args, 3, "the VASL .vmod file and scenario file" ) ;
VassalShim shim = new VassalShim( args[1], null ) ;
shim.dumpScenario( args[2] ) ;
System.exit( 0 ) ;
}
else if ( cmd.equals( "update" ) ) {
checkArgs( args, 7, "the VASL .vmod file, boards directory, scenario file, snippets file and output/report files" ) ;
VassalShim shim = new VassalShim( args[1], args[2] ) ;
shim.updateScenario( args[3], args[4], args[5], args[6] ) ;
System.exit( 0 ) ;
}
else {
System.out.println( "Unknown command: " + cmd ) ;
System.exit( 1 ) ;
}
} catch( Exception ex ) {
System.out.println( "ERROR: " + ex ) ;
ex.printStackTrace( System.out ) ;
System.exit( -1 ) ;
}
}
private static void checkArgs( String[]args, int expected, String hint )
{
// check the number of arguments
if ( args.length != expected ) {
System.out.println( "Incorrect number of arguments, please specify " + hint + "." ) ;
System.exit( 2 ) ;
}
}
private static void printHelp()
{
// show program usage
System.out.println( Main.class.getName() + " {command} {options}" ) ;
System.out.println( " Provide access to VASSAL functionality." ) ;
System.out.println() ;
System.out.println( "Available commands:" ) ;
System.out.println( " dump: Dump a .vsav file." ) ;
System.out.println( " update: Update the labels in a .vsav file." ) ;
}
}

@ -0,0 +1,17 @@
package vassal_shim ;
import javax.swing.JFrame ;
import javax.swing.JMenuBar ;
import VASSAL.tools.menu.MenuManager ;
import VASSAL.tools.menu.MenuBarProxy ;
// --------------------------------------------------------------------
public class ModuleManagerMenuManager extends MenuManager
{
private final MenuBarProxy menuBar = new MenuBarProxy() ;
public JMenuBar getMenuBarFor( JFrame fc ) { return null ; }
public MenuBarProxy getMenuBarProxyFor( JFrame fc ) { return menuBar ; }
}

@ -0,0 +1,20 @@
package vassal_shim ;
import java.awt.Point ;
// --------------------------------------------------------------------
public class ReportNode
{
// POD container that holds information about what was done.
String snippetId ;
Point labelPos ;
public ReportNode( String snippetId, Point labelPos )
{
// initialize the ReportNode
this.snippetId = snippetId ;
this.labelPos = labelPos ;
}
}

@ -0,0 +1,57 @@
package vassal_shim ;
import java.util.Properties ;
import java.util.ArrayList ;
import org.w3c.dom.NodeList ;
import org.w3c.dom.Node ;
import org.w3c.dom.Element ;
import javax.xml.xpath.XPathFactory ;
import javax.xml.xpath.XPath ;
import javax.xml.xpath.XPathExpression ;
import javax.xml.xpath.XPathExpressionException ;
import javax.xml.xpath.XPathConstants ;
import vassal_shim.Utils ;
// --------------------------------------------------------------------
public class Snippet
{
// POD container that holds the snippet information sent to us from the web server.
public String snippetId ;
public String content ;
public ArrayList<String> rawContent ;
public int width, height ;
public boolean autoCreate ;
public String labelArea ;
public Snippet( Element elem, Properties config ) throws XPathExpressionException
{
// initialize
XPathFactory xpathFactory = XPathFactory.newInstance() ;
// initialize the Snippet
this.snippetId = elem.getAttribute( "id" ) ;
this.content = Utils.getNodeTextContent( elem ) ;
String snippetWidth = elem.getAttribute( "width" ) ;
this.width = Integer.parseInt(
snippetWidth != "" ? snippetWidth : config.getProperty("AUTOCREATE_LABEL_DEFAULT_WIDTH","300")
) ;
String snippetHeight = elem.getAttribute( "height" ) ;
this.height = Integer.parseInt(
snippetHeight != "" ? snippetHeight : config.getProperty("AUTOCREATE_LABEL_DEFAULT_HEIGHT","300")
) ;
this.autoCreate = elem.getAttribute( "autoCreate" ).equals( "true" ) ;
this.labelArea = elem.getAttribute( "labelArea" ) ;
// initialize the Snippet
this.rawContent = new ArrayList<String>() ;
XPathExpression expr = xpathFactory.newXPath().compile( "rawContent/phrase/text()" ) ;
NodeList nodes = (NodeList) expr.evaluate( elem, XPathConstants.NODESET ) ;
for ( int i=0 ; i < nodes.getLength() ; ++i ) {
Node node = nodes.item( i ) ;
this.rawContent.add( nodes.item(i).getTextContent() ) ;
}
}
}

@ -0,0 +1,45 @@
package vassal_shim ;
import org.w3c.dom.NodeList ;
import org.w3c.dom.Node ;
// --------------------------------------------------------------------
public class Utils
{
public static String getNodeTextContent( Node node )
{
// get the text content for an XML node (just itself, no descendants)
StringBuilder buf = new StringBuilder() ;
NodeList childNodes = node.getChildNodes() ;
for ( int i=0 ; i < childNodes.getLength() ; ++i ) {
Node childNode = childNodes.item( i ) ;
if ( childNode.getNodeName().equals( "#text" ) )
buf.append( childNode.getTextContent() ) ;
}
return buf.toString() ;
}
public static boolean startsWith( String val, String target )
{
// check if a string starts with a target substring
if ( val.length() < target.length() )
return false ;
return val.substring( 0, target.length() ).equals( target ) ;
}
public static String printableString( String val )
{
// encode non-ASCII characters
if ( val == null )
return "<null>" ;
StringBuilder buf = new StringBuilder() ;
for ( char ch: val.toCharArray() ) {
if ( (int)ch >= 32 && (int)ch <= 127 )
buf.append( ch ) ;
else
buf.append( String.format( "<%02x>", (int)ch ) ) ;
}
return buf.toString() ;
}
}

@ -0,0 +1,884 @@
package vassal_shim ;
import java.io.File ;
import java.io.FileInputStream ;
import java.io.InputStream ;
import java.io.InputStreamReader ;
import java.io.FileOutputStream ;
import java.io.OutputStream ;
import java.io.BufferedReader ;
import java.io.IOException ;
import java.io.FileNotFoundException ;
import java.net.URISyntaxException ;
import java.util.Collections ;
import java.util.Arrays ;
import java.util.List ;
import java.util.ArrayList ;
import java.util.Map ;
import java.util.HashMap ;
import java.util.Set ;
import java.util.HashSet ;
import java.util.Iterator ;
import java.util.Comparator ;
import java.util.Properties ;
import java.util.regex.Pattern ;
import java.util.regex.Matcher ;
import java.awt.Point ;
import java.awt.Dimension ;
import javax.xml.parsers.DocumentBuilderFactory ;
import javax.xml.parsers.DocumentBuilder ;
import javax.xml.parsers.ParserConfigurationException ;
import javax.xml.transform.Transformer ;
import javax.xml.transform.TransformerException ;
import javax.xml.transform.TransformerConfigurationException ;
import javax.xml.transform.TransformerFactory ;
import javax.xml.transform.OutputKeys ;
import javax.xml.transform.dom.DOMSource ;
import javax.xml.transform.stream.StreamResult ;
import javax.xml.xpath.XPathExpressionException ;
import org.w3c.dom.Document ;
import org.w3c.dom.NodeList ;
import org.w3c.dom.Node ;
import org.w3c.dom.Element ;
import org.xml.sax.SAXException ;
import org.slf4j.Logger ;
import org.slf4j.LoggerFactory ;
import VASSAL.build.GameModule ;
import VASSAL.build.GpIdChecker ;
import VASSAL.build.module.GameState ;
import VASSAL.build.module.GameComponent ;
import VASSAL.build.module.ModuleExtension ;
import VASSAL.build.module.ObscurableOptions ;
import VASSAL.build.module.metadata.SaveMetaData ;
import VASSAL.build.widget.PieceSlot ;
import VASSAL.launch.BasicModule ;
import VASSAL.command.Command ;
import VASSAL.command.AddPiece ;
import VASSAL.command.RemovePiece ;
import VASSAL.command.ConditionalCommand ;
import VASSAL.command.AlertCommand ;
import VASSAL.build.module.map.boardPicker.Board ;
import VASSAL.counters.GamePiece ;
import VASSAL.counters.DynamicProperty ;
import VASSAL.counters.PieceCloner ;
import VASSAL.preferences.Prefs ;
import VASSAL.tools.DataArchive ;
import VASSAL.tools.DialogUtils ;
import VASSAL.tools.io.FileArchive ;
import VASSAL.tools.io.IOUtils ;
import VASSAL.tools.io.FastByteArrayOutputStream ;
import VASSAL.tools.io.ObfuscatingOutputStream ;
import VASSAL.tools.io.ZipArchive ;
import VASSAL.i18n.Resources ;
import vassal_shim.Snippet ;
import vassal_shim.GamePieceLabelFields ;
import vassal_shim.LabelArea ;
import vassal_shim.ReportNode ;
import vassal_shim.ModuleManagerMenuManager ;
import vassal_shim.Utils ;
// --------------------------------------------------------------------
public class VassalShim
{
private static final Logger logger = LoggerFactory.getLogger( VassalShim.class ) ;
private String baseDir ;
private Properties config ;
private String vmodFilename ;
private String boardsDir ;
public VassalShim( String vmodFilename, String boardsDir ) throws IOException
{
// initialize
this.vmodFilename = vmodFilename ;
this.boardsDir = boardsDir ;
// figure out where we live
baseDir = null ;
try {
String jarFilename = this.getClass().getProtectionDomain().getCodeSource().getLocation().toURI().getPath() ;
logger.debug( "Loaded from JAR: {}", jarFilename ) ;
baseDir = new File( jarFilename ).getParent() ;
logger.debug( "Base directory: {}", baseDir ) ;
} catch( URISyntaxException ex ) {
logger.error( "Can't locate JAR file:", ex ) ;
}
// load any config settings
config = new Properties() ;
if ( baseDir != null ) {
File configFile = new File( baseDir + File.separator + "vassal-shim.properties" ) ;
if ( configFile.isFile() ) {
logger.info( "Loading properties: {}", configFile.getAbsolutePath() ) ;
config.load( new FileInputStream( configFile ) ) ;
for ( String key: config.stringPropertyNames() )
logger.debug( "- {} = {}", key, config.getProperty(key) ) ;
}
}
// FUDGE! Need this to be able to load the VASL module :-/
logger.debug( "Creating the menu manager." ) ;
new ModuleManagerMenuManager() ;
// initialize VASL
logger.info( "Loading VASL module: {}", vmodFilename ) ;
if ( ! ((new File(vmodFilename)).isFile() ) )
throw new IllegalArgumentException( "Can't find VASL module: " + vmodFilename ) ;
DataArchive dataArchive = new DataArchive( vmodFilename ) ;
logger.debug( "- Initializing module." ) ;
BasicModule basicModule = new BasicModule( dataArchive ) ;
logger.debug( "- Installing module." ) ;
GameModule.init( basicModule ) ;
logger.debug( "- Loaded OK." ) ;
}
public void dumpScenario( String scenarioFilename ) throws IOException
{
// load the scenario and dump its commands
Command cmd = loadScenario( scenarioFilename ) ;
dumpCommand( cmd, "" ) ;
}
public void updateScenario( String scenarioFilename, String snippetsFilename, String saveFilename, String reportFilename )
throws IOException, ParserConfigurationException, SAXException, XPathExpressionException, TransformerException
{
// load the snippets supplied to us by the web server
Map<String,Snippet> snippets = parseSnippets( snippetsFilename ) ;
// NOTE: While we can get away with just disabling warnings about missing boards when dumping scenarios,
// they need to be present when we update a scenario, otherwise they get removed from the scenario :-/
logger.info( "Configuring boards directory: {}", boardsDir ) ;
Prefs prefs = GameModule.getGameModule().getPrefs() ;
String BOARD_DIR = "boardURL" ;
prefs.setValue( BOARD_DIR, new File(boardsDir) ) ;
// load the scenario
Command cmd = loadScenario( scenarioFilename ) ;
// NOTE: The call to execute() is what's causing the VASSAL UI to appear on-screen. If we take it out,
// label creation still works, but any boards and existing labels are not detected, presumably because
// their Command's need to be executed to take effect.
cmd.execute() ;
// extract the labels from the scenario
Map<String,GamePieceLabelFields> ourLabels = new HashMap<String,GamePieceLabelFields>() ;
ArrayList<GamePieceLabelFields> otherLabels = new ArrayList<GamePieceLabelFields>() ;
logger.info( "Searching the VASL scenario for labels..." ) ;
extractLabels( cmd, ourLabels, otherLabels ) ;
// update the labels from the snippets
Map< String, ArrayList<ReportNode> > labelReport = processSnippets( ourLabels, otherLabels, snippets ) ;
// save the scenario
saveScenario( saveFilename ) ;
// generate the report
generateLabelReport( labelReport, reportFilename ) ;
// NOTE: The test suite always dumps the scenario after updating it, so we could save a lot of time
// by dumping it here, thus avoiding the need to run this shim again to do the dump (and spinning up
// a JVM, initializing VASSAL/VASL, etc.) but it's probably worth doing things the slow way, to avoid
// any possible problems caused by reusing the current session (e.g. there might be some saved state somewhere).
}
private Map<String,Snippet> parseSnippets( String snippetsFilename ) throws IOException, ParserConfigurationException, SAXException, XPathExpressionException
{
logger.info( "Loading snippets: {}", snippetsFilename ) ;
Map<String,Snippet> snippets = new HashMap<String,Snippet>() ;
// load the snippets
DocumentBuilderFactory docBuilderFactory = DocumentBuilderFactory.newInstance() ;
DocumentBuilder docBuilder = docBuilderFactory.newDocumentBuilder() ;
Document doc = docBuilder.parse( new File( snippetsFilename ) ) ;
doc.getDocumentElement().normalize() ;
NodeList nodes = doc.getElementsByTagName( "snippet" ) ;
for ( int i=0 ; i < nodes.getLength() ; ++i ) {
Node node = nodes.item( i ) ;
if ( node.getNodeType() != Node.ELEMENT_NODE )
continue ;
Snippet snippet = new Snippet( (Element)node, config ) ;
logger.debug( "- Added snippet '{}' [{}x{}] (labelArea={}) (autoCreate={}):\n{}",
snippet.snippetId,
snippet.width, snippet.height,
snippet.labelArea,
snippet.autoCreate, snippet.content
) ;
snippets.put( snippet.snippetId, snippet ) ;
}
return snippets ;
}
private void extractLabels( Command cmd, Map<String,GamePieceLabelFields> ourLabels, ArrayList<GamePieceLabelFields> otherLabels )
{
// check if this command is a label we're interested in
if ( cmd instanceof AddPiece ) {
AddPiece addPieceCmd = (AddPiece) cmd ;
if ( addPieceCmd.getTarget() instanceof DynamicProperty ) {
GamePiece target = addPieceCmd.getTarget() ;
// NOTE: We can't check for target.getName() == "User-Labeled", it seems to get changed to the first label :shrug:
// yup - parse the label content
ArrayList<String> separators = new ArrayList<String>() ;
ArrayList<String> fields = new ArrayList<String>() ;
parseGamePieceState( target.getState(), separators, fields ) ;
// check if the label is one of ours
String snippetId = isVaslTemplatesLabel( fields, GamePieceLabelFields.FIELD_INDEX_LABEL1 ) ;
if ( snippetId != null ) {
logger.debug( "- Found label (1): {}", snippetId ) ;
ourLabels.put( snippetId,
new GamePieceLabelFields( target, separators, fields, GamePieceLabelFields.FIELD_INDEX_LABEL1 )
) ;
}
else {
snippetId = isVaslTemplatesLabel( fields, GamePieceLabelFields.FIELD_INDEX_LABEL2 ) ;
if ( snippetId != null ) {
logger.debug( "- Found label (2): {}", snippetId ) ;
ourLabels.put( snippetId,
new GamePieceLabelFields( target, separators, fields, GamePieceLabelFields.FIELD_INDEX_LABEL2 )
) ;
} else {
otherLabels.add(
new GamePieceLabelFields( target, separators, fields, -1 )
) ;
}
}
}
}
// extract labels in sub-commands
for ( Command c: cmd.getSubCommands() )
extractLabels( c, ourLabels, otherLabels ) ;
}
private String isVaslTemplatesLabel( ArrayList<String> fields, int fieldIndex )
{
// check if a label is one of ours
if ( fieldIndex >= fields.size() )
return null ;
Matcher matcher = Pattern.compile( "<!-- vasl-templates:id (.+?) " ).matcher(
fields.get( fieldIndex )
) ;
if ( ! matcher.find() )
return null ;
return matcher.group( 1 ) ;
}
private Map< String, ArrayList<ReportNode> >
processSnippets( Map<String,GamePieceLabelFields> ourLabels, ArrayList<GamePieceLabelFields> otherLabels, Map<String,Snippet> snippets )
{
// initialize
Map< String, ArrayList<ReportNode> > labelReport = new HashMap<String,ArrayList<ReportNode>>() ;
for ( String key: new String[]{"created","updated","deleted","unchanged"} )
labelReport.put( key, new ArrayList<ReportNode>() ) ;
// process each snippet
logger.info( "Processing snippets..." ) ;
Iterator< Map.Entry<String,Snippet> > iter = snippets.entrySet().iterator() ;
while( iter.hasNext() ) {
Map.Entry<String,Snippet> entry = iter.next() ;
String snippetId = entry.getKey() ;
Snippet snippet = entry.getValue() ;
if ( Utils.startsWith( snippetId, "extras/" ) ) {
logger.info( "- Skipping extras snippet: " + snippetId ) ;
continue ;
}
logger.debug( "- Processing snippet: {}", snippetId ) ;
// check if we have a label with a matching snippet ID
GamePieceLabelFields labelFields = ourLabels.get( snippetId ) ;
if ( labelFields != null ) {
logger.debug( " - Found matching label." ) ;
ourLabels.remove( snippetId ) ;
} else {
// nope - check if there is a legacy label that corresponds to this snippet
labelFields = findLegacyLabel( otherLabels, snippet ) ;
if ( labelFields != null )
logger.debug( " - Found matching legacy label." ) ;
else {
// nope - skip this snippet (we will create a new label for it later)
logger.debug( " - Couldn't find matching label." ) ;
continue ;
}
}
// we've match the snippet to a label, update the label content
String currState = labelFields.gamePiece().getState() ;
String snippetContent = snippet.content.replace( "\n", " " ) ;
String newState = labelFields.getNewGamePieceState( snippetContent ) ;
if ( currState.equals( newState ) ) {
logger.info( "- Skipping label (unchanged): {}", snippetId ) ;
labelReport.get( "unchanged" ).add( new ReportNode( snippetId, null ) ) ;
} else {
logger.info( "- Updating label: {}", snippetId ) ;
logger.debug( " - curr state: " + Utils.printableString(currState) ) ;
logger.debug( " - new state: " + Utils.printableString(newState) ) ;
labelFields.gamePiece().setState( newState ) ;
labelReport.get( "updated" ).add( new ReportNode( snippetId, null ) ) ;
}
iter.remove() ;
}
// delete excess labels
// NOTE: This will only affect labels that have a snippet ID i.e. legacy labels will be left in place.
for ( String snippetId: ourLabels.keySet() ) {
if ( Utils.startsWith( snippetId, "extras/" ) )
continue ;
logger.info( "- Deleting label: {}", snippetId ) ;
GamePieceLabelFields labelFields = ourLabels.get( snippetId ) ;
RemovePiece cmd = new RemovePiece( labelFields.gamePiece() ) ;
cmd.execute() ;
labelReport.get( "deleted" ).add( new ReportNode( snippetId, null ) ) ;
}
// We now want to create new labels for any snippets left that haven't already been processed.
//
// We divide the map into several areas:
// +------------------------------------------+
// | GENERAL |
// +------------+----------------+------------+
// | | | |
// | PLAYER 1 | board(s) | PLAYER 2 |
// | | | |
// | |----------------| +
// | | OVERFLOW | |
// +------------------------------------------+
// Non-player specific labels (e.g. SCENARIO and SSR) go into GENERAL, player-specific labels
// go into their respective areas, and everything else left over that didn't fit into their
// normal area goes into OVERFLOW.
//
// The exception to this is if the scenario contains no boards, in which case we just create
// a single GENERAL area that spans the entire available space.
//
// NOTE: We don't consider any labels that might already be present in the scenario. While we could
// handle this, it would slow down an already slow process i.e. the web server would have to dump
// the scenario, extract any existing labels, calculate their size, then pass that information
// back to us, so that we can take them into account when placing new labels (which would also
// then become much more complicated). It's just not worth it for something that will rarely happen.
// locate the PieceSlot we will use to create labels
String labelGpid = config.getProperty( "LABEL_GPID", "6295" ) ;
logger.debug( "- Locating PieceSlot: gpid={}", labelGpid ) ;
PieceSlot labelPieceSlot = null ;
GpIdChecker gpidChecker = new GpIdChecker() ;
for ( PieceSlot pieceSlot : GameModule.getGameModule().getAllDescendantComponentsOf( PieceSlot.class ) ) {
if ( pieceSlot.getGpId().equals( labelGpid ) ) {
labelPieceSlot = pieceSlot ;
break ;
}
}
if ( labelPieceSlot == null )
throw new IllegalArgumentException( "Can't find PieceSlot: gpid=" + labelGpid ) ;
// initialize our LabelArea's
int xMargin = Integer.parseInt( config.getProperty( "AUTOCREATE_LABEL_XMARGIN", "20" ) ) ;
int yMargin = Integer.parseInt( config.getProperty( "AUTOCREATE_LABEL_YMARGIN", "20" ) ) ;
Map< String, LabelArea > labelAreas = new HashMap<String,LabelArea>() ;
VASSAL.build.module.Map map ;
List<VASSAL.build.module.Map> maps = VASSAL.build.module.Map.getMapList() ;
if ( maps.size() > 1 )
logger.warn( "WARNING: Found multiple maps - using the first one." ) ;
map = maps.get( 0 ) ;
if ( map.getBoardCount() == 0 )
// the scenario doesn't contain any boards - we create a single GENERAL area that spans
// the entire map (we assume a single board width, and unlimited height)
labelAreas.put( "general",
new LabelArea( "general", new Point(xMargin,yMargin), 2500, 99999, xMargin, yMargin )
) ;
else {
// get the total amount of space available
Dimension mapSize = map.mapSize() ;
int mapWidth = mapSize.width ;
int mapHeight = mapSize.height ;
// get the amount of empty space around the boards
Dimension edgeBuffer = map.getEdgeBuffer() ;
int borderWidth = edgeBuffer.width ;
int borderHeight = edgeBuffer.height ;
labelAreas.put( "general",
new LabelArea( "general",
new Point( xMargin, yMargin ),
mapWidth-2*xMargin, borderHeight-2*yMargin,
xMargin, yMargin
)
) ;
labelAreas.put( "player1",
new LabelArea( "player1",
new Point( xMargin, borderHeight ),
borderWidth-2*xMargin, mapHeight-borderHeight,
xMargin, yMargin
)
) ;
labelAreas.put( "player2",
new LabelArea( "player2",
new Point( mapWidth-borderWidth+xMargin, borderHeight ),
borderWidth-2*xMargin, mapHeight-borderHeight,
xMargin, yMargin
)
) ;
labelAreas.put( "overflow",
new LabelArea( "overflow",
new Point( borderWidth, mapHeight-borderHeight+yMargin ),
mapWidth-2*borderWidth, 99999, // nb: unlimited height
xMargin, yMargin
)
) ;
}
// figure out what order to create the labels
String snippetOrder = config.getProperty( "AUTOCREATE_LABEL_ORDER",
"scenario players scenario_note* victory_conditions ssr ob_setup_1* ob_note_1* ob_vehicles_1 ob_ordnance_1 ob_setup_2* ob_note_2* ob_vehicles_2 ob_ordnance_2"
) ;
logger.debug( "Snippet order: {}", snippetOrder ) ;
Set<String> snippetsKeySet = new HashSet<String>( snippets.keySet() ) ;
ArrayList<String> snippetIds = new ArrayList<String>() ;
for ( String snippetId: snippetOrder.split( "\\s+" ) ) {
if ( snippetId.charAt( snippetId.length()-1 ) == '*' ) {
// this is a wildcard snippet ID - find all matching snippets
ArrayList<String> matches = new ArrayList<String>() ;
String snippetIdStem = snippetId.substring( 0, snippetId.length()-1 ) ;
Iterator<String> iter2 = snippetsKeySet.iterator() ;
while( iter2.hasNext() ) {
String sid = iter2.next() ;
if ( Utils.startsWith( sid, snippetIdStem ) ) {
matches.add( sid ) ;
iter2.remove() ;
}
}
Collections.sort( matches, new Comparator<String>() {
public int compare( String lhs, String rhs ) {
// NOTE: These snippet ID's have the form "xyz.1", "xyz.2", etc. - we sort by the trailing number.
int pos = lhs.lastIndexOf( '.' ) ;
int lhsVal = Integer.parseInt( lhs.substring( pos+1 ) ) ;
pos = rhs.lastIndexOf( '.' ) ;
int rhsVal = Integer.parseInt( rhs.substring( pos+1 ) ) ;
if ( lhsVal == rhsVal )
return 0 ;
else
return lhsVal < rhsVal ? -1 : +1 ;
}
} ) ;
for ( String sid: matches )
snippetIds.add( sid ) ;
} else {
// this is a normal snippet ID - add it to the list (if present)
if ( snippetsKeySet.contains( snippetId ) ) {
snippetIds.add( snippetId ) ;
snippetsKeySet.remove( snippetId ) ;
}
}
}
// add any leftovers
for ( String snippetId: snippetsKeySet )
snippetIds.add( snippetId ) ;
// create new labels
String forceNewRowForVal = config.getProperty( "AUTOCREATE_LABEL_FORCE_NEW_ROW_FOR",
"ob_setup_1.1 ob_note_1.1 ob_vehicles|ordnance_1 ob_setup_2.1 ob_note_2.1 ob_vehicles|ordnance_2"
) ;
logger.debug( "Force new row for: {}", forceNewRowForVal ) ;
Set<String> forceNewRowFor = new HashSet<String>(
Arrays.asList( forceNewRowForVal.split( "\\s+" ) )
) ;
logger.info( "Creating labels..." ) ;
for ( String snippetId: snippetIds ) {
// get the next snippet
Snippet snippet = snippets.get( snippetId ) ;
if ( snippet == null ) {
logger.info( "- WARNING: Couldn't find a snippet for '{}'.", snippetId ) ;
continue ;
}
if ( ! snippet.autoCreate ) {
logger.debug( "- Auto-create disabled for '{}'.", snippetId ) ;
continue ;
}
if ( snippet.content.length() == 0 ) {
logger.info( "- Skipping label creation for '{}' - no content.", snippetId ) ;
continue ;
}
// figure out where to put the new label
LabelArea labelArea = labelAreas.get( snippet.labelArea ) ;
if ( labelArea == null )
labelArea = labelAreas.get( "general" ) ;
if ( isForceNewRow( forceNewRowFor, snippetId ) )
labelArea.startNewRow( snippetId ) ;
Point pos = labelArea.getNextPosition( snippetId, snippet.width, snippet.height ) ;
if ( pos == null ) {
LabelArea labelArea2 = labelAreas.get( "overflow" ) ;
pos = labelArea2.getNextPosition( snippetId, snippet.width, snippet.height ) ;
if ( pos == null )
throw new RuntimeException( "LabelArea '" + labelArea.getName() + "' and 'overflow' are full!" ) ;
}
// create the label
// NOTE: This is a bit of a hack :-/ We generate a new GamePiece from the PieceSlot, which gives us a label
// loaded with default values. We then replace these default values with our values, and then add
// the GamePiece to the game. This will break if the default values ever change, but that's unlikely to happen.
logger.info( "- Creating label '{}' at [{},{}].", snippetId, pos.x, pos.y ) ;
GamePiece gamePiece = labelPieceSlot.getPiece() ;
gamePiece = PieceCloner.getInstance().clonePiece( gamePiece ) ; // nb: the piece needs to be "expanded"
String defaultUserName = config.getProperty( "DEFAULT_LABEL_USERNAME", "David Sullivan" ) ;
String defaultLabelText1 = config.getProperty( "DEFAULT_LABEL_TEXT1", "Label" ) ;
String defaultLabelText2 = config.getProperty( "DEFAULT_LABEL_TEXT2", "no background" ) ;
String snippetContent = snippet.content.replace( "\n", " " ) ;
gamePiece.setState(
gamePiece.getState().replace( "\t"+defaultUserName+"\\", "\tvasl-templates\\" )
.replace( "\t"+defaultLabelText1+"\\", "\t" + snippetContent + "\\" )
.replace( "\t"+defaultLabelText2+"\\", "\t\\" )
.replace( "\tnull;0;0", "\tMap0;" + makeVassalCoordString(pos,snippet) )
) ;
GameModule.getGameModule().getGameState().addPiece( gamePiece ) ;
labelReport.get( "created" ).add( new ReportNode( snippetId, pos ) ) ;
}
return labelReport ;
}
private GamePieceLabelFields findLegacyLabel( ArrayList<GamePieceLabelFields> otherLabels, Snippet snippet )
{
// NOTE: We match snippets with labels via a snippet ID, stored in the HTML fragments in a special
// "<!-- vasl-templates:id ... -->" comment. However, for labels created with older versions of vasl-templates,
// this comment won't be present, so we try to match labels based on the raw content the user entered
// in the UI of the main program.
// NOTE: Since we are dealing with labels that don't have a snippet ID, the GamePieceLabelField's won't have
// their fieldIndex set. We set this if and when we match a legacy label, but we don't handle the case
// where some phrases are found in label1 and some in label2 :-/ It doesn't really matter which one we use,
// since one of the fields will be used to store the snippet, and the other one will be blanked out.
int fieldIndex = -1 ;
// check each label and record which ones match the snippets's raw content
ArrayList<GamePieceLabelFields> matches = new ArrayList<GamePieceLabelFields>() ;
for ( GamePieceLabelFields labelFields: otherLabels ) {
// check if all the snippet raw content phrases are present in the label
if ( snippet.rawContent.size() == 0 ) {
// nb: we can get here for snippets that are always passed through, even if they have no content
continue ;
}
boolean allFound = true ;
for ( String phrase: snippet.rawContent ) {
phrase = phrase.replace( "\n", " " ) ;
String labelContent = labelFields.getLabelContent( GamePieceLabelFields.FIELD_INDEX_LABEL1 ) ;
if ( labelContent != null && labelContent.indexOf( phrase ) >= 0 ) {
fieldIndex = GamePieceLabelFields.FIELD_INDEX_LABEL1 ;
continue ;
}
labelContent = labelFields.getLabelContent( GamePieceLabelFields.FIELD_INDEX_LABEL2 ) ;
if ( labelContent != null && labelContent.indexOf( phrase ) >= 0 ) {
fieldIndex = GamePieceLabelFields.FIELD_INDEX_LABEL2 ;
continue ;
}
allFound = false ;
break ;
}
// yup - all phrases were found, record the label as a match
if ( allFound )
matches.add( labelFields ) ;
}
// NOTE: Exactly one label must match for us to consider it a match (i.e. if there are
// multiple matches, we do nothing and leave it to the user to sort it out).
if ( matches.size() == 1 ) {
GamePieceLabelFields labelFields = matches.get( 0 ) ;
labelFields.setFieldIndex( fieldIndex ) ;
return labelFields ;
}
return null ;
}
private void generateLabelReport( Map<String,ArrayList<ReportNode>> labelReport, String reportFilename )
throws TransformerException, TransformerConfigurationException, ParserConfigurationException, FileNotFoundException
{
// generate the report
Document doc = DocumentBuilderFactory.newInstance().newDocumentBuilder().newDocument() ;
Element rootElem = doc.createElement( "report" ) ;
doc.appendChild( rootElem ) ;
boolean wasModified = false ;
for ( String key: labelReport.keySet() ) {
ArrayList<ReportNode> reportNodes = labelReport.get( key ) ;
Element elem = doc.createElement( key ) ;
for ( ReportNode reportNode: reportNodes ) {
Element reportNodeElem = doc.createElement( "label" ) ;
reportNodeElem.setAttribute( "id", reportNode.snippetId ) ;
if ( reportNode.labelPos != null ) {
reportNodeElem.setAttribute( "x", Integer.toString( reportNode.labelPos.x ) ) ;
reportNodeElem.setAttribute( "y", Integer.toString( reportNode.labelPos.y ) ) ;
}
elem.appendChild( reportNodeElem ) ;
if ( ! key.equals( "unchanged" ) )
wasModified = true ;
}
rootElem.appendChild( elem ) ;
}
rootElem.setAttribute( "wasModified", wasModified?"true":"false" ) ;
// save the report
Transformer trans = TransformerFactory.newInstance().newTransformer() ;
trans.setOutputProperty( OutputKeys.INDENT, "yes" ) ;
trans.setOutputProperty( "{http://xml.apache.org/xslt}indent-amount", "4" ) ;
trans.setOutputProperty( OutputKeys.METHOD, "xml" ) ;
trans.setOutputProperty( OutputKeys.ENCODING, "UTF-8" ) ;
trans.transform( new DOMSource(doc), new StreamResult(new FileOutputStream(reportFilename)) ) ;
}
private boolean isForceNewRow( Set<String> forceNewRowFor, String snippetId )
{
// check if we should start a new row when creating labels
if ( forceNewRowFor.contains( snippetId ) )
return true ;
// FUDGE! To handle the case where an OB has only vehicles or ordnance, we recognize
// this special pseudo-snippet ID.
if ( Utils.startsWith( snippetId, "ob_vehicles_" ) || Utils.startsWith( snippetId, "ob_ordnance_" ) ) {
String playerId = snippetId.substring( snippetId.length() - 1 ) ;
if ( forceNewRowFor.contains( "ob_vehicles|ordnance_" + playerId ) ) {
// remove the pseudo-snippet ID, so that it doesn't match the other V/O snippet
forceNewRowFor.remove( "ob_vehicles|ordnance_" + playerId ) ;
return true ;
}
}
return false ;
}
private String makeVassalCoordString( Point pos, Snippet snippet )
{
// FUDGE! VASSAL positions labels by the X/Y co-ords of the label's centre (!)
return Integer.toString( pos.x + snippet.width/2 ) + ";" + Integer.toString( pos.y + snippet.height/2 ) ;
}
private void saveScenario( String saveFilename ) throws IOException
{
// disable the dialog asking for log file comments
Prefs prefs = GameModule.getGameModule().getPrefs() ;
String PROMPT_LOG_COMMENT = "promptLogComment";
prefs.setValue( PROMPT_LOG_COMMENT, false ) ;
// FUDGE! We would like to just call GameState.saveGame(), but it calls getRestoreCommand(),
// which does nothing unless the "save game" menu action has been enabled!?! Due to Java protections,
// there doesn't seem to be any way to get at this object and enable it, so we have to re-implement
// the whole saveGame() code without this check :-/
// get the save string
Command cmd = getRestoreCommand() ;
String saveString = GameModule.getGameModule().encode( cmd ) ;
// save the scenario
logger.info( "Saving scenario: {}", saveFilename ) ;
final FastByteArrayOutputStream ba = new FastByteArrayOutputStream() ;
OutputStream out = null ;
try {
out = new ObfuscatingOutputStream( ba ) ;
out.write( saveString.getBytes( "UTF-8" ) ) ;
out.close() ;
}
finally {
IOUtils.closeQuietly( out ) ;
}
FileArchive archive = null ;
try {
archive = new ZipArchive( new File( saveFilename ) ) ;
String SAVEFILE_ZIP_ENTRY = "savedGame" ; //$NON-NLS-1$
archive.add( SAVEFILE_ZIP_ENTRY, ba.toInputStream() ) ;
(new SaveMetaData()).save( archive ) ;
archive.close() ;
}
finally {
IOUtils.closeQuietly( archive ) ;
}
}
private static Command getRestoreCommand() // nb: taken from GameState.getRestoreCommand()
{
// NOTE: This is the check that's causing the problem :-/
// if (!saveGame.isEnabled()) {
// return null;
// }
GameState gameState = GameModule.getGameModule().getGameState() ;
Command c = new GameState.SetupCommand(false);
c.append(checkVersionCommand());
c.append( gameState.getRestorePiecesCommand() );
for (GameComponent gc : gameState.getGameComponents()) {
c.append(gc.getRestoreCommand());
}
c.append(new GameState.SetupCommand(true));
return c;
}
private static Command checkVersionCommand() {
// NOTE: This is the same as GameState.checkVersionCommand(), but we can't call that since it's private :-/
String runningVersion = GameModule.getGameModule().getAttributeValueString(GameModule.VASSAL_VERSION_RUNNING);
ConditionalCommand.Condition cond = new ConditionalCommand.Lt(GameModule.VASSAL_VERSION_RUNNING, runningVersion);
Command c = new ConditionalCommand(new ConditionalCommand.Condition[]{cond}, new AlertCommand(Resources.getString("GameState.version_mismatch", runningVersion))); //$NON-NLS-1$
String moduleName = GameModule.getGameModule().getAttributeValueString(GameModule.MODULE_NAME);
String moduleVersion = GameModule.getGameModule().getAttributeValueString(GameModule.MODULE_VERSION);
cond = new ConditionalCommand.Lt(GameModule.MODULE_VERSION, moduleVersion);
c.append(new ConditionalCommand(new ConditionalCommand.Condition[]{cond}, new AlertCommand(Resources.getString("GameState.version_mismatch2", moduleName, moduleVersion )))); //$NON-NLS-1$
return c;
}
private Command loadScenario( String scenarioFilename ) throws IOException
{
// load the scenario
disableBoardWarnings() ;
logger.info( "Loading scenario: {}", scenarioFilename ) ;
return GameModule.getGameModule().getGameState().decodeSavedGame(
new File( scenarioFilename )
) ;
}
private static void dumpCommand( Command cmd, String prefix )
{
// dump the command
StringBuilder buf = new StringBuilder() ;
buf.append( prefix + cmd.getClass().getSimpleName() ) ;
String details = cmd.getDetails() ;
if ( details != null )
buf.append( " [" + details + "]" ) ;
if ( cmd instanceof AddPiece )
dumpCommandExtras( (AddPiece)cmd, buf, prefix ) ;
else if ( cmd instanceof GameState.SetupCommand )
dumpCommandExtras( (GameState.SetupCommand)cmd, buf, prefix ) ;
else if ( cmd instanceof ModuleExtension.RegCmd )
dumpCommandExtras( (ModuleExtension.RegCmd)cmd, buf, prefix ) ;
else if ( cmd instanceof ObscurableOptions.SetAllowed )
dumpCommandExtras( (ObscurableOptions.SetAllowed)cmd, buf, prefix ) ;
System.out.println( buf.toString() ) ;
// dump any sub-commands
prefix += " " ;
for ( Command c: cmd.getSubCommands() )
dumpCommand( c, prefix ) ;
}
private static void dumpCommandExtras( AddPiece cmd, StringBuilder buf, String prefix )
{
// dump extra command info
GamePiece target = cmd.getTarget() ;
buf.append( ": " + target.getClass().getSimpleName() ) ;
if ( target.getName().length() > 0 )
buf.append( "/" + target.getName() ) ;
// check if this is a command we're interested in
// NOTE: We used to support VASL 6.3.3, but when we create labels, they're of type Hideable. It would be easy enough
// to add that here, but 6.3.3 is pretty old (2.5 years), so it's safer to just drop it from the list of supported versions.
if ( !( target instanceof DynamicProperty ) )
return ;
if ( ! target.getName().equals( "User-Labeled" ) )
return ;
// dump extra command info
ArrayList<String> separators = new ArrayList<String>() ;
ArrayList<String> fields = new ArrayList<String>() ;
parseGamePieceState( cmd.getState(), separators, fields ) ;
for ( String field: fields ) {
buf.append( "\n" + prefix + "- " ) ;
if ( field.length() > 0 )
buf.append( Utils.printableString( field ) ) ;
else
buf.append( "<empty>" ) ;
}
}
private static void dumpCommandExtras( GameState.SetupCommand cmd, StringBuilder buf, String prefix )
{
// dump extra command info
buf.append( ": starting=" + cmd.isGameStarting() ) ;
}
private static void dumpCommandExtras( ModuleExtension.RegCmd cmd, StringBuilder buf, String prefix )
{
// dump extra command info
buf.append( ": " + cmd.getName() + " (" + cmd.getVersion() + ")" ) ;
}
private static void dumpCommandExtras( ObscurableOptions.SetAllowed cmd, StringBuilder buf, String prefix )
{
// dump extra command info
buf.append( ": " + cmd.getAllowedIds() ) ;
}
private static void parseGamePieceState( String state, ArrayList<String> separators, ArrayList<String> fields )
{
// parse the GamePiece state
Matcher matcher = Pattern.compile( "\\\\+\t" ).matcher( state ) ;
int pos = 0 ;
while( matcher.find() ) {
separators.add( matcher.group() ) ;
fields.add( state.substring( pos, matcher.start() ) ) ;
pos = matcher.end() ;
}
fields.add( state.substring( pos ) ) ;
}
private void disableBoardWarnings()
{
// FUDGE! VASSAL shows a GUI error dialog warning about boards not being found, and while these can be disabled,
// the key used to enable/disable them is derived from the board filename :-( ASLBoardPicker catches
// the FileNotFoundException thrown by ZipArchive when it can't find a file, and then calls ReadErrorDialog.error(),
// which calls WarningDialog.showDisableable(), using the following as the key:
// (Object) ( e.getClass().getName() + "@" + filename )
// This means we have to set the "warning disabled" flag for every possible board :-/
// disable warnings for boards 00-99
logger.info( "Disabling board warnings for bd00-99." ) ;
for ( int i=0 ; i < 100 ; ++i )
disableBoardWarning( String.format( "bd%02d", i ) ) ;
// disable warnings for additional standard boards
logger.info( "Disabling board warnings for other standard boards:" ) ;
InputStream inputStream = this.getClass().getResourceAsStream( "/data/boardNames.txt" ) ;
disableBoardWarnings( inputStream, "<standard>" ) ;
// disable warnings for user-defined boards
if ( baseDir != null ) {
String fname = baseDir + File.separator + "boardNames.txt" ;
inputStream = null ;
try {
inputStream = new FileInputStream( fname ) ;
} catch( FileNotFoundException ex ) { }
if ( inputStream != null ) {
logger.info( "Disabling board warnings for user-defined boards: " + fname ) ;
disableBoardWarnings( inputStream, fname ) ;
}
}
}
private void disableBoardWarnings( InputStream inputStream, String boardFilename )
{
// disable warnings for boards listed in a file
BufferedReader reader = new BufferedReader( new InputStreamReader( inputStream ) ) ;
String lineBuf ;
try {
while ( (lineBuf = reader.readLine() ) != null ) {
lineBuf = lineBuf.trim() ;
if ( lineBuf.length() == 0 || lineBuf.charAt(0) == '#' || lineBuf.charAt(0) == ';' || lineBuf.substring(0,2).equals("//") )
continue ;
logger.debug( "- {}", lineBuf ) ;
disableBoardWarning( lineBuf ) ;
}
} catch( IOException ex ) {
logger.error( "Error reading board file: {}", boardFilename, ex ) ;
}
}
private void disableBoardWarning( String boardName )
{
// disable warnings for the specified board
String boardsPath = (new File(vmodFilename)).getParent() + File.separator + "boards" ;
String key = "java.io.FileNotFoundException@" + boardsPath + File.separator + boardName ;
DialogUtils.setDisabled( key, true ) ;
}
}
Loading…
Cancel
Save