Compare commits

..

No commits in common. 'master' and 'v1.3' have entirely different histories.
master ... v1.3

  1. 1
      .dockerignore
  2. 100
      .pylintrc
  3. 81
      Dockerfile
  4. 2
      MANIFEST.in
  5. 9
      README.md
  6. BIN
      chapter-h/chapter-h-placeholders.zip
  7. 193
      conftest.py
  8. 1
      docker/config/.gitignore
  9. 5
      docker/config/debug.cfg
  10. 31
      docker/config/logging.yaml
  11. 1
      docker/config/site.cfg
  12. 11
      docker/run.sh
  13. BIN
      examples/Hill 621 (Scenario E) (online).vsav
  14. 32
      examples/Hill 621 (Scenario E).json
  15. BIN
      examples/Hill 621 (Scenario E).png
  16. BIN
      examples/Hill 621 (Scenario E).small.jpg
  17. BIN
      examples/Hill 621 (Scenario E).vsav
  18. BIN
      examples/Hube's Pocket (Scenario G) (online).vsav
  19. 30
      examples/Hube's Pocket (Scenario G).json
  20. BIN
      examples/Hube's Pocket (Scenario G).png
  21. BIN
      examples/Hube's Pocket (Scenario G).small.jpg
  22. BIN
      examples/Hube's Pocket (Scenario G).vsav
  23. 2
      examples/README.md
  24. BIN
      examples/The Streets Of Stalingrad (Scenario C) (online).vsav
  25. 30
      examples/The Streets Of Stalingrad (Scenario C).json
  26. BIN
      examples/The Streets Of Stalingrad (Scenario C).png
  27. BIN
      examples/The Streets Of Stalingrad (Scenario C).small.jpg
  28. BIN
      examples/The Streets Of Stalingrad (Scenario C).vsav
  29. 308
      freeze.py
  30. BIN
      loader/assets/loading.gif
  31. 107
      loader/freeze.py
  32. 207
      loader/main.py
  33. 13
      requirements-dev.txt
  34. 15
      requirements.txt
  35. 540
      run-container.sh
  36. 20
      setup.py
  37. 6
      tools/build_file.py
  38. 28
      vasl_templates/about.py
  39. 19
      vasl_templates/file_dialog.py
  40. 106
      vasl_templates/main.py
  41. 46
      vasl_templates/main_window.py
  42. 15
      vasl_templates/server_settings.py
  43. 6
      vasl_templates/tools/check_connect_roar.py
  44. 4
      vasl_templates/tools/dump_log_file_analysis.py
  45. 83
      vasl_templates/tools/get_piece_info.py
  46. 38
      vasl_templates/tools/make_chapter_h_placeholders.py
  47. 45
      vasl_templates/tools/tests/fixtures/chapter-h-placeholders.txt
  48. 3
      vasl_templates/tools/tests/test_make_chapter_h_placeholders.py
  49. 26
      vasl_templates/tools/webdriver_stress_test.py
  50. 73
      vasl_templates/ui/about.ui
  51. 26
      vasl_templates/ui/server_settings.ui
  52. 52
      vasl_templates/utils.py
  53. 4
      vasl_templates/web_channel.py
  54. 212
      vasl_templates/webapp/__init__.py
  55. 3
      vasl_templates/webapp/config/constants.py
  56. 10
      vasl_templates/webapp/config/debug.cfg.example
  57. 44
      vasl_templates/webapp/config/logging.yaml.example
  58. 6
      vasl_templates/webapp/config/site.cfg.example
  59. 2
      vasl_templates/webapp/data/asl-scenario-archive.json
  60. 9
      vasl_templates/webapp/data/default-template-pack/atmm-romanian.j2
  61. 4
      vasl_templates/webapp/data/default-template-pack/atmm.j2
  62. 12
      vasl_templates/webapp/data/default-template-pack/baz-cpva16.j2
  63. 12
      vasl_templates/webapp/data/default-template-pack/baz-cpva17.j2
  64. 16
      vasl_templates/webapp/data/default-template-pack/baz.j2
  65. 16
      vasl_templates/webapp/data/default-template-pack/baz45.j2
  66. 16
      vasl_templates/webapp/data/default-template-pack/baz50.j2
  67. 3
      vasl_templates/webapp/data/default-template-pack/common.css
  68. 5
      vasl_templates/webapp/data/default-template-pack/compass.j2
  69. 41
      vasl_templates/webapp/data/default-template-pack/extras/booby-traps.j2
  70. 6
      vasl_templates/webapp/data/default-template-pack/extras/count-remaining.j2
  71. 20
      vasl_templates/webapp/data/default-template-pack/extras/grid.j2
  72. 2
      vasl_templates/webapp/data/default-template-pack/extras/kakazu-ridge-cave-complexes.j2
  73. 87
      vasl_templates/webapp/data/default-template-pack/extras/kgs.j2
  74. 49
      vasl_templates/webapp/data/default-template-pack/extras/kgs/grenade-bundles.j2
  75. 57
      vasl_templates/webapp/data/default-template-pack/extras/kgs/molotov-cocktails.j2
  76. 2
      vasl_templates/webapp/data/default-template-pack/extras/victory-points.j2
  77. 14
      vasl_templates/webapp/data/default-template-pack/mol-p.j2
  78. 8
      vasl_templates/webapp/data/default-template-pack/mol.j2
  79. 17
      vasl_templates/webapp/data/default-template-pack/nat_caps.j2
  80. 242
      vasl_templates/webapp/data/default-template-pack/national-capabilities.json
  81. 23
      vasl_templates/webapp/data/default-template-pack/nationalities.json
  82. 2
      vasl_templates/webapp/data/default-template-pack/ob_ma_notes.j2
  83. 2
      vasl_templates/webapp/data/default-template-pack/ob_vo.j2
  84. 5
      vasl_templates/webapp/data/default-template-pack/ob_vo_note.css
  85. 2
      vasl_templates/webapp/data/default-template-pack/ob_vo_note.j2
  86. 21
      vasl_templates/webapp/data/default-template-pack/pf-finnish.j2
  87. 20
      vasl_templates/webapp/data/default-template-pack/pf-hungarian.j2
  88. 19
      vasl_templates/webapp/data/default-template-pack/pf-romanian.j2
  89. 30
      vasl_templates/webapp/data/default-template-pack/pf.j2
  90. 12
      vasl_templates/webapp/data/default-template-pack/piat.j2
  91. 2
      vasl_templates/webapp/data/default-template-pack/player_flag.include
  92. 2
      vasl_templates/webapp/data/default-template-pack/player_flag_large.include
  93. 4
      vasl_templates/webapp/data/default-template-pack/players.j2
  94. 12
      vasl_templates/webapp/data/default-template-pack/psk.j2
  95. 4
      vasl_templates/webapp/data/default-template-pack/scenario.j2
  96. 2
      vasl_templates/webapp/data/default-template-pack/ssr.j2
  97. 8
      vasl_templates/webapp/data/default-template-pack/thh.j2
  98. 116
      vasl_templates/webapp/data/default-template-pack/turn_track.j2
  99. 0
      vasl_templates/webapp/data/expected-multiple-images.json
  100. 4
      vasl_templates/webapp/data/extensions/bfp-bj-v403.json
  101. Some files were not shown because too many files have changed in this diff Show More

@ -3,6 +3,5 @@
! setup.py
! requirements*.txt
! vasl_templates/
! vassal-shim/release/
! docker/
! LICENSE.txt

@ -7,7 +7,7 @@ extension-pkg-whitelist=PyQt5
# Add files or directories to the blacklist. They should be base names, not
# paths.
ignore=generated
ignore=CVS
# Add files or directories matching the regex patterns to the blacklist. The
# regex matches against base names, not paths.
@ -18,7 +18,7 @@ ignore-patterns=
#init-hook=
# Use multiple processes to speed up Pylint.
jobs=4
jobs=1
# List of plugins (as comma separated values of python modules names) to load,
# usually to register additional checkers.
@ -54,14 +54,88 @@ confidence=
# --enable=similarities". If you want to run only the classes checker, but have
# no Warning level messages displayed, use"--disable=all --enable=classes
# --disable=W"
disable=
disable=print-statement,
parameter-unpacking,
unpacking-in-except,
old-raise-syntax,
backtick,
long-suffix,
old-ne-operator,
old-octal-literal,
import-star-module-level,
non-ascii-bytes-literal,
invalid-unicode-literal,
raw-checker-failed,
bad-inline-option,
locally-disabled,
locally-enabled,
file-ignored,
suppressed-message,
useless-suppression,
deprecated-pragma,
apply-builtin,
basestring-builtin,
buffer-builtin,
cmp-builtin,
coerce-builtin,
execfile-builtin,
file-builtin,
long-builtin,
raw_input-builtin,
reduce-builtin,
standarderror-builtin,
unicode-builtin,
xrange-builtin,
coerce-method,
delslice-method,
getslice-method,
setslice-method,
no-absolute-import,
old-division,
dict-iter-method,
dict-view-method,
next-method-called,
metaclass-assignment,
indexing-exception,
raising-string,
reload-builtin,
oct-method,
hex-method,
nonzero-method,
cmp-method,
input-builtin,
round-builtin,
intern-builtin,
unichr-builtin,
map-builtin-not-iterating,
zip-builtin-not-iterating,
range-builtin-not-iterating,
filter-builtin-not-iterating,
using-cmp-argument,
eq-without-hash,
div-method,
idiv-method,
rdiv-method,
exception-message-attribute,
invalid-str-codec,
sys-max-int,
bad-python3-import,
deprecated-string-function,
deprecated-str-translate-call,
deprecated-itertools-function,
deprecated-types-field,
next-method-defined,
dict-items-not-iterating,
dict-keys-not-iterating,
dict-values-not-iterating,
deprecated-operator-function,
deprecated-urllib-function,
xreadlines-attribute,
deprecated-sys-function,
exception-escape,
comprehension-escape,
bad-whitespace,
bad-continuation,
invalid-name,
wrong-import-position,
global-statement,
@ -70,14 +144,7 @@ disable=
duplicate-code, # can't get it to shut up about @pytest.mark.skipif's :-/
no-else-return,
len-as-condition,
consider-using-enumerate,
import-outside-toplevel,
isinstance-second-argument-not-valid-type,
consider-using-f-string,
consider-using-max-builtin,
use-implicit-booleaness-not-comparison,
unnecessary-lambda-assignment,
duplicate-string-formatting-argument,
consider-using-enumerate
# Enable the message, report, category or checker with the given id(s). You can
# either give multiple identifier separated by comma (,) or put this option
@ -222,6 +289,13 @@ max-line-length=120
# Maximum number of lines in a module
max-module-lines=1000
# List of optional constructs for which whitespace checking is disabled. `dict-
# separator` is used to allow tabulation in dicts, etc.: {1 : 1,\n222: 2}.
# `trailing-comma` allows a space between comma and closing bracket: (a, ).
# `empty-line` allows space-only lines.
no-space-check=trailing-comma,
dict-separator
# Allow the body of a class to be on the same line as the declaration if body
# contains single statement.
single-line-class-stmt=no
@ -437,7 +511,7 @@ max-public-methods=20
max-returns=10
# Maximum number of statements in function / method body
max-statements=100
max-statements=80
# Minimum number of public methods for a class (see R0903).
min-public-methods=2
@ -480,4 +554,4 @@ known-third-party=enchant
# Exceptions that will emit a warning when being caught. Defaults to
# "Exception"
overgeneral-exceptions=builtins.Exception
overgeneral-exceptions=Exception

@ -1,49 +1,29 @@
# NOTE: Use the run-container.sh script to build and launch this container.
# NOTE: Multi-stage builds require Docker >= 17.05.
FROM rockylinux:9.1 AS base
# We do a multi-stage build (requires Docker >= 17.05) to install everything, then copy it all
# to the final target image.
# update packages and install requirements
RUN dnf -y upgrade-minimal && \
dnf install -y python3.11
# NOTE: We don't need the following stuff for the build step, but it's nice to not have to re-install
# it all every time we change the requirements :-/
# install Java
RUN dnf install -y java-17-openjdk
# install Firefox
# NOTE: We could install this using dnf, but the version of geckodriver needs to match it.
ARG FIREFOX_URL=https://ftp.mozilla.org/pub/firefox/releases/117.0.1/linux-x86_64/en-US/firefox-117.0.1.tar.bz2
RUN dnf install -y bzip2 xorg-x11-server-Xvfb gtk3 dbus-glib && \
curl -s "$FIREFOX_URL" | tar -jx -C /usr/local/ && \
ln -s /usr/local/firefox/firefox /usr/bin/firefox && \
echo "exclude=firefox" >>/etc/dnf/dnf.conf
# install geckodriver
ARG GECKODRIVER_URL=https://github.com/mozilla/geckodriver/releases/download/v0.33.0/geckodriver-v0.33.0-linux64.tar.gz
RUN curl -sL "$GECKODRIVER_URL" | tar -xz -C /usr/bin/
FROM centos:8 AS base
# clean up
RUN dnf clean all
# update packages and install Python
RUN dnf -y upgrade-minimal && \
dnf install -y python36 && \
dnf clean all
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
FROM base AS build
# set up a virtualenv
RUN python3.11 -m venv /opt/venv
RUN python3.6 -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
RUN pip install --upgrade pip
# install the application requirements
COPY requirements.txt requirements-dev.txt /tmp/
RUN pip3 install -r /tmp/requirements.txt
ARG CONTROL_TESTS_PORT
RUN if [ -n "$CONTROL_TESTS_PORT" ]; then \
pip3 install -r /tmp/requirements-dev.txt \
; fi
COPY requirements.txt requirements-dev.txt ./
RUN pip install -r requirements.txt
ARG ENABLE_TESTS
RUN if [ "$ENABLE_TESTS" ]; then pip install -r requirements-dev.txt ; fi
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
@ -53,34 +33,31 @@ FROM base
COPY --from=build /opt/venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
# install Firefox
RUN dnf install -y wget bzip2 xorg-x11-server-Xvfb gtk3 dbus-glib
RUN wget -qO- "https://download.mozilla.org/?product=firefox-latest-ssl&os=linux64&lang=en-US" | tar -C /usr/local/ -jx && \
ln -s /usr/local/firefox/firefox /usr/bin/firefox && \
echo "exclude=firefox" >> /etc/dnf/dnf.conf
# install geckodriver
RUN url=$( curl -s https://api.github.com/repos/mozilla/geckodriver/releases/latest | grep -Eoh 'https.*linux64\.tar\.gz' ) && \
curl -sL "$url" | tar -C /usr/bin/ -xz
# install the application
WORKDIR /app
COPY vasl_templates/ ./vasl_templates/
COPY vassal-shim/release/vassal-shim.jar ./vassal-shim/release/
COPY vasl_templates vasl_templates
COPY setup.py requirements.txt requirements-dev.txt LICENSE.txt ./
RUN pip3 install --editable .
RUN pip install -e .
# install the config files
COPY vasl_templates/webapp/config/logging.yaml.example ./vasl_templates/webapp/config/logging.yaml
COPY docker/config/ ./vasl_templates/webapp/config/
# copy the config files
COPY docker/config/* vasl_templates/webapp/config/
ARG ENABLE_TESTS
RUN if [ "$ENABLE_TESTS" ]; then echo "ENABLE_REMOTE_TEST_CONTROL = 1" >>vasl_templates/webapp/config/debug.cfg ; fi
# create a new user
# NOTE: It would be nice to just specify the UID/GID in the "docker run" command, but VASSAL has problems
# if there is no user :-/ We could specify these here, but that would bake them into the image.
# In general, this is not a problem, since the application doesn't need to access files outside the container,
# but if the user wants to e.g. keep the cached scenario index files outside the container, and they are
# running with a non-default UID/GID, they will have to manage permissions themselves. Sigh...
RUN useradd --create-home app
USER app
# FUDGE! We need this to stop spurious warning messages:
# Fork support is only compatible with the epoll1 and poll polling strategies
# Setting the verbosity to ERROR should suppress these, but doesn't :-/
# https://github.com/grpc/grpc/issues/17253
# https://github.com/grpc/grpc/blob/master/doc/environment_variables.md
ENV GRPC_VERBOSITY=NONE
# run the application
EXPOSE 5010
COPY docker/run.sh ./
COPY docker/run.sh .
CMD ./run.sh

@ -1,5 +1,3 @@
recursive-include vasl_templates/ui *.*
recursive-include vasl_templates/webapp/config *.*
recursive-include vasl_templates/webapp/data *.*
recursive-include vasl_templates/webapp/static *.*

@ -1,14 +1,16 @@
# VASL Templates
[<img src="vasl_templates/webapp/static/help/images/hill-621.small.png" width="200" align="right" hspace="10">](vasl_templates/webapp/static/help/images/hill-621.png)
<a href="https://github.com/pacman-ghost/vasl-templates/raw/master/vasl_templates/webapp/static/help/images/hill-621.png" target="_blank">
<img src="https://github.com/pacman-ghost/vasl-templates/raw/master/vasl_templates/webapp/static/help/images/hill-621.small.png" width="200" align="right" hspace="10">
</a>
*VASL Templates* makes it easy to set up attractive VASL scenarios, with loads of useful information embedded to assist with game play.
Simply enter the scenario information into the UI, and the program will generate HTML snippets that you can transfer into VASL labels in your scenario.
[<img src="vasl_templates/webapp/static/help/images/ob_setup.png" width="200">](vasl_templates/webapp/static/help/images/ob_setup.png)
<img src="https://github.com/pacman-ghost/vasl-templates/raw/master/vasl_templates/webapp/static/help/images/ob_setup.png" width="200">
You can find more examples of the program in action [here](examples/).
You can find more examples of the program in action [here](https://github.com/pacman-ghost/vasl-templates/tree/master/examples/).
### Documentation
@ -18,4 +20,3 @@ You can find more examples of the program in action [here](examples/).
* [Setting up Chapter H data](https://vasl-templates.org/help?tab=chapterh)
* [Writing your own templates](https://vasl-templates.org/help?tab=templatepacks)
* [For developers](https://vasl-templates.org/help?tab=fordevelopers)
* [FAQ](https://vasl-templates.org/FAQ)

@ -1,25 +1,21 @@
""" pytest support functions. """
import os
import shutil
import threading
import json
import re
import tempfile
import logging
import tempfile
import urllib.request
from urllib.error import URLError
import pytest
from flask import url_for
from vasl_templates.webapp import app
app.testing = True
from vasl_templates.webapp.tests import utils
from vasl_templates.webapp.tests.control_tests import ControlTests
FLASK_WEBAPP_PORT = 5011
_pytest_options = None
_orig_url_for = app.url_for
# ---------------------------------------------------------------------
def pytest_addoption( parser ):
@ -29,7 +25,7 @@ def pytest_addoption( parser ):
# add test options
parser.addoption(
"--webapp", action="store", dest="webapp_url", default=None,
"--server-url", action="store", dest="server_url", default=None,
help="Webapp server to test against."
)
# NOTE: Chrome seems to be ~15% faster than Firefox, headless ~5% faster than headful.
@ -42,7 +38,7 @@ def pytest_addoption( parser ):
help="Run the tests headless."
)
parser.addoption(
"--window-size", action="store", dest="window_size", default="1020x700",
"--window-size", action="store", dest="window_size", default="1000x700",
help="Browser window size."
)
@ -52,6 +48,31 @@ def pytest_addoption( parser ):
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)."
)
parser.addoption(
"--vasl-extensions", action="store", dest="vasl_extensions", default=None,
help="Directory containing the VASL extensions."
)
# 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: Some tests require Chapter H vehicle/ordnance notes. This is copyrighted material,
# so it is kept in a private repo.
parser.addoption(
"--vo-notes", action="store", dest="vo_notes", default=None,
help="Directory containing Chapter H vehicle/ordnance notes and test results."
)
# 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
@ -61,53 +82,16 @@ def pytest_addoption( parser ):
help="Use the clipboard to get snippets."
)
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
def pytest_configure( config ):
"""Called after command-line options have been parsed."""
global _pytest_options
_pytest_options = config.option
import vasl_templates.webapp.tests
vasl_templates.webapp.tests.pytest_options = config.option
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
@pytest.fixture( scope="session" )
def monkeypatch():
"""Override the default monkeypatch fixture."""
assert False, "Don't use monkeypatch!" # it won't work when testing against a remote server
# ---------------------------------------------------------------------
_webapp = None
@pytest.fixture( scope="function" )
@pytest.fixture( scope="session" )
def webapp():
"""Launch the webapp."""
# get the global webapp fixture
global _webapp
if _webapp is None:
_webapp = _make_webapp()
# reset the remote webapp server
_webapp.control_tests.start_tests()
# return the webapp to the caller
yield _webapp
# reset the remote webapp server
_webapp.control_tests.end_tests()
def _make_webapp():
"""Create the global webapp fixture."""
# initialize
webapp_url = _pytest_options.webapp_url
if webapp_url and not webapp_url.startswith( "http://" ):
webapp_url = "http://" + webapp_url
app.base_url = webapp_url if webapp_url else "http://localhost:{}".format( FLASK_WEBAPP_PORT )
_disable_console_logging()
server_url = pytest.config.option.server_url #pylint: disable=no-member
app.base_url = server_url if server_url else "http://localhost:{}".format(FLASK_WEBAPP_PORT)
logging.disable( logging.CRITICAL )
# initialize
# WTF?! https://github.com/pallets/flask/issues/824
@ -121,44 +105,32 @@ def _make_webapp():
# stop the browser from checking for a dirty scenario when leaving the page
kwargs["disable_close_window_check"] = 1
# check if the tests are being run headless
if _pytest_options.headless:
if pytest.config.option.headless: #pylint: disable=no-member
# yup - there is no clipboard support :-/
_pytest_options.use_clipboard = False
pytest.config.option.use_clipboard = False #pylint: disable=no-member
# check if we should disable using the clipboard for snippets
if not _pytest_options.use_clipboard:
if not pytest.config.option.use_clipboard: #pylint: disable=no-member
# NOTE: It's not a bad idea to bypass the clipboard, even when running in a browser,
# to avoid problems if something else uses the clipboard while the tests are running.
kwargs["store_clipboard"] = 1
if kwargs.get( "_external" ) is None:
kwargs["_external"] = True
url = _orig_url_for( endpoint, **kwargs )
url = url_for( endpoint, _external=True, **kwargs )
url = url.replace( "http://localhost", app.base_url )
return url
app.url_for = make_webapp_url
# check if we need to start a local webapp server
if not webapp_url:
if not server_url:
# yup - make it so
# NOTE: We run the server thread as a daemon so that it won't prevent the tests from finishing
# when they're done. We used to call $/shutdown after yielding the webapp fixture, but when
# we changed it from being per-session to per-function, we can no longer do that.
# This means that the webapp doesn't get a chance to shutdown properly (in particular,
# clean up the gRPC service), but since we send an EndTests message at the of each test,
# the remote server gets a chance to clean up then. It's not perfect (e.g. if the tests fail
# or otherwise finish early before they get a chance to send the EndTests message), but
# we can live with it.
thread = threading.Thread(
target = lambda: app.run( host="0.0.0.0", port=FLASK_WEBAPP_PORT, use_reloader=False ),
daemon = True
target = lambda: app.run( host="0.0.0.0", port=FLASK_WEBAPP_PORT, use_reloader=False )
)
thread.start()
# wait for the server to start up
def is_ready():
"""Try to connect to the webapp server."""
try:
url = app.url_for( "ping" )
with urllib.request.urlopen( url ) as resp:
assert resp.read().startswith( b"pong: " )
resp = urllib.request.urlopen( app.url_for("ping") ).read()
assert resp.startswith( b"pong: " )
return True
except URLError:
return False
@ -166,41 +138,20 @@ def _make_webapp():
assert False, "Unexpected exception: {}".format(ex)
utils.wait_for( 5, is_ready )
# set up control of the remote webapp server
try:
url = app.url_for( "get_control_tests" )
with urllib.request.urlopen( url ) as resp:
resp_data = json.load( resp )
except urllib.error.HTTPError as ex:
if ex.code == 404:
raise RuntimeError( "Can't get the test control port - has remote test control been enabled?" ) from ex
raise
port_no = resp_data.get( "port" )
if not port_no:
raise RuntimeError( "The webapp server is not running the test control service." )
mo = re.search( r"^http://(.+):\d+$", app.base_url )
addr = "{}:{}".format( mo.group(1), port_no )
app.control_tests = ControlTests( addr )
# NOTE: We set the back-end webdriver to be the of the same type (Firefox or Chrome) as the browser
# being used to drive the tests, which, strictly speaking, doesn't make sense, since the two things
# don't have anything to do with each other. However, this is a convenient way to switch the backend
# webdriver's and exercise both of them. The webdriver binary must be on the path, but if it's not,
# we won't have even got this far, since it needs to be there to drive the browser.
# NOTE: This will have no effect if we're talking to a remote server, but we can live with that.
if _pytest_options.webdriver == "firefox":
app.config[ "WEBDRIVER_PATH" ] = shutil.which( "geckodriver" )
elif _pytest_options.webdriver == "chrome":
app.config[ "WEBDRIVER_PATH" ] = shutil.which( "chromedriver" )
# return the server to the caller
yield app
return app
# shutdown the local webapp server
if not server_url:
urllib.request.urlopen( app.url_for("shutdown") ).read()
thread.join()
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
@pytest.fixture( scope="session" )
def test_client():
"""Return a test client that can be used to connect to the webapp."""
_disable_console_logging()
logging.disable( logging.CRITICAL )
return app.test_client()
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
@ -219,24 +170,30 @@ def webdriver( request ):
driver = request.config.getoption( "--webdriver" )
from selenium import webdriver as wb
if driver == "firefox":
service = wb.firefox.service.Service(
log_output = os.path.join( tempfile.gettempdir(), "webdriver-pytest.log" )
)
options = wb.FirefoxOptions()
if _pytest_options.headless:
options.add_argument( "--headless" )
driver = wb.Firefox( options=options, service=service )
options.set_headless( headless = pytest.config.option.headless ) #pylint: disable=no-member
driver = wb.Firefox(
firefox_options = options,
log_path = os.path.join( tempfile.gettempdir(), "geckodriver.log" )
)
elif driver == "chrome":
options = wb.ChromeOptions()
if _pytest_options.headless:
options.add_argument( "--headless" )
options.add_argument( "--disable-gpu" )
driver = wb.Chrome( options=options )
options.set_headless( headless = pytest.config.option.headless ) #pylint: disable=no-member
driver = wb.Chrome( chrome_options=options )
elif driver == "ie":
# NOTE: IE11 requires a registry key to be set:
# https://github.com/SeleniumHQ/selenium/wiki/InternetExplorerDriver#required-configuration
options = wb.IeOptions()
if pytest.config.option.headless: #pylint: disable=no-member
raise RuntimeError( "IE WebDriver cannot be run headless." )
options.IntroduceInstabilityByIgnoringProtectedModeSettings = True
options.EnsureCleanSession = True
driver = wb.Ie( ie_options=options )
else:
raise RuntimeError( "Unknown webdriver: {}".format( driver ) )
# set the browser size
words = _pytest_options.window_size.split( "x" )
words = pytest.config.option.window_size.split( "x" ) #pylint: disable=no-member
driver.set_window_size( int(words[0]), int(words[1]) )
# return the webdriver to the caller
@ -244,19 +201,3 @@ def webdriver( request ):
yield driver
finally:
driver.quit()
# ---------------------------------------------------------------------
def _disable_console_logging():
"""Disable Python logging to the console.
We do this when running tests because:
(1) pytest's output is voluminous enough without including our stuff in there as well (and it tends to be
not that helpful, anyway)
(2) pytest captures all output and shows it when the test ends i.e. we don't get to see messages in real-time.
"""
for logger in utils.get_all_loggers():
# NOTE: FileHandler derives from StreamHandler, and we want to keep those, so we can't use isinstance().
handlers = [ h for h in logger.handlers if type( h ) is logging.StreamHandler ] #pylint: disable=unidiomatic-typecheck
for h in handlers:
logger.removeHandler( h )

@ -1 +0,0 @@
logging.yaml

@ -1,5 +1,4 @@
[Debug]
; NOTE: These need to be mapped in if you want to run the test suite against a container.
TEST_VASSAL_ENGINES = /test-data/vassal/
TEST_VASL_MODS = /test-data/vasl-mods/
TEST_VASL_MODS = /test-data/vasl-vmods/
TEST_VASL_EXTNS_DIR = /test-data/vasl-extensions/

@ -0,0 +1,31 @@
version: 1
formatters:
standard:
format: "%(asctime)s.%(msecs)03d | %(message)s"
datefmt: "%H:%M:%S"
handlers:
console:
class: "logging.StreamHandler"
formatter: "standard"
stream: "ext://sys.stdout"
file:
class: "logging.FileHandler"
formatter: "standard"
filename: "/tmp/vasl-templates.log"
mode: "w"
loggers:
werkzeug:
level: "WARNING"
handlers: [ "console" ]
vasl_mod:
level: "WARNING"
handlers: [ "console", "file" ]
update_vsav:
level: "WARNING"
handlers: [ "console", "file" ]
control_tests:
level: "DEBUG"
handlers: [ "console", "file" ]

@ -1,5 +1,6 @@
[Site Config]
FLASK_HOST = 0.0.0.0
IS_CONTAINER = 1
WEBDRIVER_PATH = /usr/bin/geckodriver

@ -1,15 +1,8 @@
#!/bin/sh
# set up the display (so we can run VASSAL and a webdriver)
# set up the display (so we can run a webdriver)
export ENV=10
export DISPLAY=:10.0
Xvfb :10 -ac 1>/tmp/xvfb.log 2>/tmp/xvfb.err &
# run the webapp server
# IMPORTANT! This script runs as PID 1, which is the only process that will receive signals,
# so we must replace it with the Python webserver process if it is to receive e.g. SIGTERM,
# which we must handle if "docker stop" and scaling down in Kubernetes is to work (otherwise
# things timeout and we get SIGKILL'ed).
exec python3 /app/vasl_templates/webapp/run_server.py \
--addr 0.0.0.0 \
--force-init-delay 30
python /app/vasl_templates/webapp/run_server.py

@ -1,12 +1,16 @@
{
"COMPASS": "right",
"SCENARIO_NAME": "Hill 621",
"SCENARIO_ID": "ASL E",
"SCENARIO_LOCATION": "Near Minsk, Russia",
"SCENARIO_DATE": "1944-07-01",
"SCENARIO_WIDTH": "",
"PLAYER_1_DESCRIPTION": "Retreating elements of 170th Infantry Division",
"PLAYER_2_DESCRIPTION": "Elements of 5th Guards Army",
"ASA_ID": "56512",
"ROAR_ID": "129",
"PLAYERS_WIDTH": "",
"VICTORY_CONDITIONS_WIDTH": "",
"SSR_WIDTH": "400px",
"VICTORY_CONDITIONS_WIDTH": "240px",
"SSR_WIDTH": "500px",
"OB_VEHICLES_WIDTH_1": "",
"OB_VEHICLES_MA_NOTES_WIDTH_1": "300px",
"OB_ORDNANCE_WIDTH_1": "",
@ -15,12 +19,7 @@
"OB_VEHICLES_MA_NOTES_WIDTH_2": "300px",
"OB_ORDNANCE_WIDTH_2": "",
"OB_ORDNANCE_MA_NOTES_WIDTH_2": "300px",
"SCENARIO_NAME": "Hill 621",
"SCENARIO_ID": "ASL E",
"SCENARIO_LOCATION": "Near Minsk, Russia",
"PLAYER_1_DESCRIPTION": "Retreating elements of 170th Infantry Division",
"PLAYER_2_DESCRIPTION": "Elements of 5th Guards Army",
"VICTORY_CONDITIONS": "The Russians win at Game End if they Control<br>≥ five Level 3 hill hexes on Board 2.",
"VICTORY_CONDITIONS": "The Russians win at Game End if they Control &ge; five Level 3 hill hexes on Board 2.",
"SCENARIO_THEATER": "ETO",
"PLAYER_1": "russian",
"PLAYER_1_ELR": "4",
@ -28,15 +27,6 @@
"PLAYER_2": "german",
"PLAYER_2_ELR": "3",
"PLAYER_2_SAN": "4",
"TURN_TRACK": {
"NTURNS": "10",
"WIDTH": "",
"VERTICAL": false,
"SHADING": "",
"REINFORCEMENTS_1": "2,5",
"REINFORCEMENTS_2": "1,2,4,5,8",
"SWAP_PLAYERS": false
},
"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.",
@ -116,7 +106,7 @@
],
"SCENARIO_NOTES": [
{
"caption": "Download the scenario card from <a href=\"https://mmpgamers.com/asl-downloads-ezp-3#scenarios\">Multi-Man Publishing</a> (ASL Classic pack).",
"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",
"id": 1
}
@ -183,7 +173,7 @@
"id": 2
}
],
"_app_version": "v1.10",
"_last_update_time": "2022-09-12T02:46:18.035Z",
"_app_version": "v1.3",
"_last_update_time": "2020-09-27T04:13:08.719Z",
"_creation_time": "2020-09-27T03:46:56.089Z"
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 MiB

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

After

Width:  |  Height:  |  Size: 105 KiB

@ -1,11 +1,15 @@
{
"COMPASS": "down",
"SCENARIO_NAME": "Hube's Pocket",
"SCENARIO_ID": "ASL G",
"SCENARIO_LOCATION": "Near Buchach, Southern Russia",
"SCENARIO_DATE": "1944-04-06",
"SCENARIO_WIDTH": "",
"PLAYER_1_DESCRIPTION": "Advance elements of 5th Tank Corps",
"PLAYER_2_DESCRIPTION": "10th SS Panzer Division \"Frundsberg\" and the First Panzer Army ",
"ASA_ID": "56514",
"ROAR_ID": "131",
"PLAYERS_WIDTH": "",
"VICTORY_CONDITIONS_WIDTH": "",
"VICTORY_CONDITIONS_WIDTH": "320px",
"SSR_WIDTH": "330px",
"OB_VEHICLES_WIDTH_1": "",
"OB_VEHICLES_MA_NOTES_WIDTH_1": "300px",
@ -15,12 +19,7 @@
"OB_VEHICLES_MA_NOTES_WIDTH_2": "300px",
"OB_ORDNANCE_WIDTH_2": "",
"OB_ORDNANCE_MA_NOTES_WIDTH_2": "300px",
"SCENARIO_NAME": "Hube's Pocket",
"SCENARIO_ID": "ASL G",
"SCENARIO_LOCATION": "Near Buchach, Southern Russia",
"PLAYER_1_DESCRIPTION": "Advance elements of 5th Tank Corps",
"PLAYER_2_DESCRIPTION": "10th SS Panzer Division \"Frundsberg\" and the First Panzer Army",
"VICTORY_CONDITIONS": "The Germans win immediately by exiting ≥ 10 vehicles <br>\noff the west edge in either one or two Convoys (see SSR 4).",
"VICTORY_CONDITIONS": "The Germans win immediately by exiting &ge; 10 vehicles <br>\noff the west edge in either one or two Convoys (see SSR 4).",
"SCENARIO_THEATER": "ETO",
"PLAYER_1": "german",
"PLAYER_1_ELR": "4",
@ -28,15 +27,6 @@
"PLAYER_2": "russian",
"PLAYER_2_ELR": "3",
"PLAYER_2_SAN": "2",
"TURN_TRACK": {
"NTURNS": "14",
"WIDTH": "5",
"VERTICAL": false,
"SHADING": "",
"REINFORCEMENTS_1": "1,5",
"REINFORCEMENTS_2": "1",
"SWAP_PLAYERS": true
},
"SSR": [
"The SPW 251/sMG inherent HS is a 3-4-8.",
"German inherent crews have a morale of 9.",
@ -94,7 +84,7 @@
],
"SCENARIO_NOTES": [
{
"caption": "Download the scenario card from <a href=\"https://mmpgamers.com/asl-downloads-ezp-3#scenarios\">Multi-Man Publishing</a> (ASL Classic pack).",
"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": "",
"id": 1
}
@ -120,7 +110,7 @@
],
"OB_NOTES_1": [],
"OB_NOTES_2": [],
"_app_version": "v1.10",
"_last_update_time": "2022-09-12T02:22:47.511Z",
"_app_version": "v1.3",
"_last_update_time": "2020-09-27T04:33:18.643Z",
"_creation_time": "2020-09-27T04:11:07.200Z"
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 MiB

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

After

Width:  |  Height:  |  Size: 98 KiB

@ -4,4 +4,4 @@ This directory contains examples of *VASL Templates* in action, with the `.json`
The online versions contain images that will be loaded from the internet, which looks much better, but there will be a short delay when you open the scenario in VASSAL as the images are downloaded.
These scenarios were taken from Multi-Man Publishing's [*ASL Classic* scenario pack](https://mmpgamers.com/asl-downloads-ezp-3#scenarios).
These scenarios were taken from Multi-Man Publishing's [*ASL Classic* scenario pack](http://www.multimanpublishing.com/Support/ASLASLSK/ASLOfficialDownloads/tabid/109/Default.aspx).

@ -1,11 +1,15 @@
{
"COMPASS": "up",
"SCENARIO_NAME": "The Streets Of Stalingrad",
"SCENARIO_ID": "ASL C",
"SCENARIO_LOCATION": "Stalingrad, Russia",
"SCENARIO_DATE": "1942-10-06",
"SCENARIO_WIDTH": "",
"PLAYER_1_DESCRIPTION": "308th Rifle Division / 295th Rifle Division / 2nd Battalion, 37th Guards Division",
"PLAYER_2_DESCRIPTION": "389th Infantry Division",
"ASA_ID": "56510",
"ROAR_ID": "127",
"PLAYERS_WIDTH": "",
"VICTORY_CONDITIONS_WIDTH": "450px",
"VICTORY_CONDITIONS_WIDTH": "400px",
"SSR_WIDTH": "500px",
"OB_VEHICLES_WIDTH_1": "",
"OB_VEHICLES_MA_NOTES_WIDTH_1": "300px",
@ -15,12 +19,7 @@
"OB_VEHICLES_MA_NOTES_WIDTH_2": "300px",
"OB_ORDNANCE_WIDTH_2": "",
"OB_ORDNANCE_MA_NOTES_WIDTH_2": "300px",
"SCENARIO_NAME": "The Streets Of Stalingrad",
"SCENARIO_ID": "ASL C",
"SCENARIO_LOCATION": "Stalingrad, Russia",
"PLAYER_1_DESCRIPTION": "308th Rifle Division / 295th Rifle Division / 2nd Battalion, 37th Guards Division",
"PLAYER_2_DESCRIPTION": "389th Infantry Division",
"VICTORY_CONDITIONS": "Victory is based upon satisfying the Victory Conditions of Scenarios A and B:\n<ul style=\"margin:0 0 10px 10px;\">\n<li> If each side fulfills one Victory Condition, the game is a draw.\n</li><li> If a player fulfills one Victory Condition and draws the other, he wnis.\n</li><li> A decisive victory is achieved when a player fulfills both Victory Conditions.\n</li></ul>\n\n<p> <b>Scenario A:</b> The Russians win at Game End if they Control ≥ 2 more buildings initially occupied by the Germans than they lose of their own initially-held stone buildings to German Control, and/or have a favorable 3:1 ratio of unbroken squad-equivalents.\n</p><p> <b>Scenario B:</b> At Game End, the player with undisputed control of at least 6 hexes of building X3 wins. A hex containing a Melee is controlled by neither player. If only one player has an unbroken unit in the building at Game End, that player is the winner. Any other result is a draw.\n</p>",
"VICTORY_CONDITIONS": "Victory is based upon satisfying the Victory Conditions of Scenarios A and B:\n<ul style=\"margin:0 0 10px 10px;\">\n<li> If each side fulfills one Victory Condition, the game is a draw.\n<li> If a player fulfills one Victory Condition and draws the other, he wnis.\n<li> A decisive victory is achieved when a player fulfills both Victory Conditions.\n</ul>\n\n<p> <b>Scenario A:</b> The Russians win at Game End if they Control &ge; 2 more buildings initially occupied by the Germans than they lose of their own initially-held stone buildings to German Control, and/or have a favorable 3:1 ratio of unbroken squad-equivalents.\n<p> <b>Scenario B:</b> At Game End, the player with undisputed control of at least 6 hexes of building X3 wins. A hex containing a Melee is controlled by neither player. If only one player has an unbroken unit in the building at Game End, that player is the winner. Any other result is a draw.\n</ul>",
"SCENARIO_THEATER": "ETO",
"PLAYER_1": "russian",
"PLAYER_1_ELR": "3",
@ -28,15 +27,6 @@
"PLAYER_2": "german",
"PLAYER_2_ELR": "4",
"PLAYER_2_SAN": "6",
"TURN_TRACK": {
"NTURNS": "7",
"WIDTH": "",
"VERTICAL": false,
"SHADING": "",
"REINFORCEMENTS_1": "2",
"REINFORCEMENTS_2": "3",
"SWAP_PLAYERS": false
},
"SSR": [
"Roll a die to determine who moves first.",
"Set up the forces of Scenario A prior to placing the units of Scenario B.",
@ -71,7 +61,7 @@
],
"SCENARIO_NOTES": [
{
"caption": "Download the scenario card from <a href=\"https://mmpgamers.com/asl-downloads-ezp-3#scenarios\">Multi-Man Publishing</a> (ASL Classic pack).",
"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": "",
"id": 1
}
@ -167,7 +157,7 @@
],
"OB_NOTES_1": [],
"OB_NOTES_2": [],
"_app_version": "v1.10",
"_last_update_time": "2022-09-12T02:58:13.237Z",
"_app_version": "v1.3",
"_last_update_time": "2020-09-27T04:46:21.803Z",
"_creation_time": "2020-09-27T04:44:48.473Z"
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 902 KiB

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 30 KiB

@ -19,167 +19,10 @@ BASE_DIR = os.path.split( os.path.abspath(__file__) )[ 0 ]
MAIN_SCRIPT = "vasl_templates/main.py"
APP_ICON = os.path.join( BASE_DIR, "vasl_templates/webapp/static/images/app.ico" )
# ---------------------------------------------------------------------
def main( args ): #pylint: disable=too-many-locals
"""Main processing."""
# parse the command-line options
output_fname = None
no_loader = False
work_dir = None
cleanup = True
opts,args = getopt.getopt( sys.argv[1:], "o:w:", ["output=","no-loader","work=","no-clean"] )
for opt, val in opts:
if opt in ["-o","--output"]:
output_fname = val.strip()
elif opt in ["--no-loader"]:
no_loader = True
elif opt in ["-w","--work"]:
work_dir = val.strip()
elif opt in ["--no-clean"]:
cleanup = False
else:
raise RuntimeError( "Unknown argument: {}".format( opt ) )
if not output_fname:
raise RuntimeError( "No output file was specified." )
# figure out where to locate our work directories
if work_dir:
work_dir = os.path.abspath( work_dir )
build_dir = os.path.join( work_dir, "build" )
if os.path.isdir( build_dir ):
shutil.rmtree( build_dir )
dist_dir = os.path.join( work_dir, "dist" )
if os.path.isdir( dist_dir ):
shutil.rmtree( dist_dir )
else:
build_dir = tempfile.mkdtemp()
dist_dir = tempfile.mkdtemp()
# figure out the format of the release archive
formats = { ".zip": "zip", ".tar.gz": "gztar", ".tar.bz": "bztar", ".tar": "tar" }
output_fmt = None
for extn,fmt in formats.items():
if output_fname.endswith( extn ):
output_fmt = fmt
output_fname2 = output_fname[:-len(extn)]
break
if not output_fmt:
raise RuntimeError( "Unknown release archive format: {}".format( os.path.split(output_fname)[1] ) )
# configure pyinstaller
# NOTE: Using UPX gave ~25% saving on Windows, but failed to run because of corrupt DLL's :-/
target_name = make_target_name( "vasl-templates" )
args = [
"--distpath", dist_dir,
"--workpath", build_dir,
"--specpath", build_dir,
"--onefile",
"--name", target_name,
]
args.extend( [ "--add-data",
os.path.join( BASE_DIR, "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
args.extend( [ "--add-data",
os.path.join( BASE_DIR, src + os.pathsep + dest )
] )
map_dir( "vasl_templates/ui", "vasl_templates/ui" )
map_dir( "vasl_templates/resources", "vasl_templates/resources" )
map_dir( "vasl_templates/webapp/static", "vasl_templates/webapp/static" )
map_dir( "vasl_templates/webapp/templates", "vasl_templates/webapp/templates" )
if sys.platform == "win32":
args.append( "--noconsole" )
args.extend( [ "--icon", APP_ICON ] )
# NOTE: These files are not always required but it's probably safer to always include them.
import sysconfig
dname = os.path.join( sysconfig.get_path("platlib") , "PyQt5/Qt5/bin" )
args.extend( [ "--add-binary", os.path.join(dname,"libEGL.dll") + os.pathsep + "PyQt5/Qt/bin" ] )
args.extend( [ "--add-binary", os.path.join(dname,"libGLESv2.dll") + os.pathsep + "PyQt5/Qt/bin" ] )
args.append( MAIN_SCRIPT )
# freeze the application
start_time = time.time()
os.chdir( BASE_DIR )
run_pyinstaller( args ) # nb: this doesn't return any indication if it worked or not :-/
# add extra files to the distribution
def ignore_files( dname, fnames ): #pylint: disable=redefined-outer-name
"""Return files to ignore during copytree()."""
# ignore cache files
ignore = [ "__pycache__", "GPUCache" ]
# ignore dot files
ignore.extend( f for f in fnames if f.startswith(".") )
# ignore Python files
ignore.extend( f for f in fnames if os.path.splitext(f)[1] == ".py" )
# ignore anything in .gitignore
fname = os.path.join( dname, ".gitignore" )
if os.path.isfile( fname ):
with open( fname, "r", encoding="utf-8" ) as fp:
for line_buf in fp:
line_buf = line_buf.strip()
if not line_buf or line_buf.startswith("#"):
continue
ignore.append( line_buf ) # nb: we assume normal filenames i.e. no globbing
return ignore
shutil.copy( "LICENSE.txt", dist_dir )
shutil.copytree( "vasl_templates/webapp/data", os.path.join(dist_dir,"data") )
shutil.copytree( "vasl_templates/webapp/config", os.path.join(dist_dir,"config"), ignore=ignore_files )
# copy the examples
dname = os.path.join( dist_dir, "examples" )
os.makedirs( dname )
fnames = [ f for f in os.listdir("examples") if os.path.splitext(f)[1] in (".json",".png") ]
for f in fnames:
shutil.copy( os.path.join("examples",f), dname )
# set the build info
build_info = {
"timestamp": int( time.time() ),
}
build_info.update( get_git_info() )
dname = os.path.join( dist_dir, "config" )
fname = os.path.join( dname, "build-info.json" )
with open( fname, "w", encoding="utf-8" ) as fp:
json.dump( build_info, fp )
# freeze the loader
if no_loader:
print( "Not including the loader." )
else:
print( "--- BEGIN FREEZE LOADER ---" )
shutil.move(
os.path.join( dist_dir, target_name ),
os.path.join( dist_dir, make_target_name("vasl-templates-main") )
)
from loader.freeze import freeze_loader #pylint: disable=no-name-in-module
freeze_loader(
os.path.join( dist_dir, target_name ),
build_dir, # nb: a "loader" sub-directory will be created and used
False # nb: we will clean up, or not, everything ourself
)
# create the release archive
os.chdir( dist_dir )
print()
print( "Generating release archive: {}".format( output_fname ) )
shutil.make_archive( output_fname2, output_fmt )
file_size = os.path.getsize( output_fname )
print( "- Done: {0:.1f} MB".format( float(file_size) / 1024 / 1024 ) )
# clean up
if cleanup:
os.chdir( BASE_DIR ) # so we can delete the build directory :-/
shutil.rmtree( build_dir )
shutil.rmtree( dist_dir )
# log the elapsed time
elapsed_time = time.time() - start_time
print()
print( "Elapsed time: {}".format( datetime.timedelta( seconds=int(elapsed_time) ) ) )
TARGET_NAMES = {
"win32": "vasl-templates.exe",
}
DEFAULT_TARGET_NAME = "vasl-templates"
# ---------------------------------------------------------------------
@ -189,8 +32,7 @@ def get_git_info():
# get the latest commit ID
proc = subprocess.run(
[ "git", "log" ],
stdout=subprocess.PIPE, stderr=subprocess.PIPE, encoding="utf-8",
check=True
stdout=subprocess.PIPE, stderr=subprocess.PIPE, encoding="utf-8"
)
buf = proc.stdout.split( "\n" )[0]
mo = re.search( r"^commit ([a-z0-9]+)$", buf )
@ -199,8 +41,7 @@ def get_git_info():
# get the current git branch
proc = subprocess.run(
[ "git", "branch" ],
stdout=subprocess.PIPE, stderr=subprocess.PIPE, encoding="utf-8",
check=True
stdout=subprocess.PIPE, stderr=subprocess.PIPE, encoding="utf-8"
)
lines = [ s for s in proc.stdout.split("\n") if s.startswith("* ") ]
if len(lines) != 1:
@ -211,11 +52,136 @@ def get_git_info():
return { "last_commit_id": last_commit_id, "branch_name": branch_name }
def make_target_name( fname ):
"""Generate a target filename."""
return fname+".exe" if sys.platform == "win32" else fname
# ---------------------------------------------------------------------
if __name__ == "__main__":
main( sys.argv[1:] )
# parse the command-line options
output_fname = None
work_dir = None
cleanup = True
opts,args = getopt.getopt( sys.argv[1:], "o:w:", ["output=","work=","noclean"] )
for opt,val in opts:
if opt in ["-o","--output"]:
output_fname = val.strip()
elif opt in ["-w","--work"]:
work_dir = val.strip()
elif opt in ["--noclean"]:
cleanup = False
else:
raise RuntimeError( "Unknown argument: {}".format( opt ) )
if not output_fname:
raise RuntimeError( "No output file was specified." )
# figure out where to locate our work directories
if work_dir:
build_dir = os.path.join( work_dir, "build" )
if os.path.isdir( build_dir ):
shutil.rmtree( build_dir )
dist_dir = os.path.join( work_dir, "dist" )
if os.path.isdir( dist_dir ):
shutil.rmtree( dist_dir )
else:
build_dir = tempfile.mkdtemp()
dist_dir = tempfile.mkdtemp()
# figure out the format of the release archive
formats = { ".zip": "zip", ".tar.gz": "gztar", ".tar.bz": "bztar", ".tar": "tar" }
output_fmt = None
for extn,fmt in formats.items():
if output_fname.endswith( extn ):
output_fmt = fmt
output_fname2 = output_fname[:-len(extn)]
break
if not output_fmt:
raise RuntimeError( "Unknown release archive format: {}".format( os.path.split(output_fname)[1] ) )
# configure pyinstaller
# NOTE: Using UPX gave ~25% saving on Windows, but failed to run because of corrupt DLL's :-/
# NOTE: Setting --specpath breaks the build - it's being used as the project root...? (!)
target_name = TARGET_NAMES.get( sys.platform, DEFAULT_TARGET_NAME )
args = [
"--distpath", dist_dir,
"--workpath", build_dir,
"--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
args.extend( [ "--add-data", src + os.pathsep + dest ] )
map_dir( "vasl_templates/ui", "vasl_templates/ui" )
map_dir( "vasl_templates/resources", "vasl_templates/resources" )
map_dir( "vasl_templates/webapp/static", "vasl_templates/webapp/static" )
map_dir( "vasl_templates/webapp/templates", "vasl_templates/webapp/templates" )
if sys.platform == "win32":
args.append( "--noconsole" )
args.extend( [ "--icon", APP_ICON ] )
# NOTE: These files are not always required but it's probably safer to always include them.
import distutils.sysconfig #pylint: disable=import-error
dname = os.path.join( distutils.sysconfig.get_python_lib() , "PyQt5/Qt/bin" )
args.extend( [ "--add-binary", os.path.join(dname,"libEGL.dll") + os.pathsep + "PyQt5/Qt/bin" ] )
args.extend( [ "--add-binary", os.path.join(dname,"libGLESv2.dll") + os.pathsep + "PyQt5/Qt/bin" ] )
args.append( MAIN_SCRIPT )
# freeze the application
start_time = time.time()
os.chdir( BASE_DIR )
run_pyinstaller( args ) # nb: this doesn't return any indication if it worked or not :-/
# add extra files to the distribution
def ignore_files( dname, fnames ): #pylint: disable=redefined-outer-name
"""Return files to ignore during copytree()."""
# ignore cache files
ignore = [ "__pycache__", "GPUCache" ]
# ignore dot files
ignore.extend( f for f in fnames if f.startswith(".") )
# ignore Python files
ignore.extend( f for f in fnames if os.path.splitext(f)[1] == ".py" )
# ignore anything in .gitignore
fname = os.path.join( dname, ".gitignore" )
if os.path.isfile( fname ):
for line_buf in open(fname,"r"):
line_buf = line_buf.strip()
if not line_buf or line_buf.startswith("#"):
continue
ignore.append( line_buf ) # nb: we assume normal filenames i.e. no globbing
return ignore
shutil.copy( "LICENSE.txt", dist_dir )
shutil.copytree( "vasl_templates/webapp/data", os.path.join(dist_dir,"data") )
shutil.copytree( "vasl_templates/webapp/config", os.path.join(dist_dir,"config"), ignore=ignore_files )
# copy the examples
dname = os.path.join( dist_dir, "examples" )
os.makedirs( dname )
fnames = [ f for f in os.listdir("examples") if os.path.splitext(f)[1] in (".json",".png") ]
for f in fnames:
shutil.copy( os.path.join("examples",f), dname )
# set the build info
build_info = {
"timestamp": int( time.time() ),
}
build_info.update( get_git_info() )
dname = os.path.join( dist_dir, "config" )
with open( os.path.join(dname,"build-info.json"), "w" ) as fp:
json.dump( build_info, fp )
# create the release archive
os.chdir( dist_dir )
print()
print( "Generating release archive: {}".format( output_fname ) )
shutil.make_archive( output_fname2, output_fmt )
file_size = os.path.getsize( output_fname )
print( "- Done: {0:.1f} MB".format( float(file_size) / 1024 / 1024 ) )
# clean up
if cleanup:
os.chdir( BASE_DIR ) # so we can delete the build directory :-/
os.unlink( target_name + ".spec" )
shutil.rmtree( build_dir )
shutil.rmtree( dist_dir )
# log the elapsed time
elapsed_time = time.time() - start_time
print()
print( "Elapsed time: {}".format( datetime.timedelta( seconds=int(elapsed_time) ) ) )

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

@ -1,107 +0,0 @@
#!/usr/bin/env python3
""" Freeze the vasl-templates loader program.
This script is called by the main freeze script.
"""
import sys
import os
import shutil
import tempfile
import getopt
from PyInstaller.__main__ import run as run_pyinstaller
from PIL import Image
APP_ICON = os.path.join(
os.path.abspath( os.path.dirname( __file__ ) ),
"../vasl_templates/webapp/static/images/app.ico"
)
# ---------------------------------------------------------------------
def main( args ):
"""Main processing."""
# parse the command-line options
output_fname = "./loader"
work_dir = os.path.join( tempfile.gettempdir(), "freeze-loader" )
cleanup = True
opts,args = getopt.getopt( args, "o:w:", ["output=","work=","no-clean"] )
for opt, val in opts:
if opt in ["-o","--output"]:
output_fname = val.strip()
elif opt in ["-w","--work"]:
work_dir = val.strip()
elif opt in ["--no-clean"]:
cleanup = False
else:
raise RuntimeError( "Unknown argument: {}".format( opt ) )
# freeze the loader program
freeze_loader( output_fname, work_dir, cleanup )
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
def freeze_loader( output_fname, work_dir, cleanup ):
"""Freeze the loader program."""
with tempfile.TemporaryDirectory() as dist_dir:
# initialize
base_dir = os.path.abspath( os.path.dirname( __file__ ) )
assets_dir = os.path.join( base_dir, "assets" )
# convert the app icon to an image
if not os.path.isdir( work_dir ):
os.makedirs( work_dir )
app_icon_fname = os.path.join( work_dir, "app-icon.png" )
_convert_app_icon( app_icon_fname )
# initialize
app_name = "loader"
args = [
"--distpath", dist_dir,
"--workpath", work_dir,
"--specpath", work_dir,
"--onefile",
"--name", app_name,
]
args.extend( [
"--add-data", app_icon_fname + os.pathsep + "assets/",
"--add-data", os.path.join(assets_dir,"loading.gif") + os.pathsep + "assets/"
] )
if sys.platform == "win32":
args.append( "--noconsole" )
args.extend( [ "--icon", APP_ICON ] )
args.append( os.path.join( base_dir, "main.py" ) )
# freeze the program
run_pyinstaller( args )
# save the generated artifact
fname = app_name+".exe" if sys.platform == "win32" else app_name
shutil.move(
os.path.join( dist_dir, fname ),
output_fname
)
# clean up
if cleanup:
shutil.rmtree( work_dir )
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
def _convert_app_icon( save_fname ):
"""Convert the app icon to an image."""
# NOTE: Tkinter's PhotoImage doesn't handle .ico files, so we convert the app icon
# to an image, then insert it into the PyInstaller-generated executable (so that
# we don't have to bundle Pillow into the release).
img = Image.open( APP_ICON )
img = img.convert( "RGBA" ).resize( (48, 48) )
img.save( save_fname, "png" )
# ---------------------------------------------------------------------
if __name__ == "__main__":
main( sys.argv[1:] )

@ -1,207 +0,0 @@
""" Load the main vasl-templates program.
vasl-templates can be slow to start (especially on Windows), since it has to unpack the PyInstaller-generated EXE,
then startup Qt. We want to show a splash screen while all this happening, but we can't just put it in vasl-templates,
since it would only happen *after* all the slow stuff has finished :-/ So, we have this stub program that shows
a splash screen, launches the main vasl-templates program, and waits for it to finish starting up.
"""
import sys
import os
import subprocess
import threading
import itertools
import urllib.request
from urllib.error import URLError
import time
import configparser
# NOTE: It's important that this program start up quickly (otherwise it becomes pointless),
# so we use tkinter, instead of PyQt (and also avoid bundling a 2nd copy of PyQt :-/).
import tkinter
import tkinter.messagebox
if getattr( sys, "frozen", False ):
BASE_DIR = sys._MEIPASS #pylint: disable=no-member,protected-access
else:
BASE_DIR = os.path.abspath( os.path.dirname( __file__ ) )
STARTUP_TIMEOUT = 60 # how to long to wait for vasl-templates to start (seconds)
main_window = None
# ---------------------------------------------------------------------
def main( args ):
"""Load the main vasl-templates program."""
# initialize Tkinter
global main_window
main_window = tkinter.Tk()
main_window.option_add( "*Dialog.msg.font", "Helvetica 12" )
# load the app icon
# NOTE: This image file doesn't exist in source control, but is created dynamically from
# the main app icon by the freeze script, and inserted into the PyInstaller-generated executable.
# We do things this way so that we don't have to bundle Pillow into the release.
app_icon = tkinter.PhotoImage(
file = make_asset_path( "app-icon.png" )
)
# locate the main vasl-templates executable
fname = os.path.join( os.path.dirname( sys.executable ), "vasl-templates-main" )
if sys.platform == "win32":
fname += ".exe"
if not os.path.isfile( fname ):
show_error_msg( "Can't find the main vasl-templates program.", withdraw=True )
return -1
# launch the main vasl-templates program
try:
proc = subprocess.Popen( itertools.chain( [fname], args ) ) #pylint: disable=consider-using-with
except Exception as ex: #pylint: disable=broad-except
show_error_msg( "Can't start vasl-templates:\n\n{}".format( ex ), withdraw=True )
return -2
# get the webapp port number
port = 5010
fname = os.path.join( os.path.dirname( fname ), "config/app.cfg" )
if os.path.isfile( fname ):
config_parser = configparser.ConfigParser()
config_parser.optionxform = str # preserve case for the keys :-/
config_parser.read( fname )
args = dict( config_parser.items( "System" ) )
port = args.get( "FLASK_PORT_NO", port )
# create the splash window
create_window( app_icon )
# start a background thread to check on the main vasl-templates process
threading.Thread(
target = check_startup,
args = ( proc, port )
).start()
# run the main loop
main_window.mainloop()
return 0
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
def create_window( app_icon ):
"""Create the splash window."""
# create the splash window
main_window.geometry( "275x64" )
main_window.title( "vasl-templates loader" )
main_window.overrideredirect( 1 ) # nb: "-type splash" doesn't work on Windows :-/
main_window.eval( "tk::PlaceWindow . center" )
main_window.wm_attributes( "-topmost", 1 )
main_window.tk.call( "wm", "iconphoto", main_window._w, app_icon ) #pylint: disable=protected-access
main_window.protocol( "WM_DELETE_WINDOW", lambda: None )
# add the app icon
label = tkinter.Label( main_window, image=app_icon )
label.grid( row=0, column=0, rowspan=2, padx=8, pady=8 )
# add the caption
label = tkinter.Label( main_window, text="Loading vasl-templates...", font=("Helvetica",12) )
label.grid( row=0, column=1, padx=5, pady=(8,0) )
# add the "loading" image (we have to animate it ourself :-/)
anim_label = tkinter.Label( main_window )
anim_label.grid( row=1, column=1, sticky=tkinter.N, padx=0, pady=0 )
fname = make_asset_path( "loading.gif" )
nframes = 13
frames = [
tkinter.PhotoImage( file=fname, format="gif -index {}".format( i ) )
for i in range(nframes)
]
frame_index = 0
def next_frame():
nonlocal frame_index
frame = frames[ frame_index ]
frame_index = ( frame_index + 1 ) % nframes
anim_label.configure( image=frame )
main_window.after( 75, next_frame )
main_window.after( 0, next_frame )
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
def check_startup( proc, port ):
"""Check the startup of the main vasl-templates process."""
def do_check():
# check if we've waited for too long
if time.time() - start_time > STARTUP_TIMEOUT:
# yup - give up
raise RuntimeError( "Couldn't start vasl-templates." )
# check if the main vasl-templates process has gone away
if proc.poll() is not None:
raise RuntimeError( "The vasl-templates program ended unexpectedly." )
# check if the webapp is responding
url = "http://localhost:{}/ping".format( port )
try:
with urllib.request.urlopen( url ) as resp:
_ = resp.read()
except URLError:
# no response - the webapp is probably still starting up
return False
except Exception as ex: #pylint: disable=broad-except
raise RuntimeError( "Couldn't communicate with vasl-templates:\n\n{}".format( ex ) ) from ex
# the main vasl-templates program has started up and is responsive - our job is done!
if sys.platform == "win32":
# FUDGE! There is a short amount of time between the webapp server starting and
# the main window appearing. We delay here for a bit, to try to synchronize
# our window fading out with the main vasl-templates window appearing.
time.sleep( 1 )
return True
def on_done( msg ):
if msg:
show_error_msg( msg, withdraw=True )
fade_out( main_window, main_window.quit )
# run the main loop
start_time = time.time()
while True:
try:
if do_check():
on_done( None )
break
except Exception as ex: #pylint: disable=broad-except
on_done( str(ex) )
return
time.sleep( 0.25 )
# ---------------------------------------------------------------------
def fade_out( target, on_done ):
"""Fade out the target window."""
alpha = target.attributes( "-alpha" )
if alpha > 0:
alpha -= 0.1
target.attributes( "-alpha", alpha )
target.after( 50, lambda: fade_out( target, on_done ) )
else:
on_done()
def make_asset_path( fname ):
"""Generate the path to an asset file."""
return os.path.join( BASE_DIR, "assets", fname )
def show_error_msg( error_msg, withdraw=False ):
"""Show an error dialog."""
if withdraw:
main_window.withdraw()
tkinter.messagebox.showinfo( "vasl-templates loader error", error_msg )
# ---------------------------------------------------------------------
if __name__ == "__main__":
sys.exit( main( sys.argv[1:] ) )

@ -1,7 +1,6 @@
pytest==7.4.2
grpcio-tools==1.58.0
tabulate==0.9.0
lxml==4.9.3
pylint==2.17.5
pytest-pylint==0.19.0
pyinstaller==5.13.2
pytest==3.6.0
tabulate==0.8.2
lxml==4.2.4
pylint==1.9.2
pytest-pylint==0.9.0
pyinstaller==3.6

@ -1,10 +1,7 @@
# python 3.11.4
# python 3.6.8
flask==2.3.3
pyyaml==6.0.1
# NOTE: Pillow 9.5.0 is the last version that provides 32-bit wheels.
pillow==9.5.0
selenium==4.12.0
waitress==2.1.2
appdirs==1.4.4
click==8.1.7
flask==1.0.2
pyyaml==5.3.1
pillow==7.0.0
selenium==3.12.0
click==6.7

@ -3,394 +3,168 @@
# ---------------------------------------------------------------------
function main
{
# initialize
cd `dirname "$0"`
PORT=5010
VASSAL=
VASL_MOD=
VASL_EXTNS=
VASL_BOARDS=
CHAPTER_H_NOTES=
USER_FILES=
LOGGING_CONFIG=
VASSAL_SHIM_LOGGING_CONFIG=
ASA_INDEX=
ROAR_INDEX=
VO_NOTES_IMAGE_CACHE=
TEMPLATE_PACK=
IMAGE_TAG=latest
CONTAINER_NAME=vasl-templates
DETACH=
NO_BUILD=
BUILD_ARGS=
BUILD_NETWORK=
RUN_NETWORK=
CONTROL_TESTS_PORT=
TEST_DATA_VASSAL=
TEST_DATA_VASL_MODS=
# parse the command-line arguments
if [ $# -eq 0 ]; then
print_help
exit 0
fi
params="$(getopt -o p:v:e:k:t:d -l port:,control-tests-port:,vassal:,vasl:,vasl-extensions:,boards:,chapter-h:,template-pack:,user-files:,logging:,vassal-shim-logging:,asa-index:,roar-index:,vo-notes-image-cache:,tag:,name:,detach,no-build,build-arg:,build-network:,run-network:,test-data-vassal:,test-data-vasl-mods:,help --name "$0" -- "$@")"
if [ $? -ne 0 ]; then exit 1; fi
eval set -- "$params"
while true; do
case "$1" in
-p | --port)
PORT=$2
shift 2 ;;
--vassal)
VASSAL=$2
shift 2 ;;
-v | --vasl)
VASL_MOD=$2
shift 2 ;;
-e | --vasl-extensions)
VASL_EXTNS=$2
shift 2 ;;
--boards)
VASL_BOARDS=$2
shift 2 ;;
--chapter-h)
CHAPTER_H_NOTES=$2
shift 2 ;;
--user-files)
USER_FILES=$2
shift 2 ;;
-k | --template-pack)
TEMPLATE_PACK=$2
shift 2 ;;
--logging)
LOGGING_CONFIG=$2
shift 2 ;;
--vassal-shim-logging)
VASSAL_SHIM_LOGGING_CONFIG=$2
shift 2 ;;
--asa-index)
ASA_INDEX=$2
shift 2 ;;
--roar-index)
ROAR_INDEX=$2
shift 2 ;;
--vo-notes-image-cache)
VO_NOTES_IMAGE_CACHE=$2
shift 2 ;;
-t | --tag)
IMAGE_TAG=$2
shift 2 ;;
--name)
CONTAINER_NAME=$2
shift 2 ;;
-d | --detach )
DETACH=--detach
shift 1 ;;
--no-build )
NO_BUILD=1
shift 1 ;;
--build-arg )
BUILD_ARGS="$BUILD_ARGS --build-arg $2"
shift 2 ;;
--build-network )
# FUDGE! We sometimes can't get out to the internet from the container (DNS problems) using the default
# "bridge" network, so we offer the option of using an alternate network (e.g. "host").
BUILD_NETWORK="--network $2"
shift 2 ;;
--run-network )
RUN_NETWORK="--network $2"
shift 2 ;;
--control-tests-port)
CONTROL_TESTS_PORT=$2
shift 2 ;;
--test-data-vassal )
target=$( realpath --no-symlinks "$2" )
TEST_DATA_VASSAL="--volume $target:/test-data/vassal/"
shift 2 ;;
--test-data-vasl-mods )
target=$( realpath --no-symlinks "$2" )
TEST_DATA_VASL_MODS="--volume $target:/test-data/vasl-mods/"
shift 2 ;;
--help )
print_help
exit 0 ;;
-- ) shift ; break ;;
* )
echo "Unknown option: $1" >&2
exit 1 ;;
esac
done
# check if a VASSAL directory has been specified
if [ -n "$VASSAL" ]; then
target=$( get_target DIR "$VASSAL" )
if [ -z "$target" ]; then
echo "Can't find the VASSAL directory: $VASSAL"
exit 1
fi
mpoint=/data/vassal/
VASSAL_VOLUME="--volume $target:$mpoint"
VASSAL_ENV="--env VASSAL_DIR=$mpoint --env VASSAL_DIR_TARGET=$target"
fi
# check if a VASL module file has been specified
if [ -n "$VASL_MOD" ]; then
target=$( get_target FILE "$VASL_MOD" )
if [ -z "$target" ]; then
echo "Can't find the VASL .vmod file: $VASL_MOD"
exit 1
fi
mpoint=/data/vasl.vmod
VASL_MOD_VOLUME="--volume $target:$mpoint"
VASL_MOD_ENV="--env VASL_MOD=$mpoint --env VASL_MOD_TARGET=$target"
fi
# check if a VASL extensions directory has been specified
if [ -n "$VASL_EXTNS" ]; then
target=$( get_target DIR "$VASL_EXTNS" )
if [ -z "$target" ]; then
echo "Can't find the VASL extensions directory: $VASL_EXTNS"
exit 1
fi
mpoint=/data/vasl-extensions/
VASL_EXTNS_VOLUME="--volume $target:$mpoint"
VASL_EXTNS_ENV="--env VASL_EXTNS_DIR=$mpoint --env VASL_EXTNS_DIR_TARGET=$target"
fi
# check if a VASL boards directory has been specified
if [ -n "$VASL_BOARDS" ]; then
target=$( get_target DIR "$VASL_BOARDS" )
if [ -z "$target" ]; then
echo "Can't find the VASL boards directory: $VASL_BOARDS"
exit 1
fi
mpoint=/data/boards/
VASL_BOARDS_VOLUME="--volume $target:$mpoint"
VASL_BOARDS_ENV="--env BOARDS_DIR=$mpoint --env BOARDS_DIR_TARGET=$target"
fi
# check if a Chapter H notes directory has been specified
if [ -n "$CHAPTER_H_NOTES" ]; then
target=$( get_target DIR "$CHAPTER_H_NOTES" )
if [ -z "$target" ]; then
echo "Can't find the Chapter H notes directory: $CHAPTER_H_NOTES"
exit 1
fi
mpoint=/data/chapter-h-notes/
CHAPTER_H_NOTES_VOLUME="--volume $target:$mpoint"
CHAPTER_H_NOTES_ENV="--env CHAPTER_H_NOTES_DIR=$mpoint --env CHAPTER_H_NOTES_DIR_TARGET=$target"
fi
# check if a user files directory has been specified
if [ -n "$USER_FILES" ]; then
target=$( get_target DIR "$USER_FILES" )
if [ -z "$target" ]; then
echo "Can't find the user files directory: $USER_FILES"
exit 1
fi
mpoint=/data/user-files/
USER_FILES_VOLUME="--volume $target:$mpoint"
USER_FILES_ENV="--env USER_FILES_DIR=$mpoint --env USER_FILES_DIR_TARGET=$target"
fi
# check if a template pack has been specified
if [ -n "$TEMPLATE_PACK" ]; then
# NOTE: The template pack can either be a file (ZIP) or a directory.
target=$( get_target FILE-OR-DIR "$TEMPLATE_PACK" )
if [ -z "$target" ]; then
echo "Can't find the template pack: $TEMPLATE_PACK"
exit 1
fi
mpoint=/data/template-pack
TEMPLATE_PACK_VOLUME="--volume $target:$mpoint"
TEMPLATE_PACK_ENV="--env DEFAULT_TEMPLATE_PACK=$mpoint"
fi
# check if logging has been configured
if [ -n "$LOGGING_CONFIG" ]; then
target=$( get_target FILE "$LOGGING_CONFIG" )
if [ -z "$target" ]; then
echo "Can't find the logging config file: $LOGGING_CONFIG"
exit 1
fi
mpoint=/app/vasl_templates/webapp/config/logging.yaml
LOGGING_CONFIG_VOLUME="--volume $target:$mpoint"
fi
if [ -n "$VASSAL_SHIM_LOGGING_CONFIG" ]; then
target=$( get_target FILE "$VASSAL_SHIM_LOGGING_CONFIG" )
if [ -z "$target" ]; then
echo "Can't find the VASSAL shim logging config file: $VASSAL_SHIM_LOGGING_CONFIG"
exit 1
fi
mpoint=/app/vassal-shim/release/logback-test.xml
VASSAL_SHIM_LOGGING_CONFIG_VOLUME="--volume $target:$mpoint"
fi
# check if external ASA/ROAR index files have been specified
# NOTE: We don't need to pass env.vars into the container, or anything like that. The code already
# saves the downloaded files in /tmp/ (inside the container), so all we need to do is map these files
# to the specified external files.
if [ -n "$ASA_INDEX" ]; then
target=$( realpath --no-symlinks "$ASA_INDEX" )
if [ ! -f "$target" ]; then
if ! touch "$target" 2>/dev/null; then
echo "Can't find the ASA index file: $ASA_INDEX"
exit 1
fi
fi
mpoint=/tmp/asl-scenario-archive.json
ASA_INDEX_VOLUME="--volume $target:$mpoint"
fi
if [ -n "$ROAR_INDEX" ]; then
target=$( realpath --no-symlinks "$ROAR_INDEX" )
if [ ! -f "$target" ]; then
if ! touch "$target" 2>/dev/null ; then
echo "Can't find the ROAR index file: $ASA_INDEX"
exit 1
fi
fi
mpoint=/tmp/roar-scenario-index.json
ROAR_INDEX_VOLUME="--volume $target:$mpoint"
fi
# check if an external v/o notes image cache directory has been specified
if [ -n "$VO_NOTES_IMAGE_CACHE" ]; then
target=$( realpath --no-symlinks "$VO_NOTES_IMAGE_CACHE" )
if [ ! -d "$target" ]; then
if ! mkdir "$target" 2>/dev/null; then
echo "Can't find the V/O notes image cache directory: $VO_NOTES_IMAGE_CACHE"
exit 1
fi
fi
mpoint=/tmp/vo-notes-image-cache/
VO_NOTES_IMAGE_CACHE_VOLUME="--volume $target:$mpoint"
fi
# check if testing has been enabled
if [ -n "$CONTROL_TESTS_PORT" ]; then
BUILD_ARGS="$BUILD_ARGS --build-arg CONTROL_TESTS_PORT=$CONTROL_TESTS_PORT"
CONTROL_TESTS_PORT_RUN="--env CONTROL_TESTS_PORT=$CONTROL_TESTS_PORT --publish $CONTROL_TESTS_PORT:$CONTROL_TESTS_PORT"
fi
# build the image
if [ -z "$NO_BUILD" ]; then
echo Building the \"$IMAGE_TAG\" image...
docker build \
--tag vasl-templates:$IMAGE_TAG \
$BUILD_ARGS \
$BUILD_NETWORK \
. 2>&1 \
| sed -e 's/^/ /'
if [ ${PIPESTATUS[0]} -ne 0 ]; then exit 10 ; fi
echo
fi
# launch the container
echo Launching the \"$IMAGE_TAG\" image as \"$CONTAINER_NAME\"...
docker run \
--name $CONTAINER_NAME \
--publish $PORT:5010 \
--env DOCKER_IMAGE_NAME="vasl-templates:$IMAGE_TAG" \
--env DOCKER_IMAGE_TIMESTAMP="$(date --utc +"%Y-%m-%d %H:%M:%S %:z")" \
--env BUILD_GIT_INFO="$(get_git_info)" \
--env DOCKER_CONTAINER_NAME="$CONTAINER_NAME" \
$CONTROL_TESTS_PORT_RUN \
$VASSAL_VOLUME $VASL_MOD_VOLUME $VASL_EXTNS_VOLUME $VASL_BOARDS_VOLUME $CHAPTER_H_NOTES_VOLUME $TEMPLATE_PACK_VOLUME $USER_FILES_VOLUME \
$LOGGING_CONFIG_VOLUME $VASSAL_SHIM_LOGGING_CONFIG_VOLUME \
$ASA_INDEX_VOLUME $ROAR_INDEX_VOLUME $VO_NOTES_IMAGE_CACHE_VOLUME \
$VASSAL_ENV $VASL_MOD_ENV $VASL_EXTNS_ENV $VASL_BOARDS_ENV $CHAPTER_H_NOTES_ENV $TEMPLATE_PACK_ENV $USER_FILES_ENV \
$RUN_NETWORK $DETACH \
$TEST_DATA_VASSAL $TEST_DATA_VASL_MODS \
-it --rm \
vasl-templates:$IMAGE_TAG \
2>&1 \
| sed -e 's/^/ /'
exit ${PIPESTATUS[0]}
}
# ---------------------------------------------------------------------
function get_git_info {
# NOTE: We assume the source code has a git repo, and git is installed, etc. etc., which should
# all be true, but in the event we can't get the current branch and commit ID, we return nothing,
# and nothing will be shown in the Program Info dialog in the UI.
cd "${0%/*}"
local branch=$( git branch | grep "^\*" | cut -c 3- )
local commit=$( git log | head -n 1 | cut -f 2 -d " " | cut -c 1-8 )
if [[ -n "$branch" && -n "$commit" ]]; then
echo "$branch:$commit"
fi
}
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
function get_target {
local type=$1
local target=$2
# check that the target exists
if [ "$type" == "FILE" ]; then
test -f "$target" || return
elif [ "$type" == "DIR" ]; then
test -d "$target" || return
elif [ "$type" == "FILE-OR-DIR" ]; then
ls "$target" >/dev/null 2>&1 || return
fi
# convert the target to a full path
# FUDGE! I couldn't get the "docker run" command to work with spaces in the volume targets (although
# copying the generated command into the terminal worked fine) (and no, using ${var@Q} didn't help).
# So, the next best thing is to allow users to create symlinks to the targets :-/
echo $( realpath --no-symlinks "$target" )
}
# ---------------------------------------------------------------------
function print_help {
echo "`basename "$0"` {options}"
echo " Build and launch the \"vasl-templates\" container."
echo
echo " -p --port Web server port number."
echo " -v --vasl-vmod Path to the VASL .vmod file."
echo " -e --vasl-extensions Path to the VASL extensions directory."
echo " -h --chapter-h Path to the Chapter H notes directory."
echo " -u --user-files Path to the user files directory."
echo
echo " -t --tag Docker tag."
echo " -d --detach Detach from the container and let it run in the background."
echo " --no-build Launch the container as-is (i.e. without rebuilding it first)."
echo " --build-network Docker network to use when building the container."
echo " --run-network Docker network to use when running the container."
echo
cat <<EOM
Build and launch the "vasl-templates" container.
-p --port Web server port number.
--vassal VASSAL installation directory.
-v --vasl Path to the VASL module file (.vmod).
-e --vasl-extensions Path to the VASL extensions directory.
--boards Path to the VASL boards.
--chapter-h Path to the Chapter H notes directory.
--user-files Path to the user files directory.
-k --template-pack Path to a user-defined template pack.
-t --tag Docker image tag.
--name Docker container name.
-d --detach Detach from the container and let it run in the background.
--no-build Launch the container as-is (i.e. without rebuilding the image first).
--build-network Docker network to use when building the image.
--run-network Docker network to use when running the container.
Options for storing data files outside the container (so that they can be re-used):
--asa-index Path to the ASL Scenario Archive index file (downloaded).
--roar-index Path to the ROAR index file (downloaded).
--vo-notes-image-cache Cache directory for images generated for vehicle/ordnance notes.
Options for the test suite:
--control-tests-port Remote test control port number.
--test-data-vassal Directory containing VASSAL releases.
--test-data-vasl-mods Directory containing VASL modules.
NOTE: If the port the webapp server is listening on *inside* the container is different
to the port exposed *outside* the container, webdriver image generation (e.g. Shift-Click
on a snippet button, or Chapter H content as images) may not work properly. This is because
a web browser is launched internally with snippet HTML and a screenshot taken of it, but
the HTML will contain links to the webapp server that work from outside the container,
but if those links don't resolve from inside the container, you will get broken images.
In this case, you will need to make such links resolve from inside the container e.g. by
port-forwarding, or via DNS.
NOTE: If the port the webapp server is listening on *inside* the container is different
to the port exposed *outside* the container, webdriver image generation (e.g. Shift-Click
on a snippet button, or Chapter H content as images) may not work properly. This is because
a web browser is launched internally with snippet HTML and a screenshot taken of it, but
the HTML will contain links to the webapp server that work from outside the container,
but if those links don't resolve from inside the container, you will get broken images.
In this case, you will need to make such links resolve from inside the container e.g. by
port-forwarding, or via DNS.
EOM
}
# ---------------------------------------------------------------------
main "$@"
# initialize
cd `dirname "$0"`
PORT=5010
VASL_MOD_LOCAL=
VASL_MOD=
VASL_EXTNS_LOCAL=
VASL_EXTNS=
CHAPTER_H_NOTES_LOCAL=
CHAPTER_H_NOTES=
USER_FILES_LOCAL=
USER_FILES=
TAG=latest
DETACH=
NO_BUILD=
BUILD_NETWORK=
RUN_NETWORK=
# parse the command-line arguments
if [ $# -eq 0 ]; then
print_help
exit 0
fi
params="$(getopt -o p:v:e:h:u:t:d -l port:,vasl-vmod:,vasl-extensions:,chapter-h:,user-files:,tag:,detach,no-build,build-network:,run-network:,help --name "$0" -- "$@")"
if [ $? -ne 0 ]; then exit 1; fi
eval set -- "$params"
while true; do
case "$1" in
-p | --port)
PORT=$2
shift 2 ;;
-v | --vasl-vmod)
VASL_MOD_LOCAL=$2
shift 2 ;;
-e | --vasl-extensions)
VASL_EXTNS_LOCAL=$2
shift 2 ;;
-h | --chapter-h)
CHAPTER_H_NOTES_LOCAL=$2
shift 2 ;;
-u | --user-files)
USER_FILES_LOCAL=$2
shift 2 ;;
-t | --tag)
TAG=$2
shift 2 ;;
-d | --detach )
DETACH=--detach
shift 1 ;;
--no-build )
NO_BUILD=1
shift 1 ;;
--build-network )
# FUDGE! We sometimes can't get out to the internet from the container (DNS problems) using the default
# "bridge" network, so we offer the option of using an alternate network (e.g. "host").
BUILD_NETWORK="--network $2"
shift 2 ;;
--run-network )
RUN_NETWORK="--network $2"
shift 2 ;;
--help )
print_help
exit 0 ;;
-- ) shift ; break ;;
* )
echo "Unknown option: $1" >&2
exit 1 ;;
esac
done
# check if a VASL .vmod file has been specified
if [ -n "$VASL_MOD_LOCAL" ]; then
if [ ! -f "$VASL_MOD_LOCAL" ]; then
echo "Can't find the VASL .vmod file: $VASL_MOD_LOCAL"
exit 1
fi
VASL_MOD=/data/vasl.vmod
VASL_MOD_VOLUME="--volume `readlink -f "$VASL_MOD_LOCAL"`:$VASL_MOD"
VASL_MOD_ENV="--env VASL_MOD=$VASL_MOD"
fi
# check if a VASL extensions directory has been specified
if [ -n "$VASL_EXTNS_LOCAL" ]; then
if [ ! -d "$VASL_EXTNS_LOCAL" ]; then
echo "Can't find the VASL extensions directory: $_EXTNS_DIR_LOCAL"
exit 1
fi
VASL_EXTNS=/data/vasl-extensions/
VASL_EXTNS_VOLUME="--volume `readlink -f "$VASL_EXTNS_LOCAL"`:$VASL_EXTNS"
VASL_EXTNS_ENV="--env VASL_EXTNS_DIR=$VASL_EXTNS"
fi
# check if a Chapter H notes directory has been specified
if [ -n "$CHAPTER_H_NOTES_LOCAL" ]; then
if [ ! -d "$CHAPTER_H_NOTES_LOCAL" ]; then
echo "Can't find the Chapter H notes directory: $CHAPTER_H_NOTES_LOCAL"
exit 1
fi
CHAPTER_H_NOTES=/data/chapter-h-notes/
CHAPTER_H_NOTES_VOLUME="--volume `readlink -f "$CHAPTER_H_NOTES_LOCAL"`:$CHAPTER_H_NOTES"
CHAPTER_H_NOTES_ENV="--env CHAPTER_H_NOTES_DIR=$CHAPTER_H_NOTES"
fi
# check if a user files directory has been specified
if [ -n "$USER_FILES_LOCAL" ]; then
if [ ! -d "$USER_FILES_LOCAL" ]; then
echo "Can't find the user files directory: $USER_FILES_LOCAL"
exit 1
fi
USER_FILES=/data/user-files/
USER_FILES_VOLUME="--volume `readlink -f "$USER_FILES_LOCAL"`:$USER_FILES"
USER_FILES_ENV="--env USER_FILES_DIR=$USER_FILES"
fi
# build the container
if [ -z "$NO_BUILD" ]; then
echo Building the \"$TAG\" container...
docker build $BUILD_NETWORK --tag vasl-templates:$TAG . 2>&1 \
| sed -e 's/^/ /'
if [ ${PIPESTATUS[0]} -ne 0 ]; then exit 10 ; fi
echo
fi
# launch the container
echo Launching the \"$TAG\" container...
docker run \
--publish $PORT:5010 \
--name vasl-templates \
$VASL_MOD_VOLUME $VASL_EXTNS_VOLUME $CHAPTER_H_NOTES_VOLUME $USER_FILES_VOLUME \
$VASL_MOD_ENV $VASL_EXTNS_ENV $CHAPTER_H_NOTES_ENV $USER_FILES_ENV \
$RUN_NETWORK $DETACH \
-it --rm \
vasl-templates:$TAG \
2>&1 \
| sed -e 's/^/ /'
exit ${PIPESTATUS[0]}

@ -16,31 +16,29 @@ def parse_requirements( fname ):
"""Parse a requirements file."""
lines = []
fname = os.path.join( os.path.split(__file__)[0], fname )
with open( fname, "r", encoding="utf-8" ) as fp:
for line in fp:
line = line.strip()
if line == "" or line.startswith("#"):
continue
lines.append( line )
for line in open(fname,"r"):
line = line.strip()
if line == "" or line.startswith("#"):
continue
lines.append( line )
return lines
# ---------------------------------------------------------------------
setup(
name = "vasl_templates",
version = "1.13", # nb: also update constants.py
version = "1.3", # nb: also update constants.py
description = "Create HTML snippets for use in VASL.",
license = "AGPLv3",
url = "https://code.pacman-ghost.com/public/vasl-templates",
url = "https://github.com/pacman-ghost/vasl-templates",
packages = find_packages(),
install_requires = parse_requirements( "requirements.txt" ),
extras_require = {
"gui": [
# NOTE: PyQt5 requirements: https://doc.qt.io/qt-5/linux.html
# Linux: mesa-libGL-devel ; @"C Development Tools and Libraries"
# NOTE: You may need to disable VMware 3D acceleration, if QWebEngineView is crashing.
"PyQT5==5.15.4",
"PyQtWebEngine==5.15.4",
# nb: WebEngine seems to be broken in 5.10.1 :-/
"PyQT5==5.10.0",
],
"dev": parse_requirements( "requirements-dev.txt" ),
},

@ -46,7 +46,7 @@ class BuildFile:
val = click.style( child.tag, fg="green" )
if line_nos:
val += ":{}".format( click.style( str(child.sourceline), fg="cyan" ) )
click.echo( "{header} {} {header}".format( val, header=header ) )
click.echo( "{} {} {}".format( header, val, header ) )
click.echo()
# dump any attributes
if attribs:
@ -173,8 +173,8 @@ def main( input_file, line_nos, images ):
# check if we've been given a .vmod file
if os.path.splitext( input_file.name )[1] == ".vmod":
# yup - extract the build file
with zipfile.ZipFile( input_file.name, "r" ) as zf:
build_file = zf.read( "buildFile" )
zip_file = zipfile.ZipFile( input_file.name, "r" )
build_file = zip_file.read( "buildFile" )
else:
# nope - read the build file from the specified file
build_file = input_file.read()

@ -2,6 +2,7 @@
import sys
import os
import json
import time
import io
import re
@ -11,8 +12,7 @@ from PyQt5.QtCore import QUrl
from PyQt5.QtGui import QDesktopServices, QIcon, QCursor
from PyQt5.QtWidgets import QDialog
from vasl_templates.webapp.config.constants import APP_NAME, APP_VERSION, APP_HOME_URL, APP_ISSUES_URL, IS_FROZEN
from vasl_templates.utils import get_build_info
from vasl_templates.webapp.config.constants import APP_NAME, APP_VERSION, APP_HOME_URL, BASE_DIR, IS_FROZEN
# ---------------------------------------------------------------------
@ -41,17 +41,28 @@ class AboutDialog( QDialog ):
self.app_icon.mouseReleaseEvent = self.on_app_icon_clicked
self.app_icon.setCursor( QCursor( QtCore.Qt.PointingHandCursor ) )
# get the build info
dname = os.path.join( BASE_DIR, "config" )
fname = os.path.join( dname, "build-info.json" )
if os.path.isfile( fname ):
build_info = json.load( open( fname, "r" ) )
else:
build_info = None
# load the dialog
self.app_name.setText( "{} ({})".format( APP_NAME, APP_VERSION ) )
self.license.setText( "Licensed under the GNU Affero General Public License (v3)." )
build_info = get_build_info()
if build_info:
buf = io.StringIO()
timestamp = build_info[ "timestamp" ]
buf.write( "Built {}".format(
time.strftime( "%d %B %Y %H:%M", time.localtime( build_info["timestamp"] ) )
time.strftime( "%d %B %Y %H:%S", time.localtime( timestamp ) ) # nb: "-d" doesn't work on Windows :-/
) )
if "git_info" in build_info:
buf.write( " <small><tt>({})</tt></small>".format( build_info["git_info"] ) )
if "branch_name" in build_info or "last_commit_id" in build_info:
buf.write( " <small><em>({}".format( build_info["branch_name"] ) )
if "last_commit_id" in build_info:
buf.write( ":{}".format( build_info["last_commit_id"][:8] ) )
buf.write( ")</em></small>" )
buf.write( "." )
self.build_info.setText( buf.getvalue() )
else:
@ -60,11 +71,8 @@ class AboutDialog( QDialog ):
self.home_url.setText( "Visit us at <a href='{}'>{}</a>.".format(
APP_HOME_URL, mo.group(1) if mo else APP_HOME_URL
) )
self.issues_url.setText( "Report a bug, request a feature, ask a question <a href='{}'>here</a>.".format(
APP_ISSUES_URL
) )
def on_app_icon_clicked( self, event ): #pylint: disable=unused-argument
def on_app_icon_clicked( self, event ): #pylint: disable=no-self-use,unused-argument
"""Click handler."""
QDesktopServices.openUrl( QUrl( APP_HOME_URL ) )

@ -21,9 +21,6 @@ class FileDialog:
self.default_extn = default_extn
self.filters = filters
self.curr_fname = default_fname
# NOTE: We can't just use the directory of self.curr_fname, since this gets reset
# when the user chooses "new scenario", but we want to remember the current directory.
self._curr_dir = os.path.dirname( default_fname ) if default_fname else None
def load_file( self, binary ):
"""Load a file."""
@ -31,7 +28,7 @@ class FileDialog:
# ask the user which file to load
fname, _ = QFileDialog.getOpenFileName(
self.parent, "Load {}".format( self.object_name ),
self._get_start_path(),
self.curr_fname,
self.filters
)
if not fname:
@ -47,7 +44,6 @@ class FileDialog:
if not binary:
data = data.decode( "utf-8" )
self.curr_fname = fname
self._curr_dir = os.path.dirname( fname )
return data
@ -62,8 +58,8 @@ class FileDialog:
# ask the user where to save the file
fname, _ = QFileDialog.getSaveFileName(
self.parent, "Save {}".format( self.object_name ),
self._get_start_path(),
self.parent, "Save {}".format( self.object_name),
self.curr_fname,
self.filters
)
if not fname:
@ -84,13 +80,4 @@ class FileDialog:
self.parent.showErrorMsg( "Can't save the {}:\n\n{}".format( self.object_name, ex ) )
continue
self.curr_fname = fname
self._curr_dir = os.path.dirname( fname )
return True
def _get_start_path( self ):
"""Get the start filename or directory path for saving/loading files."""
if self.curr_fname and os.path.isabs( self.curr_fname ):
return self.curr_fname
if self._curr_dir and self.curr_fname:
return os.path.join( self._curr_dir, self.curr_fname )
return self.curr_fname

@ -11,37 +11,13 @@ import logging
import urllib.request
from urllib.error import URLError
# FUDGE! This works around a problem running the compiled desktop app on Fedora 30.
# https://github.com/pyinstaller/pyinstaller/issues/1113#issuecomment-244855512
# https://github.com/pyinstaller/pyinstaller/issues/1113#issuecomment-551934945
import encodings.idna #pylint: disable=unused-import
import PyQt5.QtWebEngineWidgets
from PyQt5.QtWidgets import QApplication, QMessageBox
from PyQt5.QtCore import Qt, QSettings, QDir
import PyQt5.QtCore
import click
# notify everyone that we're being run as the desktop application
os.environ[ "IS_DESKTOP_APP" ] = "1"
os.environ[ "QDIR_HOME_PATH" ] = QDir.homePath()
from vasl_templates.webapp.utils import SimpleError, is_windows
# NOTE: We're supposed to do the following to support HiDPI, but it causes the main window
# to become extremely large when the Windows zoom level is high (and it doesn't really fix
# the dialog layout problems anyway :-/).# Since we're a webapp running in a browser,
# desktop DPI isn't really an issue for us, we just need to make sure that the Qt dialogs
# look OK. I adjusted the layout for the About box so it's correct for HiDPI; it doesn't
# look great for normal DPI (too much whitespace), but it's useable.
# # nb: this must be done before the QApplication object is created
# QApplication.setAttribute( PyQt5.QtCore.Qt.AA_EnableHighDpiScaling, True )
# QApplication.setAttribute( PyQt5.QtCore.Qt.AA_UseHighDpiPixmaps, True )
# FUDGE! We need this to get the embedded browser working on Fedora 35 (things were
# still OK on Windows, but setting this doesn't seem to hurt), and it needs to be done
# before creating the QApplication.
os.environ[ "QTWEBENGINE_CHROMIUM_FLAGS" ] = "--no-sandbox"
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 )
@ -85,7 +61,7 @@ def main( template_pack, default_scenario, remote_debugging, debug ):
# assume too much about how much of our expected environment has been set up.
try:
fname = os.path.join( QDir.homePath(), "vasl-templates.log" )
with open( fname, "w", encoding="utf-8" ) as fp:
with open( fname, "w" ) as fp:
traceback.print_exc( file=fp )
except: #pylint: disable=bare-except
pass
@ -99,20 +75,9 @@ def _do_main( template_pack, default_scenario, remote_debugging, debug ): #pylin
# NOTE: We do these imports here (instead of at the top of the file) so that we can catch errors.
from vasl_templates.webapp import app as webapp
from vasl_templates.webapp import globvars, load_debug_config
from vasl_templates.webapp import load_debug_config
from vasl_templates.webapp import main as webapp_main, snippets as webapp_snippets
# initialize logging
# NOTE: We set up basic logging for people using the desktop app (if they are running from source,
# or using Docker, there is an expectation they can do this themselves). If logging has already
# been set up, this config is in *addition* to what's already been configured.
handler = logging.FileHandler( globvars.user_profile.default_log_fname, mode="w" )
handler.setLevel( logging.WARNING )
handler.setFormatter(
logging.Formatter( "%(asctime)s | %(message)s" )
)
logging.getLogger().addHandler( handler )
# configure the default template pack
if template_pack:
if template_pack.lower().endswith( ".zip" ):
@ -136,12 +101,12 @@ def _do_main( template_pack, default_scenario, remote_debugging, debug ): #pylin
os.environ["QTWEBENGINE_REMOTE_DEBUGGING"] = remote_debugging
# load the application settings
app_settings_fname = "vasl-templates.ini" if sys.platform == "win32" else ".vasl-templates.conf"
if not os.path.isfile( app_settings_fname ) :
app_settings_fname = os.path.join( QDir.homePath(), app_settings_fname )
# FUDGE! Declaring app_settings as global here doesn't work on Windows (?!), we have to do this weird import :-/
import vasl_templates.main #pylint: disable=import-self
vasl_templates.main.app_settings = QSettings(
globvars.user_profile.desktop_settings_fname,
QSettings.IniFormat
)
vasl_templates.main.app_settings = QSettings( app_settings_fname, QSettings.IniFormat )
# install the debug config file
if debug:
@ -162,19 +127,16 @@ def _do_main( template_pack, default_scenario, remote_debugging, debug ): #pylin
MainWindow.showErrorMsg( "Couldn't install the server settings:\n\n{}".format( ex ) )
return 2
# disable the Flask "do not use in a production environment" warning
import flask.cli
flask.cli.show_server_banner = lambda *args: None
# start the webapp server
flask_port = webapp.config[ "FLASK_PORT_NO" ]
port = webapp.config[ "FLASK_PORT_NO" ]
def webapp_thread():
"""Run the webapp server."""
try:
import waitress
# FUDGE! Browsers tend to send a max. of 6-8 concurrent requests per server, so we increase
# the number of worker threads to avoid task queue warnings :-/
nthreads = webapp.config.get( "WAITRESS_THREADS", 8 )
waitress.serve( webapp,
host="localhost", port=flask_port,
threads=nthreads
)
webapp.run( host="localhost", port=port, use_reloader=False )
except Exception as ex: #pylint: disable=broad-except
logging.critical( "WEBAPP SERVER EXCEPTION: %s", ex )
logging.critical( traceback.format_exc() )
@ -199,17 +161,17 @@ def _do_main( template_pack, default_scenario, remote_debugging, debug ): #pylin
if _webapp_error:
break
try:
url = "http://localhost:{}/ping".format( flask_port )
with urllib.request.urlopen( url ) as resp:
resp_data = resp.read().decode( "utf-8" )
# we got a response - figure out if we connected to ourself or another instance
if resp_data[:6] != "pong: ":
raise SimpleError( "Unexpected server check response: {}".format( resp_data ) )
if resp_data[6:] == INSTANCE_ID:
break
from vasl_templates.webapp.config.constants import APP_NAME
QMessageBox.warning( None, APP_NAME, "The program is already running." )
return -1
url = "http://localhost:{}/ping".format( port )
resp = urllib.request.urlopen( url ).read().decode( "utf-8" )
# we got a response - figure out if we connected to ourself or another instance
if resp[:6] != "pong: ":
raise SimpleError( "Unexpected server check response: {}".format( resp ) )
if resp[6:] == INSTANCE_ID:
break
else:
from vasl_templates.webapp.config.constants import APP_NAME
QMessageBox.warning( None, APP_NAME, "The program is already running." )
return -1
except URLError:
# no response - the webapp server is probably still starting up
time.sleep( 0.25 )
@ -233,29 +195,21 @@ def _do_main( template_pack, default_scenario, remote_debugging, debug ): #pylin
opengl_type = getattr( Qt, opengl_type )
QApplication.setAttribute( opengl_type )
#pylint: disable=line-too-long
# FUDGE! This works around a weird problem on Windows, if it has been configured to *not* show
# accelerator underlines by default. Pressing ALT is supposed to show them, but doesn't :-/
# The odd thing is, the default theme is "windowsvista", but we need to set it anyway (probably
# a timing issue during startup). It might also have something to do with virtualenv's:
# https://stackoverflow.com/questions/69032767/show-hide-menu-underline-accelerators-with-pyqt-according-to-platform-integratio#comment122036986_69032767
#pylint: enable=line-too-long
if is_windows():
QApplication.setStyle( "windowsvista" )
# disable the context help button in the title bar (Windows only)
QApplication.setAttribute( PyQt5.QtCore.Qt.AA_DisableWindowContextHelpButton )
# check if we should disable the embedded browser
disable_browser = webapp.config.get( "DISABLE_WEBENGINEVIEW" )
# run the application
url = "http://localhost:{}".format( flask_port )
url = "http://localhost:{}".format( port )
from vasl_templates.main_window import MainWindow #pylint: disable=cyclic-import
main_window = MainWindow( url, disable_browser )
main_window.show()
ret_code = qt_app.exec_()
# shutdown the webapp server
url = "http://localhost:{}/shutdown".format( port )
urllib.request.urlopen( url ).read()
thread.join()
return ret_code
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

@ -14,10 +14,9 @@ from PyQt5.QtGui import QDesktopServices, QIcon
from PyQt5.QtCore import Qt, QUrl, QMargins, pyqtSlot, QVariant
from vasl_templates.webapp.config.constants import APP_NAME, APP_VERSION, IS_FROZEN
from vasl_templates.webapp import globvars
from vasl_templates.main import app_settings
from vasl_templates.web_channel import WebChannelHandler
from vasl_templates.utils import catch_exceptions, launch_file
from vasl_templates.utils import catch_exceptions
_CONSOLE_SOURCE_REGEX = re.compile( r"^http://.+?/static/(.*)$" )
@ -26,11 +25,10 @@ _CONSOLE_SOURCE_REGEX = re.compile( r"^http://.+?/static/(.*)$" )
class AppWebPage( QWebEnginePage ):
"""Application web page."""
def acceptNavigationRequest( self, url, nav_type, is_mainframe ): #pylint: disable=unused-argument
def acceptNavigationRequest( self, url, nav_type, is_mainframe ): #pylint: disable=no-self-use,unused-argument
"""Called when a link is clicked."""
if url.host() in ("localhost","127.0.0.1"):
if "/asl-rulebook2/" not in url.url(): # nb: asl-rulebook2 links are routed through our webapp
return True
return True
if not is_mainframe:
# NOTE: We get here if we're in a child frame (e.g. Google Maps). However, we can't just ignore
# these requests, because the help is also in a frame, and we want links to open in an external browser.
@ -40,12 +38,12 @@ class AppWebPage( QWebEnginePage ):
QDesktopServices.openUrl( url )
return False
def javaScriptConsoleMessage( self, level, msg, line_no, source_id ): #pylint: disable=unused-argument
def javaScriptConsoleMessage( self, level, msg, line_no, source_id ): #pylint: disable=unused-argument,no-self-use
"""Log a Javascript console message."""
mo = _CONSOLE_SOURCE_REGEX.search( source_id )
source = mo.group(1) if mo else source_id
logger = logging.getLogger( "javascript" )
logger.warning( "%s:%d - %s", source, line_no, msg )
logger.info( "%s:%d - %s", source, line_no, msg )
# ---------------------------------------------------------------------
@ -64,27 +62,25 @@ class MainWindow( QWidget ):
# initialize the main window
self.setWindowTitle( APP_NAME )
if IS_FROZEN:
base_dir = os.path.join( sys._MEIPASS, "vasl_templates/webapp" ) #pylint: disable=no-member,protected-access
dname = os.path.join( sys._MEIPASS, "vasl_templates/webapp" ) #pylint: disable=no-member,protected-access
else:
base_dir = os.path.join( os.path.split(__file__)[0], "webapp" )
dname = os.path.join( os.path.split(__file__)[0], "webapp" )
self.setWindowIcon( QIcon(
os.path.join( base_dir, "static/images/app.ico" )
os.path.join( dname, "static/images/app.ico" )
) )
# create the menu
menu_bar = QMenuBar( self )
file_menu = menu_bar.addMenu( "&File" )
def add_action( caption, icon, handler ):
def add_action( caption, handler ):
"""Add a menu action."""
icon = QIcon( os.path.join( base_dir, "static/images/menu", icon ) if icon else None )
action = QAction( icon, caption, self )
action = QAction( caption, self )
action.triggered.connect( handler )
file_menu.addAction( action )
add_action( "&Settings", "settings.png", self.on_settings )
add_action( "&Log file", "log.png", self.on_log_file )
add_action( "&About", "info.png", self.on_about )
add_action( "&Settings", self.on_settings )
add_action( "&About", self.on_about )
file_menu.addSeparator()
add_action( "E&xit", "exit.png", self.on_exit )
add_action( "E&xit", self.on_exit )
# set the window geometry
if disable_browser:
@ -96,14 +92,11 @@ class MainWindow( QWidget ):
self.restoreGeometry( val )
else :
self.resize( 1000, 650 )
# NOTE: This should be wide enough for the sortable hints to not wrap (so that
# we don't see a scrollbar when their panels are reduced to their minimum height).
# We also want the Trumbowyg button pane for the VC to wrap somewhere sensible.
self.setMinimumSize( 1030, 650 )
self.setMinimumSize( 1000, 620 )
# initialize the layout
layout = QVBoxLayout( self )
layout.setMenuBar( menu_bar )
layout.addWidget( menu_bar )
# FUDGE! We offer the option to disable the QWebEngineView since getting it to run
# under Windows (especially older versions) is unreliable (since it uses OpenGL).
# By disabling it, the program will at least start (in particular, the webapp server),
@ -171,9 +164,6 @@ class MainWindow( QWidget ):
if self._view:
app_settings.setValue( "MainWindow/geometry", self.saveGeometry() )
self.close()
# FUDGE! We need to do this to stop PyQt 5.15.2 from complaining that the profile
# is being deleted while the page is still alive.
self._view.page().deleteLater()
# check if the scenario is dirty
def callback( is_dirty ):
@ -221,10 +211,6 @@ class MainWindow( QWidget ):
dlg = ServerSettingsDialog( self )
dlg.exec_()
def on_log_file( self ):
"""Menu action handler."""
launch_file( globvars.user_profile.default_log_fname )
def on_about( self ):
"""Menu action handler."""
from vasl_templates.about import AboutDialog #pylint: disable=cyclic-import
@ -317,7 +303,7 @@ class MainWindow( QWidget ):
@pyqtSlot( str )
@catch_exceptions( caption="SLOT EXCEPTION" )
def on_user_settings_change( self, user_settings ):
def on_user_settings_change( self, user_settings ): #pylint: disable=no-self-use
"""Called when the user changes the user settings."""
# delete all existing keys
for key in app_settings.allKeys():

@ -29,7 +29,7 @@ SERVER_SETTINGS = {
"webdriver-path": { "type": "file", "name": "webdriver", "allow_on_path": True, "fspec": _EXE_FSPEC },
"chapter-h-notes-dir": { "type": "dir", "name": "Chapter H notes directory" },
"chapter-h-image-scaling": { "type": "int", "name": "Chapter H image scaling" },
"user-files-dir": { "type": "dir", "name": "user files directory", "allow_urls": True },
"user-files-dir": { "type": "dir", "name": "user files directory" },
}
# ---------------------------------------------------------------------
@ -133,9 +133,8 @@ class ServerSettingsDialog( QDialog ):
ctrl = self._get_control( key )
func = getattr( self, "_unload_"+vals["type"] )
args, kwargs = [ vals["name"] ], {}
for k in ("allow_on_path","allow_urls"):
if k in vals:
kwargs[ k ] = vals[ k ]
if "allow_on_path" in vals:
kwargs[ "allow_on_path" ] = vals["allow_on_path"]
val = func( ctrl, *args, **kwargs )
if val is None:
# nb: something failed validation, an error message has already been shown
@ -155,9 +154,9 @@ class ServerSettingsDialog( QDialog ):
app_settings.setValue( "ServerSettings/"+key, val )
try:
install_server_settings( False )
except Exception as ex2: #pylint: disable=broad-except
except Exception as ex: #pylint: disable=broad-except
logging.error( traceback.format_exc() )
MainWindow.showErrorMsg( "Couldn't rollback the server settings:\n\n{}".format( ex2 ) )
MainWindow.showErrorMsg( "Couldn't rollback the server settings:\n\n{}".format( ex ) )
return
self.close()
@ -190,11 +189,9 @@ class ServerSettingsDialog( QDialog ):
self._update_ui()
@staticmethod
def _unload_dir( ctrl, name, allow_urls=False ):
def _unload_dir( ctrl, name ):
"""Unload and validate a directory path."""
dname = ctrl.text().strip()
if allow_urls and dname.startswith( ("http://","https://") ):
return dname
if dname and not os.path.isdir( dname ):
MainWindow.showErrorMsg( "Can't find the {}:\n {}".format( name, dname ) )
ctrl.setFocus()

@ -25,14 +25,12 @@ def roar_string( s ):
# load the ASL Scenario Archive scenarios
fname = sys.argv[1]
with open( fname, "r", encoding="utf-8" ) as fp:
asa_data = json.load( fp )
asa_data = json.load( open( fname, "r" ) )
_build_asa_scenario_index( _asa_scenarios, asa_data, None )
# load the ROAR scenarios
fname = sys.argv[2]
with open( fname, "r", encoding="utf-8" ) as fp:
roar_data = json.load( fp )
roar_data = json.load( open( fname, "r" ) )
_build_roar_scenario_index( _roar_scenarios, roar_data, None )
# try to connect each ASA scenario to ROAR

@ -179,7 +179,7 @@ def dump_time_plot( players, log_file, roll_type, window_size ):
"""Dump the buffered ROLL events."""
print( tabulate.tabulate( rolls, tablefmt="plain" ) )
def onTurnTrack( evt ): #pylint: disable=unused-variable,possibly-unused-variable
def onTurnTrack( evt ): #pylint: disable=unused-variable
"""Process a TURN TRACK event."""
nonlocal rolls
if rolls:
@ -189,7 +189,7 @@ def dump_time_plot( players, log_file, roll_type, window_size ):
print( "--- {} Turn {} {} ---".format( evt["side"], evt["turnNo"], evt["phase"] ) )
print()
def onRoll( evt ) : #pylint: disable=unused-variable,possibly-unused-variable
def onRoll( evt ) : #pylint: disable=unused-variable
"""Process a ROLL event"""
# check if we should process this ROLL event
if roll_type:

@ -1,83 +0,0 @@
#!/usr/bin/env python3
""" Prepare the piece info for a VASL module.
The main program used to identify 5/8" counters by reading a module's buildFile and checking the height
attribute of the PieceSlot nodes, but it turns out this is the wrong thing to do (this field actually
controls the size of the piece's entry in the counter palette):
https://github.com/vasl-developers/vasl/issues/1195
For each version of VASL supported, run vassal-shim (getPieceInfo command) to analyze the module's
buildFile and get the correct counter sizes. Then pass the output into this script, to generate
the final data file that should be saved in the $/data/vasl-$VERSION/ directory, where it will
be read by the main program.
NOTE: Introducing this process opens the possibility of also extracting the image file paths
within the .vmod file, instead of the current messy parsing of the PieceSlot CDATA... :-/
"""
import sys
import os
import json
import xml.etree.ElementTree as ET
# ---------------------------------------------------------------------
# initialize
report = {}
# figure out which GPID's we're interested in
gpids = set()
def get_gpids( vo_type ):
"""Get the GPID's from our data files."""
dname = os.path.join( os.path.dirname(__file__), "../webapp/data", vo_type )
for root,_,fnames in os.walk( dname ):
for fname in fnames:
if os.path.splitext( fname )[1] != ".json":
continue
fname = os.path.join( root, fname )
with open( fname, "r", encoding="utf-8" ) as fp:
entries = json.load( fp )
for entry in entries:
entry_gpid = entry.get( "gpid" )
if not entry_gpid:
continue
if isinstance( entry_gpid, list ):
gpids.update( str(g) for g in entry_gpid )
else:
gpids.add( str( entry_gpid ) )
get_gpids( "vehicles" )
get_gpids( "ordnance" )
# parse the piece info generated by vassal-shim
doc = ET.parse( sys.stdin )
for piece_info in doc.getroot():
gpid = piece_info.attrib["gpid"]
if gpid not in gpids:
continue
info = {}
# check if the next piece is small
# FUDGE! We used to check for <= 48, but what we get is GamePiece.boundingBox(), which is
# the click zone for the counter, not the actual size of the counter's image :-/
if int( piece_info.attrib["height"] ) <= 55:
info["is_small"] = True
if info:
report[ gpid ] = info
# FUDGE! These are from extensions - it's not worth trying to figure these out programtically.
report[ "adf:1948" ] = { "is_small": True } # BFP Blood & Jungle: Dutch Brandt 47mm Mortar
report[ "adf:75" ] = { "is_small": True } # BFP Blood & Jungle: Indonesian Type 89 Heavy Grenade Launcher
report[ "adf:77" ] = { "is_small": True } # BFP Blood & Jungle: Indonesian Type 97 Automatic Gun
report[ "adf:76" ] = { "is_small": True } # BFP Blood & Jungle: Indonesian Year-11 Flat-Trajectory INF Gun
report[ "adf:1407" ] = { "is_small": True } # BFP Poland In Flames: German 2cm Tankbusche S-18
report[ "08d:75" ] = { "is_small": True } # Fight For Seoul: American M20(L) 75mm Recoilless Rifle
# output the final report
print( "{" )
lines = []
for gpid, piece_info in report.items():
lines.append( "\"{}\": {}".format(
gpid, json.dumps( piece_info )
) )
print( ",\n".join( lines ) )
print( "}" )

@ -38,10 +38,9 @@ def make_chapter_h_placeholders( output_fname, log=None \
results = {}
# load the nationalities
global nationalities
fname = os.path.join( os.path.split(__file__)[0], "../webapp/data/default-template-pack/nationalities.json" )
with open( fname, "r", encoding="utf-8" ) as fp:
nationalities = json.load( fp )
global nationalities
nationalities = json.load( open( fname, "r" ) )
# load the vehicle/ordnance data files
base_dir = os.path.join( os.path.split(__file__)[0], "../webapp/data/" )
@ -67,7 +66,10 @@ def make_chapter_h_placeholders( output_fname, log=None \
notes, ma_notes = load_vo_data( fname, nat )
if nat not in results:
results[ nat ] = {}
results[ nat ][ vo_type ] = { "notes": notes, "ma_notes": ma_notes }
if nat == "landing-craft":
results[ nat ][ vo_type ] = { "notes": notes, "ma_notes": ma_notes }
else:
results[ nat ][ vo_type ] = { "notes": notes, "ma_notes": ma_notes }
# insert the K:FW vehicles/ordnance
kfw_vo_data = load_kfw_vo_data()
@ -96,21 +98,16 @@ def make_chapter_h_placeholders( output_fname, log=None \
base_dir = os.path.join( os.path.split(__file__)[0], "../webapp/data/extensions" )
for fname in glob.glob( os.path.join( base_dir, "*.json" ) ):
extn_data = load_vo_data_from_extension( fname )
for nat, vo_types in extn_data.items():
for vo_type in vo_types:
for key in vo_types[vo_type]:
for nat in extn_data:
for vo_type in extn_data[nat]:
for key in extn_data[nat][vo_type]:
if nat not in results:
results[nat] = {}
if vo_type not in results[nat]:
results[nat][vo_type] = {}
if key not in results[nat][vo_type]:
results[nat][vo_type][key] = []
results[nat][vo_type][key].extend( vo_types[vo_type].get( key, [] ) )
# FUDGE! Allied Ordnance Note D is not in the Allied Minor common.json file (it's referenced
# by some of the nationality-specific Guns e.g. Belgian DBT), so we add it in manually.
assert "D" not in results["allied-minor"]["ordnance"]["ma_notes"]
results["allied-minor"]["ordnance"]["ma_notes"].append( "D" )
results[nat][vo_type][key].extend( extn_data[nat][vo_type].get( key, [] ) )
# generate the placeholder files
with zipfile.ZipFile( output_fname, "w" ) as zip_file:
@ -155,8 +152,7 @@ def load_vo_data( fname, nat ):
notes, ma_notes = set(), set()
# load the file
with open( fname, "r", encoding="utf-8" ) as fp:
vo_data = json.load( fp )
vo_data = json.load( open( fname, "r" ) )
for vo_entry in vo_data:
if "note_number" in vo_entry:
notes.add(
@ -220,14 +216,8 @@ def load_vo_data_from_extension( fname ):
results = {}
# get the extension ID
with open( fname, "r", encoding="utf-8" ) as fp:
data = json.load( fp )
data = json.load( open( fname, "r" ) )
extn_id = data["extensionId"]
if extn_id == "08d":
# NOTE: All the vehicle/ordnance notes and multi-applicable notes in the Fight For Seoul extension
# actually reference those in K:FW (and there is code in the main application to handle this), so
# the user doesn't need to set anything up for FfS (other than what they already need to do for K:FW).
return results
# load the file
for nat in data:
@ -273,8 +263,8 @@ REDIRECTED_MA_NOTE_REGEX = re.compile(
def _extract_note_number( val ):
"""Extract a vehicle/ordnance's note number."""
mo = re.search( r"^\d+(\.\d)?", val )
return mo.group()
mo = re.search( r"^\d+", val )
return int( mo.group() )
def _extract_ma_note_ids( val ):
"""Extract a vehicle/ordnance's multi-applicable note ID's."""

@ -15,7 +15,6 @@ allied-minor/ordnance/36.png
allied-minor/ordnance/a.html
allied-minor/ordnance/b.html
allied-minor/ordnance/c.html
allied-minor/ordnance/d.html
allied-minor/ordnance/de.html
allied-minor/ordnance/gr.html
allied-minor/ordnance/n.html
@ -115,11 +114,9 @@ american/vehicles/45.png
american/vehicles/46.png
american/vehicles/47.png
american/vehicles/48.png
american/vehicles/49.1.png
american/vehicles/49.png
american/vehicles/5.png
american/vehicles/50.png
american/vehicles/51.1.png
american/vehicles/51.png
american/vehicles/52.png
american/vehicles/53.png
@ -184,6 +181,10 @@ american/vehicles/w.html
american/vehicles/x.html
american/vehicles/y.html
american/vehicles/z.html
anzac/vehicles/adf-bj/1.png
anzac/vehicles/adf-bj/2.png
anzac/vehicles/adf-bj/3.png
anzac/vehicles/adf-bj/4.png
axis-minor/ordnance/44.png
axis-minor/ordnance/45.png
axis-minor/ordnance/46.png
@ -423,10 +424,6 @@ british/vehicles/w.html
british/vehicles/x.html
british/vehicles/y.html
british/vehicles/z.html
british~anzac/vehicles/adf-bj/1.png
british~anzac/vehicles/adf-bj/2.png
british~anzac/vehicles/adf-bj/3.png
british~anzac/vehicles/adf-bj/4.png
bulgarian/ordnance/37.png
bulgarian/ordnance/38.png
bulgarian/ordnance/39.png
@ -754,7 +751,6 @@ german/vehicles/33.png
german/vehicles/34.png
german/vehicles/35.png
german/vehicles/36.png
german/vehicles/37.1.png
german/vehicles/37.png
german/vehicles/38.png
german/vehicles/39.png
@ -763,9 +759,7 @@ german/vehicles/40.png
german/vehicles/41.png
german/vehicles/42.png
german/vehicles/43.png
german/vehicles/44.1.png
german/vehicles/44.png
german/vehicles/45.1.png
german/vehicles/45.png
german/vehicles/46.png
german/vehicles/47.png
@ -789,10 +783,7 @@ german/vehicles/62.png
german/vehicles/63.png
german/vehicles/64.png
german/vehicles/65.png
german/vehicles/66.1.png
german/vehicles/66.png
german/vehicles/67.1.png
german/vehicles/67.2.png
german/vehicles/67.png
german/vehicles/68.png
german/vehicles/69.png
@ -810,19 +801,14 @@ german/vehicles/79.png
german/vehicles/8.png
german/vehicles/80.png
german/vehicles/81.png
german/vehicles/82.1.png
german/vehicles/82.png
german/vehicles/83.png
german/vehicles/84.png
german/vehicles/85.png
german/vehicles/86.png
german/vehicles/87.png
german/vehicles/88.1.png
german/vehicles/88.png
german/vehicles/89.png
german/vehicles/9.1.png
german/vehicles/9.2.png
german/vehicles/9.3.png
german/vehicles/9.png
german/vehicles/90.png
german/vehicles/91.png
@ -1317,7 +1303,6 @@ romanian/vehicles/3.png
romanian/vehicles/4.png
romanian/vehicles/5.png
romanian/vehicles/6.png
russian/ordnance/1.1.png
russian/ordnance/1.png
russian/ordnance/10.png
russian/ordnance/11.png
@ -1333,7 +1318,6 @@ russian/ordnance/2.png
russian/ordnance/20.png
russian/ordnance/21.png
russian/ordnance/22.png
russian/ordnance/23.1.png
russian/ordnance/23.png
russian/ordnance/24.png
russian/ordnance/25.png
@ -1352,12 +1336,9 @@ russian/ordnance/adf-pif/2.png
russian/ordnance/adf-pif/3.png
russian/ordnance/adf-pif/a.html
russian/ordnance/adf-pif/b.html
russian/vehicles/1.1.png
russian/vehicles/1.png
russian/vehicles/10.png
russian/vehicles/11.1.png
russian/vehicles/11.png
russian/vehicles/12.1.png
russian/vehicles/12.png
russian/vehicles/13.png
russian/vehicles/14.png
@ -1368,8 +1349,6 @@ russian/vehicles/18.png
russian/vehicles/19.png
russian/vehicles/2.png
russian/vehicles/20.png
russian/vehicles/21.1.png
russian/vehicles/21.2.png
russian/vehicles/21.png
russian/vehicles/22.png
russian/vehicles/23.png
@ -1395,25 +1374,12 @@ russian/vehicles/40.png
russian/vehicles/41.png
russian/vehicles/42.png
russian/vehicles/43.png
russian/vehicles/44.1.png
russian/vehicles/44.2.png
russian/vehicles/44.png
russian/vehicles/45.png
russian/vehicles/46.1.png
russian/vehicles/46.2.png
russian/vehicles/46.3.png
russian/vehicles/46.png
russian/vehicles/47.1.png
russian/vehicles/47.2.png
russian/vehicles/47.3.png
russian/vehicles/47.png
russian/vehicles/5.png
russian/vehicles/6.1.png
russian/vehicles/6.2.png
russian/vehicles/6.3.png
russian/vehicles/6.4.png
russian/vehicles/6.png
russian/vehicles/7.1.png
russian/vehicles/7.png
russian/vehicles/8.png
russian/vehicles/9.png
@ -1468,9 +1434,6 @@ slovakian/vehicles/20.png
slovakian/vehicles/21.png
slovakian/vehicles/22.png
slovakian/vehicles/23.png
swedish/ordnance/1.png
swedish/ordnance/2.png
swedish/ordnance/3.png
thai/ordnance/adf-bj/4.png
thai/ordnance/adf-bj/5.png
thai/ordnance/adf-bj/8.png

@ -18,8 +18,7 @@ def test_make_chapter_h_placeholders():
# get the expected results
fname = os.path.join( os.path.split(__file__)[0], "fixtures/chapter-h-placeholders.txt" )
with open( fname, "r", encoding="utf-8" ) as fp:
expected = [ line.strip() for line in fp ]
expected = [ line.strip() for line in open(fname,"r") ]
# check the results
with ZipFile( temp_file.name, "r" ) as zip_file:

@ -31,11 +31,11 @@ stats = defaultdict( lambda: [0,0] ) # nb: [ #runs, total elapsed time ]
# ---------------------------------------------------------------------
@click.command()
@click.option( "--webapp-url", default="http://localhost:5010", help="Webapp server URL." )
@click.option( "--server-url", default="http://localhost:5010", help="Webapp server URL." )
@click.option( "--snippet-images", default=1, help="Number of 'snippet image' threads to run." )
@click.option( "--update-vsav", default=1, help="Number of 'update VSAV' threads to run." )
@click.option( "--vsav","vsav_fname", help="VASL scenario file (.vsav) to be updated." )
def main( webapp_url, snippet_images, update_vsav, vsav_fname ):
def main( server_url, snippet_images, update_vsav, vsav_fname ):
"""Stress-test the shared WebDriver."""
# initialize
@ -44,8 +44,7 @@ def main( webapp_url, snippet_images, update_vsav, vsav_fname ):
# read the VASL scenario file
vsav_data = None
if update_vsav > 0:
with open( vsav_fname, "rb" ) as fp:
vsav_data = fp.read()
vsav_data = open( vsav_fname, "rb" ).read()
# prepare the test threads
threads = []
@ -53,13 +52,13 @@ def main( webapp_url, snippet_images, update_vsav, vsav_fname ):
threads.append( threading.Thread(
target = snippet_images_thread,
name = "snippet-images/{:02d}".format( 1+i ),
args = ( webapp_url, )
args = ( server_url, )
) )
for i in range(0,update_vsav):
threads.append( threading.Thread(
target = update_vsav_thread,
name = "update-vsav/{:02d}".format( 1+i ),
args = ( webapp_url, vsav_fname, vsav_data )
args = ( server_url, vsav_fname, vsav_data )
) )
# launch the test threads
@ -97,14 +96,14 @@ def main( webapp_url, snippet_images, update_vsav, vsav_fname ):
# ---------------------------------------------------------------------
def snippet_images_thread( webapp_url ):
def snippet_images_thread( server_url ):
"""Test generating snippet images."""
with WebDriver() as webdriver:
# initialize
webdriver = webdriver.driver
init_webapp( webdriver, webapp_url,
init_webapp( webdriver, server_url,
[ "snippet_image_persistence", "scenario_persistence" ]
)
@ -170,7 +169,7 @@ def snippet_images_thread( webapp_url ):
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
def update_vsav_thread( webapp_url, vsav_fname, vsav_data ):
def update_vsav_thread( server_url, vsav_fname, vsav_data ):
"""Test updating VASL scenario files."""
# initialize
@ -180,14 +179,13 @@ def update_vsav_thread( webapp_url, vsav_fname, vsav_data ):
# initialize
webdriver = webdriver.driver
init_webapp( webdriver, webapp_url,
init_webapp( webdriver, server_url,
[ "vsav_persistence", "scenario_persistence" ]
)
# load a test scenario
fname = os.path.join( os.path.split(__file__)[0], "../webapp/tests/fixtures/update-vsav/full.json" )
with open( fname, "r", encoding="utf-8" ) as fp:
saved_scenario = json.load( fp )
saved_scenario = json.load( open( fname, "r" ) )
load_scenario( saved_scenario, webdriver )
while not shutdown_event.is_set():
@ -244,10 +242,10 @@ def log( fmt, *args, **kwargs ):
# ---------------------------------------------------------------------
def init_webapp( webdriver, webapp_url, options ):
def init_webapp( webdriver, server_url, options ):
"""Initialize the webapp."""
log( "Initializing the webapp." )
url = webapp_url + "?" + "&".join( "{}=1".format(opt) for opt in options )
url = server_url + "?" + "&".join( "{}=1".format(opt) for opt in options )
url += "&store_msgs=1" # nb: stop notification balloons from building up
webdriver.get( url )
wait_for( 5, lambda: find_child("#_page-loaded_",webdriver) is not None )

@ -9,8 +9,8 @@
<rect>
<x>0</x>
<y>0</y>
<width>460</width>
<height>215</height>
<width>410</width>
<height>150</height>
</rect>
</property>
<property name="windowTitle">
@ -22,7 +22,7 @@
<widget class="QLabel" name="app_icon">
<property name="geometry">
<rect>
<x>15</x>
<x>10</x>
<y>10</y>
<width>64</width>
<height>64</height>
@ -39,20 +39,14 @@
<property name="geometry">
<rect>
<x>80</x>
<y>19</y>
<width>361</width>
<height>58</height>
<y>20</y>
<width>321</width>
<height>44</height>
</rect>
</property>
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="QLabel" name="app_name">
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="MinimumExpanding">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="font">
<font>
<family>DejaVu Sans</family>
@ -66,15 +60,10 @@
</item>
<item>
<widget class="QLabel" name="build_info">
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="font">
<font>
<family>DejaVu Sans</family>
<italic>true</italic>
</font>
</property>
<property name="text">
@ -87,9 +76,9 @@
<widget class="QWidget" name="horizontalLayoutWidget">
<property name="geometry">
<rect>
<x>19</x>
<y>172</y>
<width>425</width>
<x>10</x>
<y>110</y>
<width>391</width>
<height>31</height>
</rect>
</property>
@ -128,18 +117,12 @@
<widget class="QLabel" name="home_url">
<property name="geometry">
<rect>
<x>80</x>
<y>122</y>
<width>361</width>
<height>21</height>
<x>10</x>
<y>100</y>
<width>391</width>
<height>17</height>
</rect>
</property>
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="text">
<string>*** HOME URL ***</string>
</property>
@ -147,34 +130,12 @@
<bool>true</bool>
</property>
</widget>
<widget class="QLabel" name="issues_url">
<property name="geometry">
<rect>
<x>80</x>
<y>142</y>
<width>361</width>
<height>21</height>
</rect>
</property>
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="text">
<string>*** ISSUES URL ***</string>
</property>
<property name="openExternalLinks">
<bool>true</bool>
</property>
</widget>
<widget class="QLabel" name="license">
<property name="geometry">
<rect>
<x>80</x>
<y>100</y>
<width>361</width>
<x>10</x>
<y>80</y>
<width>388</width>
<height>17</height>
</rect>
</property>

@ -37,7 +37,7 @@
<x>10</x>
<y>30</y>
<width>381</width>
<height>58</height>
<height>51</height>
</rect>
</property>
<layout class="QFormLayout" name="formLayout_3">
@ -152,7 +152,7 @@
<x>10</x>
<y>30</y>
<width>381</width>
<height>116</height>
<height>111</height>
</rect>
</property>
<layout class="QFormLayout" name="formLayout">
@ -357,7 +357,7 @@
<x>10</x>
<y>30</y>
<width>381</width>
<height>86</height>
<height>81</height>
</rect>
</property>
<layout class="QFormLayout" name="formLayout_4">
@ -446,6 +446,18 @@
</property>
<item>
<widget class="QLabel" name="chapter_h_image_scaling_label">
<property name="sizePolicy">
<sizepolicy hsizetype="Fixed" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="font">
<font>
<family>DejaVu Sans</family>
<pointsize>8</pointsize>
</font>
</property>
<property name="text">
<string>Image s&amp;caling:</string>
</property>
@ -474,6 +486,12 @@
<height>16777215</height>
</size>
</property>
<property name="font">
<font>
<family>DejaVu Sans</family>
<pointsize>8</pointsize>
</font>
</property>
<property name="inputMask">
<string/>
</property>
@ -509,7 +527,7 @@
<rect>
<x>650</x>
<y>230</y>
<width>173</width>
<width>164</width>
<height>31</height>
</rect>
</property>

@ -1,49 +1,8 @@
""" Miscellaneous utilities. """
import os
import platform
import subprocess
import functools
import logging
import traceback
import json
from vasl_templates.webapp import app
from vasl_templates.webapp.config.constants import BASE_DIR, IS_FROZEN
# ---------------------------------------------------------------------
def get_build_info():
"""Get the program build info."""
# locate and load the build info file
fname = os.path.join( BASE_DIR, "config", "build-info.json" )
if not os.path.isfile( fname ):
return None
with open( fname, "r", encoding="utf-8" ) as fp:
build_info = json.load( fp )
# get the build timestamp
result = { "timestamp": build_info["timestamp"] }
# get the git info
if "branch_name" in build_info:
git_info = build_info[ "branch_name" ]
if "last_commit_id" in build_info:
git_info += ":{}".format( build_info["last_commit_id"][:8] )
result["git_info"] = git_info
return result
def get_build_git_info():
"""Get the git details for the current build."""
if IS_FROZEN:
build_info = get_build_info()
if build_info:
return build_info[ "git_info" ]
elif app.config.get( "IS_CONTAINER" ):
return os.environ.get( "BUILD_GIT_INFO" )
return None
# ---------------------------------------------------------------------
@ -80,14 +39,3 @@ def show_msg_store( msg_store ):
for msg_type in ("error","warning"):
for msg in msg_store.get_msgs( msg_type ):
MainWindow.showErrorMsg( msg )
# ---------------------------------------------------------------------
def launch_file( fname ):
"""Launch the specified file."""
if platform.system() == "Windows":
os.startfile( fname ) #pylint: disable=no-member
elif platform.system() == "Darwin":
subprocess.call( ("open", fname) )
else:
subprocess.call( ("xdg-open", fname) )

@ -38,14 +38,12 @@ class WebChannelHandler:
def on_new_scenario( self ):
"""Called when the scenario is reset."""
self.scenario_file_dialog.curr_fname = None
self.updated_vsav_file_dialog.curr_fname = None
def load_scenario( self ):
"""Called when the user wants to load a scenario."""
data = self.scenario_file_dialog.load_file( False )
if data is None:
return None, None
self.updated_vsav_file_dialog.curr_fname = None
return self.scenario_file_dialog.curr_fname, data
def save_scenario( self, fname, data ):
@ -70,7 +68,7 @@ class WebChannelHandler:
title += " (*)"
self.parent.setWindowTitle( title )
def on_snippet_image( self, img_data ):
def on_snippet_image( self, img_data ): #pylint: disable=no-self-use
"""Called when a snippet image has been generated."""
# NOTE: We could maybe add an HTML object to the clipboard as well, but having two formats on the clipboard
# simultaneously might confuse some programs, causing problems for no real benefit :shrug:

@ -4,98 +4,51 @@ import sys
import os
import signal
import threading
import time
import configparser
import logging
import logging.config
from flask import Flask, request
import flask.cli
from flask import Flask
import yaml
from vasl_templates.webapp.config.constants import BASE_DIR
shutdown_event = threading.Event()
# ---------------------------------------------------------------------
_init_done = False
_init_lock = threading.Lock()
def _on_request():
"""Called before each request."""
# initialize the webapp on the first request, except for $/control-tests.
# NOTE: The test suite calls $/control-tests to find out which port the gRPC test control service
# is running on, which is nice since we don't need to configure both ends with a predefined port.
# However, we don't want this call to trigger initialization, since the tests will often want to
# configure the remote webapp before loading the main page.
if request.path == "/control-tests":
return
with _init_lock:
global _init_done
if not _init_done or (request.path == "/" and request.args.get("force-reinit")):
try:
_init_webapp()
except Exception as ex: #pylint: disable=broad-except
from vasl_templates.webapp.main import startup_msg_store #pylint: disable=cyclic-import
startup_msg_store.error( str(ex) )
finally:
# NOTE: It's important to set this, even if initialization failed, so we don't
# try to initialize again.
_init_done = True
def _init_webapp():
def _on_startup():
"""Do startup initialization."""
# NOTE: While this is generally called only once (before the first request), the test suite
# can force it be done again, since it wants to reconfigure the server to test different cases.
# initialize
from vasl_templates.webapp.main import startup_msg_store #pylint: disable=cyclic-import
# start downloading files
# NOTE: We used to do this in the mainline code of __init__, so that we didn't have to wait
# for the first request before starting the download (useful if we are running as a standalone server).
# However, this means that the downloads start whenever we import this module e.g. for a stand-alone
# command-line tool :-/ Instead, we send a dummy request in run_server.py to trigger a call
# to this function.
if not _init_done:
from vasl_templates.webapp.downloads import DownloadedFile
threading.Thread( daemon=True,
target = DownloadedFile.download_files
).start()
# command-line tool :-/
from vasl_templates.webapp.downloads import DownloadedFile
threading.Thread( daemon=True,
target = DownloadedFile.download_files
).start()
# load the default template_pack
from vasl_templates.webapp.snippets import load_default_template_pack
load_default_template_pack()
# configure the VASL module
fname = app.config.get( "VASL_MOD" )
from vasl_templates.webapp.vasl_mod import set_vasl_mod #pylint: disable=cyclic-import
set_vasl_mod( fname, startup_msg_store )
# NOTE: The Docker container configures this setting via an environment variable.
fname = app.config.get( "VASL_MOD", os.environ.get("VASL_MOD") )
if fname:
from vasl_templates.webapp.vasl_mod import set_vasl_mod #pylint: disable=cyclic-import
from vasl_templates.webapp.main import startup_msg_store #pylint: disable=cyclic-import
set_vasl_mod( fname, startup_msg_store )
# load the vehicle/ordnance listings
from vasl_templates.webapp.vo import load_vo_listings #pylint: disable=cyclic-import
from vasl_templates.webapp.main import startup_msg_store #pylint: disable=cyclic-import
load_vo_listings( startup_msg_store )
# load the vehicle/ordnance notes
from vasl_templates.webapp.vo_notes import load_vo_notes #pylint: disable=cyclic-import
load_vo_notes( startup_msg_store )
# initialize the vehicle/ordnance notes image cache
from vasl_templates.webapp import vo_notes as webapp_vo_notes #pylint: disable=reimported
dname = app.config.get( "VO_NOTES_IMAGE_CACHE_DIR" )
if dname in ( "disable", "disabled" ):
webapp_vo_notes._vo_notes_image_cache_dname = None #pylint: disable=protected-access
elif dname:
webapp_vo_notes._vo_notes_image_cache_dname = dname #pylint: disable=protected-access
else:
webapp_vo_notes._vo_notes_image_cache_dname = globvars.user_profile.vo_notes_image_cache_dname #pylint: disable=protected-access
# load integration data from asl-rulebook2
from vasl_templates.webapp.vo_notes import load_asl_rulebook2_vo_note_targets #pylint: disable=cyclic-import
load_asl_rulebook2_vo_note_targets( startup_msg_store )
# ---------------------------------------------------------------------
def _load_config( fname, section ):
@ -111,73 +64,14 @@ def load_debug_config( fname ):
"""Configure the application."""
_load_config( fname, "Debug" )
def _set_config_from_env( key ):
"""Set an app config setting from an environment variable."""
val = os.environ.get( key )
if val:
app.config[ key ] = val
def _is_flask_child_process():
"""Check if we are the Flask child process."""
# NOTE: There are actually 3 possible cases:
# (*) Flask reloading is enabled:
# - we are the parent process (returns False)
# - we are the child process (returns True)
# (*) Flask reloading is disabled:
# - returns False
return os.environ.get( "WERKZEUG_RUN_MAIN" ) is not None
# ---------------------------------------------------------------------
def _on_sig( signum, stack ): #pylint: disable=unused-argument
"""Clean up after a SIGINT/SIGTERM."""
# FUDGE! Since we added gRPC test control, we want to shutdown properly and clean things up (e.g. temp files
# created by the gRPC service), but the Flask reloader complicates what we have to do here horribly :-(
# Since automatic reloading is a really nice feature to have, we try to handle things.
# If the Flask app is started with reloading enabled, it launches a child process to actually do the work,
# that is restarted when any of the monitored files change. It's easy for each process to figure out
# if it's the parent or child, but they need to synchronize their shutdown. Both processes get the SIGINT,
# but the parent can't just exit, since that will cause the child process to terminate, even if it hasn't
# finished shutting down i.e. the parent process needs to wait for the child process to finish shutting down
# before it can exit itself.
# Unfortunately, the way the child process is launched (see werkzeug._reloader.restart_with_reloader())
# means that there is no way for us to know what the child process is (and hence be able to wait for it),
# so the way the child process tells its parent that it has finished shutting down is via a lock file.
# NOTE: We always go through the shutdown process, regardless of whether we are the Flask parent or child process,
# because if Flask reloading is disabled, there will be only one process (that will look like it's the parent),
# and there doesn't seem to be any way to check if reloading is enabled or not. Note that if reloading is enabled,
# then doing shutdown in the parent process will be harmless, since it won't have done any real work (it's all done
# by the child process), and so there won't be anything to clean up.
# notify everyone that we're shutting down
shutdown_event.set()
# call any registered cleanup handlers
def _on_sigint( signum, stack ): #pylint: disable=unused-argument
"""Clean up after a SIGINT."""
from vasl_templates.webapp import globvars #pylint: disable=cyclic-import
for handler in globvars.cleanup_handlers:
handler()
lock_fname = globvars.user_profile.flask_lock_fname
if _is_flask_child_process():
# notify the parent process that we're done
os.unlink( lock_fname )
else:
# we are the Flask parent process (so we wait for the child process to finish) or Flask reloading
# is disabled (and the wait below will end immediately, because the lock file was never created).
# NOTE: If, for whatever reason, the lock file doesn't get deleted, we give up waiting and exit anyway.
# This means that the child process might not get to finish cleaning up properly, but if it hasn't
# deleted the lock file, it was probably in trouble anyway.
for _ in range(0, 20):
# NOTE: os.path.isfile() and .exists() both return True even after the log file has gone!?!?
# Is somebody caching something somewhere? :-/
try:
with open( lock_fname, "rb" ):
pass
except FileNotFoundError:
break
time.sleep( 0.1 )
raise SystemExit()
raise SystemExit()
# ---------------------------------------------------------------------
@ -193,57 +87,31 @@ app.config[ "ASA_GET_SCENARIO_URL" ] = "https://aslscenarioarchive.com/rest/scen
app.config[ "ASA_MAX_VASL_SETUP_SIZE" ] = 200 # nb: KB
app.config[ "ASA_MAX_SCREENSHOT_SIZE" ] = 200 # nb: KB
# initialize logging
_config_dir = os.path.join( BASE_DIR, "config" )
_fname = os.path.join( _config_dir, "logging.yaml" )
if os.path.isfile( _fname ):
with open( _fname, "r", encoding="utf-8" ) as fp:
try:
logging.config.dictConfig( yaml.safe_load( fp ) )
except Exception as _ex: #pylint: disable=broad-except
logging.error( "Can't load the logging config: %s", _ex )
else:
# stop Flask from logging every request :-/
logging.getLogger( "werkzeug" ).setLevel( logging.WARNING )
# load the application configuration
_fname = os.path.join( _config_dir, "app.cfg" )
config_dir = os.path.join( BASE_DIR, "config" )
_fname = os.path.join( config_dir, "app.cfg" )
_load_config( _fname, "System" )
# load any site configuration
_fname = os.path.join( _config_dir, "site.cfg" )
_fname = os.path.join( config_dir, "site.cfg" )
_load_config( _fname, "Site Config" )
# load any debug configuration
_fname = os.path.join( _config_dir, "debug.cfg" )
load_debug_config( _fname )
# load any config from environment variables (e.g. set in the Docker container)
# NOTE: We could add these settings to the container's site.cfg, so that they are always defined, and things
# would work (or not) depending on whether anything had been mapped to the endpoints. For example, if nothing
# had been mapped to /data/vassal/, we would not find a Vengine.jar and it would look like no VASSAL engine
# had been configured). However, requiring things to be explicitly turned on via an environment variable
# lets us issue better error message, such as "VASSAL has not been configured".
_set_config_from_env( "VASSAL_DIR" )
_set_config_from_env( "VASL_MOD" )
_set_config_from_env( "VASL_EXTNS_DIR" )
_set_config_from_env( "BOARDS_DIR" )
_set_config_from_env( "CHAPTER_H_NOTES_DIR" )
_set_config_from_env( "USER_FILES_DIR" )
_set_config_from_env( "ASL_RULEBOOK2_BASE_URL" )
# NOTE: The Docker container also sets DEFAULT_TEMPLATE_PACK, but we read it directly from
# the environment variable, since it is not something that is stored in app.config.
_fname = os.path.join( config_dir, "debug.cfg" )
if os.path.isfile( _fname ) :
load_debug_config( _fname )
# initialize the user profile
from vasl_templates.webapp.user_profile import UserProfile #pylint: disable=cyclic-import
from vasl_templates.webapp import globvars #pylint: disable=cyclic-import
globvars.user_profile = UserProfile( app.config )
# check if we are the Flask child process
if _is_flask_child_process():
# yup - create a lock file
with open( globvars.user_profile.flask_lock_fname, "wb" ):
pass
# initialize logging
_fname = os.path.join( config_dir, "logging.yaml" )
if os.path.isfile( _fname ):
with open( _fname, "r" ) as fp:
try:
logging.config.dictConfig( yaml.safe_load( fp ) )
except Exception as ex: #pylint: disable=broad-except
logging.error( "Can't load the logging config: %s", ex )
else:
# stop Flask from logging every request :-/
logging.getLogger( "werkzeug" ).setLevel( logging.WARNING )
# load the application
import vasl_templates.webapp.main #pylint: disable=cyclic-import
@ -256,12 +124,12 @@ import vasl_templates.webapp.nat_caps #pylint: disable=cyclic-import
import vasl_templates.webapp.scenarios #pylint: disable=cyclic-import
import vasl_templates.webapp.downloads #pylint: disable=cyclic-import
import vasl_templates.webapp.lfa #pylint: disable=cyclic-import
if app.config.get( "ENABLE_REMOTE_TEST_CONTROL" ):
print( "*** WARNING: Remote test control enabled! ***" )
import vasl_templates.webapp.testing #pylint: disable=cyclic-import
# install our signal handler (must be done in the main thread)
signal.signal( signal.SIGINT, _on_sig )
# NOTE: We must handle SIGTERM, so that "docker stop" and scaling down in Kubernetes
# work properly (otherwise it times out and we get SIGKILL'ed :-/).
signal.signal( signal.SIGTERM, _on_sig )
signal.signal( signal.SIGINT, _on_sigint )
# register startup initialization
app.before_request( _on_request )
app.before_first_request( _on_startup )

@ -4,10 +4,9 @@ import sys
import os
APP_NAME = "VASL Templates"
APP_VERSION = "v1.13" # nb: also update setup.py
APP_VERSION = "v1.3" # nb: also update setup.py
APP_DESCRIPTION = "Generate HTML for use in VASL scenarios."
APP_HOME_URL = "https://vasl-templates.org"
APP_ISSUES_URL = "https://code.pacman-ghost.com/public/vasl-templates/issues"
if getattr( sys, "frozen", False ):
IS_FROZEN = True

@ -1,10 +0,0 @@
[Debug]
; Set this if you want to run the test suite (allows the webapp server to be controlled using gRPC).
; CONTROL_TESTS_PORT = -1
; Set this to a directory containing the VASSAL releases to run the test suite with.
; TEST_VASSAL_ENGINES = ...
; Set this to a directory containing the VASL modules (.vmod files) to run the test suite with.
; TEST_VASL_MODS = ...

@ -1,5 +1,4 @@
# This is a sample config file for Python logging - rename it as logging.yaml.
# It also gets deployed into the Docker container, unless you create $/docker/config/logging.yaml.
version: 1
@ -9,10 +8,6 @@ formatters:
datefmt: "%H:%M:%S"
handlers:
console:
class: "logging.StreamHandler"
formatter: "standard"
stream: "ext://sys.stdout"
file:
class: "logging.FileHandler"
formatter: "standard"
@ -21,49 +16,32 @@ handlers:
root:
level: "WARNING"
handlers: [ "console", "file" ]
handlers: [ "file" ]
loggers:
werkzeug:
level: "ERROR"
handlers: [ "console", "file" ]
propagate: 0
level: "WARNING"
handlers: [ "file" ]
javascript:
level: "INFO"
handlers: [ "console", "file" ]
propagate: 0
handlers: [ "file" ]
qt:
level: "INFO"
handlers: [ "console", "file" ]
propagate: 0
handlers: [ "file" ]
vasl_mod:
level: "WARNING"
handlers: [ "console", "file" ]
propagate: 0
vassal_shim:
level: "WARNING"
handlers: [ "console", "file" ]
propagate: 0
handlers: [ "file" ]
update_vsav:
level: "WARNING"
handlers: [ "console", "file" ]
propagate: 0
handlers: [ "file" ]
analyze_vsav:
level: "WARNING"
handlers: [ "console", "file" ]
propagate: 0
handlers: [ "file" ]
analyze_vlog:
level: "WARNING"
handlers: [ "console", "file" ]
propagate: 0
handlers: [ "file" ]
webdriver:
level: "WARNING"
handlers: [ "console", "file" ]
propagate: 0
handlers: [ "file" ]
downloads:
level: "WARNING"
handlers: [ "console", "file" ]
propagate: 0
user_profile:
level: "WARNING"
handlers: [ "console", "file" ]
propagate: 0
handlers: [ "file" ]

@ -2,11 +2,11 @@
; configure VASSAL and VASL
VASSAL_DIR = ...configure the VASSAL installation directory...
VASL_MOD = ...configure the VASL module (e.g. vasl-6.6.7.vmod)...
VASL_EXTNS_DIR = ...configure the VASL extensions directory...
VASL_MOD = ...configure the VASL module (e.g. vasl-6.5.0.vmod)...
VASL_EXTNS_DIR = ...configured the VASL extensions directory...
BOARDS_DIR = ...configure the VASL boards directory...
; configure support programs
; configure support prorams
; JAVA_PATH = ...configure the Java executable here (optional, must be in the PATH otherwise)...
WEBDRIVER_PATH = ...configure either geckodriver or chromedriver here...

@ -1,9 +1,7 @@
{
"_comment_": "This section maps theaters from those at the ASL Scenario Archive to ours.",
"_comment2_": "CBI is handled in getEffectiveTheater().",
"theater-mappings": {
"WTO": "ETO",
"MTO": "ETO",
"Normandy": "ETO",
"KW": "Korea"

@ -14,15 +14,11 @@
padding: 2px 5px ;
font-size: 105% ; font-weight: bold ;
">
{{INCLUDE:player_flag_large}}Anti-Tank Magnetic Mines
{{INCLUDE:player_flag}}Anti-Tank Magnetic Mines
<tr>
<td style="padding:2px 5px;">
CC Attack -2 DRM
<tr>
<td style="padding:2px 5px;">
<b>ATMM check</b>: dr &le; {%if SCENARIO_YEAR < 1944%} 2 {%else%} 3 {%endif%} (&#9651;) <br>
ATMM check: dr &le; {%if SCENARIO_YEAR < 1944%} 2 {%else%} 3 {%endif%} (&#9651;) <br>
<table style="margin-left:10px;">
<tr>
<td style="width:25px;"> +1 <td> HS
@ -34,6 +30,7 @@
<td> +1 <td> vs. non-armored vehicle
</table>
original 6 = pinned (CCV reduced by 1) <br>
<span class="exc"> EXC: CC DRM is -2 </span>
</table>

@ -14,11 +14,11 @@
padding: 2px 5px ;
font-size: 105% ; font-weight: bold ;
">
{{INCLUDE:player_flag_large}}Anti-Tank Magnetic Mines
{{INCLUDE:player_flag}}Anti-Tank Magnetic Mines
<tr>
<td style="padding:2px 5px;">
<b>ATMM check</b>: dr &le; 3 (&#9651;) <br>
ATMM check: dr &le; 3 (&#9651;) <br>
<table style="margin-left:10px;">
<tr>
<td style="width:25px;"> +1 <td> HS/crew

@ -16,14 +16,14 @@
padding: 2px 5px ;
font-size: 105% ; font-weight: bold ;
">
{{INCLUDE:player_flag_large}}Bazooka '44
{{INCLUDE:player_flag}}Bazooka '44
<tr>
<td style="padding:0 5px;">
<table>
<tr>
<td class="c"> Range <td class="c" width="35"> <b>TH#</b>
<td> <b>Range</b> <td> <b>TH#</b>
<tr>
<td class="c"> 0 <td class="c"> 10
<tr>
@ -38,16 +38,14 @@
<td valign="top" style="padding:0 5px;">
<table>
<tr>
<td> <b>X#:</b>
<td class="r"> 11
<tr>
<td> <b>TK#:</b>
<td class="r"> 16
<tr>
<td colspan="2" class="r"> 8-4
<tr>
<td> &nbsp;
<tr>
<td> <b>X#:</b>
<td class="r"> 11
</table>
</table>

@ -16,14 +16,14 @@
padding: 2px 5px ;
font-size: 105% ; font-weight: bold ;
">
{{INCLUDE:player_flag_large}}Bazooka Type 51
{{INCLUDE:player_flag}}Bazooka Type 51
<tr>
<td style="padding:0 5px;">
<table>
<tr>
<td class="c"> Range <td class="c" width="35"> <b>TH#</b>
<td> <b>Range</b> <td> <b>TH#</b>
<tr>
<td class="c"> 0 <td class="c"> 10
<tr>
@ -40,16 +40,14 @@
<td valign="top" style="padding:0 5px;">
<table>
<tr>
<td> <b>X#:</b>
<td class="r"> 10
<tr>
<td> <b>TK#:</b>
<td class="r"> 22
<tr>
<td colspan="2" class="r"> 12-5
<tr>
<td> &nbsp;
<tr>
<td> <b>X#:</b>
<td class="r"> 10
</table>
</table>

@ -16,14 +16,14 @@
padding: 2px 5px ;
font-size: 105% ; font-weight: bold ;
">
{{INCLUDE:player_flag_large}}Bazooka {%if BAZ_TYPE%} ('{{BAZ_TYPE}}) {%endif%}
{{INCLUDE:player_flag}}Bazooka {%if BAZ_TYPE%} ('{{BAZ_TYPE}}) {%endif%}
<tr>
<td style="padding:0 5px;">
<table>
<tr>
<td class="c"> Range <td class="c" width="35"> <b>TH#</b>
<td> <b>Range</b> <td> <b>TH#</b>
{%if BAZ_TYPE == 45%}
<tr>
<td class="c"> 0 <td class="c"> 11
@ -53,13 +53,6 @@
<td valign="top" style="padding:0 5px;">
<table>
<tr>
<td> <b>TK#:</b>
<td class="r"> {{BAZ_TK}}
<tr>
<td colspan="2" class="r"> 8-{{BAZ_RANGE}}
<tr>
<td> &nbsp;
<tr>
<td> <b>X#:</b>
<td class="r"> {{BAZ_BREAKDOWN}}
@ -68,6 +61,11 @@
<td> <b>WP#:</b>
<td class="r"> {{BAZ_WP}}
{%endif%}
<tr>
<td> <b>TK#:</b>
<td class="r"> {{BAZ_TK}}
<tr>
<td colspan="2" class="r"> 8-{{BAZ_RANGE}}
</table>
</table>

@ -16,14 +16,14 @@
padding: 2px 5px ;
font-size: 105% ; font-weight: bold ;
">
{{INCLUDE:player_flag_large}}Bazooka '45
{{INCLUDE:player_flag}}Bazooka '45
<tr>
<td style="padding:0 5px;">
<table>
<tr>
<td class="c"> Range <td class="c" width="35"> <b>TH#</b>
<td> <b>Range</b> <td> <b>TH#</b>
<tr>
<td class="c"> 0 <td class="c"> 11
<tr>
@ -40,19 +40,17 @@
<td valign="top" style="padding:0 5px;">
<table>
<tr>
<td> <b>TK#:</b>
<td class="r"> 16
<tr>
<td colspan="2" class="r"> 8-5
<tr>
<td> &nbsp;
<tr>
<td> <b>X#:</b>
<td class="r"> 11
<tr>
<td> <b>WP#:</b>
<td class="r"> 6
<tr>
<td> <b>TK#:</b>
<td class="r"> 16
<tr>
<td colspan="2" class="r"> 8-5
</table>
</table>

@ -16,14 +16,14 @@
padding: 2px 5px ;
font-size: 105% ; font-weight: bold ;
">
{{INCLUDE:player_flag_large}}Bazooka '50
{{INCLUDE:player_flag}}Bazooka '50
<tr>
<td style="padding:0 5px;">
<table>
<tr>
<td class="c"> Range <td class="c" width="35"> <b>TH#</b>
<td> <b>Range</b> <td> <b>TH#</b>
<tr>
<td class="c"> 0 <td class="c"> 11
<tr>
@ -40,13 +40,6 @@
<td valign="top" style="padding:0 5px;">
<table>
<tr>
<td> <b>TK#:</b>
<td class="r"> 32
<tr>
<td colspan="2" class="r"> 12-5
<tr>
<td> &nbsp;
<tr>
<td> <b>X#:</b>
<td class="r"> 11
@ -55,6 +48,11 @@
<td> <b>WP#:</b>
<td class="r"> 6
{%endif%}
<tr>
<td> <b>TK#:</b>
<td class="r"> 32
<tr>
<td colspan="2" class="r"> 12-5
</table>
</table>

@ -6,12 +6,10 @@ body {
p { margin-top: 5px ; margin-bottom: 0 ; }
ul { margin: 0 ; padding: 0 0 0 10px ; }
ol { margin: 0 ; padding: 0 0 0 21px ; }
{%if CUSTOM_LIST_BULLETS%}
ul { list-style-image: url("{{IMAGES_BASE_URL}}/bullet.png") ; }
ul ul { list-style-image: url("{{IMAGES_BASE_URL}}/bullet2.png") ; }
ul ul ul { list-style-image: url("{{IMAGES_BASE_URL}}/bullet3.png") ; }
ol { list-style-image: none ; }
{%endif%}
td { margin: 0 ; padding: 0 ; }
@ -20,6 +18,5 @@ td.l { text-align: left ; }
td.r { text-align: right ; }
sup { font-size: 75% ; }
sub { vertical-align: sub ; font-size: 80% ; line-height: 0.5em ; }
.exc { font-style: italic ; color: #404040 ; }

@ -1,5 +0,0 @@
<html> <!-- vasl-templates:id {{SNIPPET_ID}} -->
<img src="{{IMAGES_BASE_URL}}/compass/{{COMPASS}}.png">
</html>

@ -1,41 +0,0 @@
<html> <!-- vasl-templates:id {{SNIPPET_ID}} -->
<!-- vasl-templates:name Booby Traps -->
<!-- vasl-templates:description Data chart for Booby Traps. -->
<!-- player = {{PLAYER_DROPLIST:|Player}}
<!-- boards = {{BOARDS*:/8|Board(s)}} -->
<head>
<meta charset="utf-8">
<style>
.header {
background: {{PLAYER_COLORS[PLAYER_DROPLIST][0]}} ;
border-bottom: 1px solid {{PLAYER_COLORS[PLAYER_DROPLIST][2]}} ;
padding: 2px 5px ;
font-size: 105% ; font-weight: bold ;
}
.header .level { font-size: 90% ; font-style: italic ; }
{{CSS:common}}
</style>
</head>
<table>
<tr>
<td class="header">
<img src="{{PLAYER_FLAGS[PLAYER_DROPLIST]}}?prefh={{PLAYER_FLAG_SIZE_LARGE}}" width="{{PLAYER_FLAG_SIZE_LARGE}}" height="{{PLAYER_FLAG_SIZE_LARGE}}">&nbsp;Booby Traps <span class="level">(Level {{LEVEL:A::B::C/3|Level}})</span>
<tr>
<td style="padding:2px 5px;">
<b> {%if BOARDS%} Boards: {{BOARDS}} {%else%} Entire map {%endif%} </b>
<ul>
<li> Original TC
{% if LEVEL == "A" %} &ge; 11
{% elif LEVEL == "B" %} 11
{% elif LEVEL == "C" %} 12
{%else%} ??? {%endif%}
<li> Search Casualties
</ul>
</table>

@ -1,7 +1,7 @@
<html> <!-- vasl-templates:id {{SNIPPET_ID}} -->
<!-- vasl-templates:name Count remaining -->
<!-- vasl-templates:description Add the snippet as the label of a counter (e.g. Panzerfaust or Tank-Hunter Hero), then press <i>Ctrl-L</i> when you need to update how many are left. Or add a caption, and use it as a stand-alone label. -->
<!-- vasl-templates:description Add the snippet as the label of a counter (e.g. Panzerfaust or Tank-Hunter Hero), then press <i>Ctrl-L</i> when you need to update how many are left. -->
<!-- vasl-templates:comment The HTML is deliberately malformed, so that the number remaining is the last thing in snippet, which makes it easier to change during the course of a game. -->
<!-- vasl-templates:footer <table> <tr>
{% set HILITE_STYLE = 'style="background:#ffffe0;border-color:#888;"' %}
@ -41,7 +41,5 @@
</table> -->
<!-- vasl-templates:comment We don't include common.css because it's not necessary, and we want to keep the generated snippet short. -->
<!-- vasl-templates:comment {{CAPTION:(none)::Panzerfaust::Tank-Hunter Heroes/11|Caption}} -->
{# NOTE: We specify the font size in pixels, rather than as a percentage, since this label should be added to a counter. #}
<div style="font-size:12px;font-weight:bold;">
{%if CAPTION != "(none)"%}{{CAPTION}}:{%endif%} {{COUNT:/2|Number}}
<div style="font-size:12px;font-weight:bold;"> {{COUNT:/2|Number}}

@ -3,10 +3,9 @@
<!-- vasl-templates:name Grid -->
<!-- vasl-templates:description Generates a grid. -->
<!-- caption = {{CAPTION*:/25|Caption}} -->
<!-- caption = {{CAPTION:/20|Grid caption}} -->
<!-- #cols = {{COLS:3/1|# columns}} ; #rows = {{ROWS:2/1|# rows}} -->
<!-- cell size = {{CELL_WIDTH:180px/5|Cell width}} x {{CELL_HEIGHT:70px/5|Cell height}} -->
<!-- cell labels = {{CELL_LABELS:none::letters::numbers/6|Cell labels}} -->
<!-- cell size = {{CELL_WIDTH:160px/5|Cell width}} x {{CELL_HEIGHT:60px/5|Cell height}} -->
<!-- color = {{PLAYER_COLOR_DROPLIST:|Border color}} ; border = {{BORDER_STYLE:solid::dotted::dashed::double::groove::ridge::inset::outset|Border style}} -->
<head>
@ -16,33 +15,20 @@ td {
width: {{CELL_WIDTH}} ;
height: {{CELL_HEIGHT}} ;
border: 1px {{BORDER_STYLE}} {{PLAYER_COLOR2}} ;
padding: 2px 5px ;
}
td.caption {
height: 1px ; padding: 2px 5px ;
font-size: 105% ; font-weight: bold ; text-align: center ;
background: {{PLAYER_COLOR0}} ; border: none ;
}
.cell-label { font-size: 120% ; }
</style>
</head>
{% set RANGE = [1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50] %}
{% set LETTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" %}
<table>
{%if CAPTION%} <tr> <td class="caption" colspan="{{COLS}}"> {{CAPTION}} {%endif%}
{% for row in RANGE %} {% if row <= ROWS %}
<tr> {% for col in RANGE %}
{% if col <= COLS %}
{% set CELL_NO = (row - 1) * COLS + (col - 1) %}
<td valign="top">
{% if CELL_LABELS == "letters" %}
<span class="cell-label"> {{LETTERS[CELL_NO]}} </span>
{% elif CELL_LABELS == "numbers" %}
<span class="cell-label"> {{CELL_NO + 1}} </span>
{%endif%}
{%endif%}
{%endfor%}
<tr> {% for col in RANGE %} {% if col <= COLS %} <td> {%endif%} {%endfor%}
{%endif%} {%endfor%}
</table>

@ -6,7 +6,7 @@
<head>
<style>
{{CSS:common}}
/* {{CAVE_COMPLEX:Kakazu West::Kakazu Saddle::Kakazu Center::Kakazu Front::Kakazu Reverse::Kakazu East::Kakazu Village::(other)/10|Cave Complex}} */
/* {{CAVE_COMPLEX:Kakazu West::Kakazu Saddle::Kakazu Center::Kakazu Front::Kakazu Reverse::Kakazu East::Kakazu Village::(other)|Cave Complex}} */
.box {
width: {{WIDTH:270px/5|Width}} ;
height: {{HEIGHT:150px/5|Height}} ;

@ -1,87 +0,0 @@
<html> <!-- vasl-templates:id {{SNIPPET_ID}} -->
<!-- vasl-templates:name Kampfgruppe Scherer -->
<!-- vasl-templates:description Data charts for Grenade Bundles and Molotov Cocktails. -->
<!-- {{TYPE:Grenade Bundles::Molotov Cocktails/10|Data chart}} -->
<head>
<meta charset="utf-8">
<style> {{CSS:common}} </style>
</head>
<table>
<tr>
<td colspan="2" style="
background: {{PLAYER_COLORS["german"][0]}} ;
border-bottom: 1px solid {{PLAYER_COLORS["german"][2]}} ;
padding: 2px 5px ;
font-size: 105% ; font-weight: bold ;
">
{# Some versions of Java require <img> tags to have the width and height specified!?! #}
{%if PLAYER_FLAGS["german"]%}<img src="{{PLAYER_FLAGS["german"]}}?prefh={{PLAYER_FLAG_SIZE_LARGE}}" width="{{PLAYER_FLAG_SIZE_LARGE}}" height="{{PLAYER_FLAG_SIZE_LARGE}}">&nbsp;{%endif%}{{TYPE}}
{% if TYPE == "Grenade Bundles" %}
<tr>
<td style="padding:3px 5px 0 5px;">
CC Attack -2 DRM
<tr>
<td style="padding:3px 5px 0 5px;">
<b>ATMM check</b>: dr &le; 3 (&#9651;)
<table style="margin-left:10px;">
<tr>
<td style="width:20px;"> +1
<td> HS/crew
<tr>
<td> +2
<td> SMC
<tr>
<td> +1
<td> CX
<tr>
<td> +1
<td> vs. non-armored vehicle
</table>
original 6 = pinned (CCV reduced by 1)
{% elif TYPE == "Molotov Cocktails" %}
<tr>
<td style="padding:3px 5px 0 5px;">
Against AFV only.
<tr>
<td style="padding:3px 5px 0 5px;">
<b>MOL check</b>: dr &le; 3 (&#9651;)
<table style="margin-left:10px;">
<tr>
<td style="width:20px;"> +1
<td> HS/crew
<tr>
<td> +2
<td> SMC
<tr>
<td> +1
<td> CX
</table>
<tr>
<td style="padding:3px 5px 0 5px;">
<b>IFT DR original colored dr</b>:
<ul>
<li> 1 = Flame in target Location
<li> 6 = thrower breaks, Flame in their Location
</ul>
<tr>
<td style="padding:3px 5px 0 5px;">
<b>Kindling Attempt</b>: +2 DRM
{%endif%}
</table>
</html>

@ -0,0 +1,49 @@
<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>. -->
<head>
<meta charset="utf-8">
<style> {{CSS:common}} </style>
</head>
<table>
<tr>
<td colspan="2" style="
background: {{PLAYER_COLORS["german"][0]}} ;
border-bottom: 1px solid {{PLAYER_COLORS["german"][2]}} ;
padding: 2px 5px ;
font-size: 105% ; font-weight: bold ;
">
{# Some versions of Java require <img> tags to have the width and height specified!?! #}
{%if PLAYER_FLAGS["german"]%}<img src="{{PLAYER_FLAGS["german"]}}" {{PLAYER_FLAG_SIZE}}>&nbsp;{%endif%}Grenade Bundles
<tr>
<td style="padding:3px 5px 0 5px;">
-2 CC Attack DRM
<tr>
<td style="padding:3px 5px 0 5px;">
ATMM check: dr &le; 3 (&#9651;)
<table style="margin-left:10px;">
<tr>
<td style="width:20px;"> +1
<td> HS/crew
<tr>
<td> +2
<td> SMC
<tr>
<td> +1
<td> CX
<tr>
<td> +1
<td> vs. non-armored vehicle
</table>
original 6 = pinned (CCV reduced by 1)
</table>
</html>

@ -0,0 +1,57 @@
<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>. -->
<head>
<meta charset="utf-8">
<style> {{CSS:common}} </style>
</head>
<table>
<tr>
<td colspan="2" style="
background: {{PLAYER_COLORS["german"][0]}} ;
border-bottom: 1px solid {{PLAYER_COLORS["german"][2]}} ;
padding: 2px 5px ;
font-size: 105% ; font-weight: bold ;
">
{# Some versions of Java require <img> tags to have the width and height specified!?! #}
{%if PLAYER_FLAGS["german"]%}<img src="{{PLAYER_FLAGS["german"]}}" {{PLAYER_FLAG_SIZE}}>&nbsp;{%endif%}Molotov Cocktails
<tr>
<td style="padding:3px 5px 0 5px;">
vs. AFV only
<tr>
<td style="padding:3px 5px 0 5px;">
MOL check: dr &le; 3 (&#9651;)
<table style="margin-left:10px;">
<tr>
<td style="width:20px;"> +1
<td> HS/crew
<tr>
<td> +2
<td> SMC
<tr>
<td> +1
<td> CX
</table>
<tr>
<td style="padding:3px 5px 0 5px;">
IFT DR original colored dr:
<ul>
<li> 1 = Flame in target Location
<li> 6 = thrower breaks, Flame in their Location
</ul>
<tr>
<td style="padding:3px 5px 0 5px;">
Kindling Attempt: +2 DRM
</table>
</html>

@ -6,4 +6,4 @@
<!-- vasl-templates:comment We don't include common.css because it's not necessary, and we want to keep the generated snippet short. -->
<div style="font-size:110%">
<b>{{TYPE:Victory Points::Casualty VP::Exit VP/9|Type}}:</b> 0
<b>{{TYPE:Victory Points::Casualty VP::Exit VP|Type}}:</b> 0

@ -16,14 +16,14 @@
padding: 2px 5px ;
font-size: 105% ; font-weight: bold ;
">
{{INCLUDE:player_flag_large}}MOL Projector
{{INCLUDE:player_flag}}MOL Projector
<tr>
<td style="padding:0 5px;">
<table>
<tr>
<td class="c"> Range <td class="c" width="35"> <b>TH#</b>
<td> <b>Range</b> <td> <b>TH#</b>
<tr>
<td class="c"> 0 <td class="c"> 10
<tr>
@ -36,23 +36,21 @@
<td class="c"> 4 <td class="c"> 4
</table>
<td valign="top" style="padding:0 5px;" width="170">
<td valign="top" style="padding:0 5px;">
<table>
<tr>
<td colspan="2" class="r"> 4-4
<tr>
<td> &nbsp;
<tr>
<td> <b>X#:</b>
<td class="r"> 12
<tr>
<td> <b>B#:</b>
<td class="r"> 11
<tr>
<td colspan="2" class="r"> 4-4
</table>
<tr>
<td colspan="2">
<b>IFT DR original colored dr</b>:
IFT DR original colored dr:
<ul>
<li> 1 = Flame in target Location
<li> 6 = thrower breaks, Flame in their Location

@ -16,11 +16,11 @@
padding: 2px 5px ;
font-size: 105% ; font-weight: bold ;
">
{{INCLUDE:player_flag_large}}Molotov Cocktail
{{INCLUDE:player_flag}}Molotov Cocktail
<tr>
<td style="padding:0 5px;">
<b>MOL check</b>: dr &le; 3 (&#9651;) <br>
MOL check: dr &le; 3 (&#9651;) <br>
<table style="margin:0 0 5px 10px;">
<tr>
<td style="width:25px;"> +1 <td> HS/crew
@ -31,12 +31,12 @@
<tr>
<td> +1 <td> non-AFV target
</table>
<b>IFT DR original colored dr</b>:
IFT DR original colored dr:
<ul style="margin-bottom:5px;">
<li> 1 = Flame in target Location
<li> 6 = thrower breaks, Flame in their Location
</ul>
<b>Kindling Attempt</b>: +2 DRM
Kindling Attempt: +2 DRM
</table>

@ -7,7 +7,6 @@
td { padding: 2px 5px ; }
li.comment { font-size: 96% ; font-style: italic ; color: #404040 ; }
span.comment { font-size: 85% ; font-style: italic ; color: #404040 ; }
.note-group { margin-top: 5px ; padding-top: 3px ; border-top: 1px solid #ccc ; }
</style>
</head>
@ -18,7 +17,7 @@ span.comment { font-size: 85% ; font-style: italic ; color: #404040 ; }
border-bottom: 1px solid {{OB_COLOR_2}} ;
font-size: 105% ; font-weight: bold ;
">
<nobr>{{INCLUDE:player_flag_large}}{{PLAYER_NAME|nbsp}}&nbsp;Capabilities</nobr>
<nobr>{{INCLUDE:player_flag}}{{PLAYER_NAME|nbsp}}&nbsp;Capabilities</nobr>
<tr> <td>
@ -31,20 +30,18 @@ span.comment { font-size: 85% ; font-style: italic ; color: #404040 ; }
{%if NAT_CAPS.OBA_BLACK%}
<li> OBA: <span class="oba-black">{{NAT_CAPS.OBA_BLACK}}</span> <span class="oba-red">{{NAT_CAPS.OBA_RED}}</span>
{%if NAT_CAPS.OBA_ACCESS%} <span class="oba-access">(access: {{NAT_CAPS.OBA_ACCESS}})</span> {%endif%}
</span>
{%if NAT_CAPS.OBA_COMMENTS%}
<ul class="oba-comments"> {%for cmt in NAT_CAPS.OBA_COMMENTS%} <li class="comment"> {{cmt}} {%endfor%} </ul>
{%endif%}
{%endif%}
</ul>
{% for group in NAT_CAPS.NOTE_GROUPS %}
<div class="note-group" {%if not group.CAPTION%}style="border-top:none;"{%endif%} >
{%if group.CAPTION %}<div class="caption"> {{group.CAPTION}} </div>{%endif%}
{%if group.NOTES %}<ul> {%for note in group.NOTES%}
<li> {{note}} {%endfor%}
</ul> {%endif%}
</div>
{%endfor%}
{%if NAT_CAPS.NOTES%}
<ul class="notes" style="margin-top:5px;"> {%for note in NAT_CAPS.NOTES%}
<li> {{note}}
{%endfor%} </ul>
{%endif%}
{%else%}

@ -8,11 +8,9 @@
"notes": [
"{? 10/1943- | Inherent PF | No Inherent PF | Inherent PF<sup>10/43+</sup> ?}",
"{? 01/1944- | Inherent ATMM | No Inherent ATMM | Inherent ATMM<sup>44+</sup> ?}",
{ "caption": "SS", "notes": [
"Disrupt &amp; RtPh Surrender NA <br> vs Russians",
"Massacre OK",
"{? 01/1944- | Squad Assault Fire | No Squad Assault Fire | Squad Assault Fire<sup>44+</sup> ?}"
] }
"SS: Disrupt &amp; RtPh Surrender NA vs Russians",
"Massacre OK",
"{? 01/1944- | Squad Assault Fire | No Squad Assault Fire | Squad Assault Fire<sup>44+</sup> ?}"
]
},
@ -38,11 +36,7 @@
"hob_drm": "0 DRM",
"grenades": "SMOKE",
"notes": [
{ "caption": "U.S.M.C.", "notes": [
"Disruption NA",
"7-6-8 can Self-Deploy",
"Vehicle [EXC: LC] Crew: Army 1-2-6"
] }
"U.S.M.C.: <ul> <li> Disruption NA <li> 7-6-8 can Self-Deploy <li> Vehicle [EXC: LC] Crew: Army 1-2-6 </ul>"
]
},
"kfw-american": {
@ -54,30 +48,14 @@
"hob_drm": [ "0 DRM", "+3 for Katusa; NA for TACP" ],
"grenades": "SMOKE",
"notes": [
"Rangers: 6-6-8 <ul> <li> Self-Rally OK <li> Self-Deploy (1TC) &amp; Self-Recombine OK <li> Cowering NA <li> Commandos <li> No Non-Qualified Use penalty for RCL <li> No Captured Use penalty for Communist SW </ul>",
"Airborne: 6-6-7",
"{! 06/1950-08/1950 = Early KW U.S. Army rules: <ul> <li> Always Lax <li> Ammo Shortage <li> SW repair only on \"1\" <li> Radio/Phone Contact reduced by 1 <li> AFV Inherent Crews have Morale 7 <li> All motorized vehicles have Red MP </ul> !}",
"Katusa: As U.S. Army MMC <ul> <li> HoB +3 DRM <li> Leader Creation +1 drm {! 09/1950-10/1951 = <li> ELR 2 <li> Allied Troop penalties with U.S. leaders !} </ul>",
"Disruption NA",
"7-6-8 can Self-Deploy",
"Use 5-5-8 when: <ul> <li> U.S.M.C. ELR Replacement is in effect <li> U.S.M.C. MMC re-arms </ul>",
{ "caption": "Rangers (6-6-8)", "notes": [
"Self-Rally OK",
"Self-Deploy (1TC) &amp; Self-Recombine OK",
"Cowering NA",
"Commandos",
"No Non-Qualified Use penalty for RCL",
"No Captured Use penalty for Communist SW"
] },
{ "caption": "Airborne (6-6-7)", "allow_empty": true },
{ "caption": "Katusa", "notes": [
"As U.S. Army MMC",
"HoB +3 DRM",
"Leader Creation +1 drm",
"{! 09/1950-10/1951 = ELR 2 !}",
"{! 09/1950-10/1951 = Allied Troop penalties with U.S. leaders !}"
] },
{ "caption": "Tactical Air Control Party", "notes": [
"Inherent Radio (Contact = 9)",
"May set up HIP"
] }
"Tactical Air Control Party: <ul> <li> Inherent Radio (Contact = 9) <li> May set up HIP </ul>"
]
},
@ -87,18 +65,9 @@
"hob_drm": "-1 DRM",
"grenades": "{? 01/1944- | SMOKE | Smoke | SMOKE<sup>44+</sup> ?}",
"notes": [
{ "caption": "Elite &amp; 1st Line", "notes": [
"Cowering NA"
] },
{ "caption": "ANZAC", "notes": [
"Stealthy (unless Green)"
] },
{ "caption": "Gurkha", "notes": [
"-1 CC DRM",
"Disrupt &amp; RtPh Surrender NA",
"Commando (unless Green)",
"Stealthy"
] }
"Elite &amp; 1st Line: Cowering NA",
"ANZAC: Stealthy (unless Green)",
"Gurkha: <ul> <li> -1 CC DRM <li> Disrupt &amp; RtPh Surrender NA <li> Commando (unless Green) <li> Stealthy </ul>"
]
},
@ -106,29 +75,10 @@
"th_color": [ "Black", "AFV use Red TH#" ],
"oba": [ "6B", "2R" ], "oba_access": "&le; 1",
"hob_drm": "+1 DRM",
"grenades": "Smoke"
},
"free-french": {
"th_color": "Black",
"oba": [ "8B", "2R" ], "oba_access": "&le; 2",
"hob_drm": "-1 DRM",
"grenades": "{? 01/1944- | SMOKE | Smoke | SMOKE<sup>44+</sup> ?}",
"notes": [
"{? 12/1943- | Assault Fire | No Assault Fire | Assault Fire<sup>12/43+</sup> ?}",
"{? 12/1943-05/1945 | Inherent Crews as British for Morale | | Inherent Crews as British for Morale<sup>12/43-5/45</sup> ?}",
{ "caption": "Elite &amp; 1st Line", "notes": [
"Cowering NA"
] },
{ "caption": "No Captured Use penalty", "notes": [
"U.S. MTR/BAZ",
"Vichy French SW",
"{? -11/1943 | British (f) vehicles/Guns/SW | | British (f) vehicles/Guns/SW <sup>-11/43</sup> ?}",
"{? 12/1943-05/1945 | British/French (a)/(f) SW | | British/French (a)/(f) SW<sup>12/43-5/45</sup> ?}"
] }
]
"grenades": "Smoke",
"notes": []
},
"italian": {
"th_color": "Red",
"oba": [ "7B", "3R" ], "oba_access": "&le; 1",
@ -136,13 +86,7 @@
"grenades": "Smoke",
"notes": [
"Escape NA",
{ "caption": "1st Line &amp; Conscript", "notes": [
"Surrender on HoB Final DR &ge; 10",
"Deploy NA",
"+1 CC Capture DRM NA",
"Always Lax",
"1 PAATC"
] }
"1st Line &amp; Conscript: <ul> <li> Surrender on HoB Final DR &ge; 10 <li> Deploy NA <li> +1 CC Capture DRM NA <li> Always Lax <li> 1 PAATC </ul>"
]
},
@ -160,29 +104,10 @@
"Deploy (1TC) &amp; Recombine without Leader",
"Self-Rally OK [EXC: Conscript]",
"Cowering NA [EXC: Conscript]",
"Elite &amp; 1st Line: <ul> <li> Always Stealthy <li> Use FT/DC as Elite <li> {? 07/1944- | Inherent PF | No Inherent PF | Inherent PF<sup>7/44+</sup> ?} </ul>",
"Ski-trained (don Skis = one MF)",
"Leader Creation NA",
"Captured Use penalties NA for Russian MG <br> [EXC: LMG in 1939; .50-cal]",
{ "caption": "Elite &amp; 1st Line", "notes": [
"Always Stealthy",
"Use FT/DC as Elite",
"{? 07/1944- | Inherent PF | No Inherent PF | Inherent PF<sup>7/44+</sup> ?}"
] }
]
},
"swedish": {
"th_color": [ "Red", "[EXC: MG]" ],
"oba": [ "6B", "3R"],
"hob_drm": "0 DRM",
"notes": [
"Extreme Winter effects NA",
{ "caption": "1<sup>st</sup> Line", "notes": [
"Battle Hardening &rarr; Fanatic"
] },
{ "caption": "Allied Troops", "notes": [
"Captured Use penalties NA"
] }
"Captured Use penalties NA for Russian MG [EXC: LMG in 1939; .50-cal]"
]
},
@ -193,17 +118,9 @@
"grenades": "Smoke",
"notes": [
"Escape NA",
{ "caption": "1st Line &amp; Conscript", "notes": [
"1 PAATC",
"Surrender on HoB Final DR &ge; 10"
] },
{ "caption": "Romanian, Hungarian", "notes": [
"{! -02/1944 = No Inherent PF | 03/1944-05/1944 = | 06/1944- = Inherent PF in non-Crew MMC | Inherent PF <span class='comment'>(Romanian<sup>3/44+</sup>, Hungarian<sup>6/44+</sup>)</span> !}"
] },
{ "caption": "Romanian", "notes": [
"{! 03/1944-05/1944 = Inherent PF in non-Crew MMC !}",
"{? 07/1943- | Inherent ATMM in non-Crew Elite <br> and 1st Line MMC (-2 CC DRM) | No Inherent ATMM | Inherent ATMM<sup>(7/43+)</sup> ?}"
] }
"1st Line &amp; Conscript: <ul> <li> 1 PAATC <li> Surrender on HoB Final DR &ge; 10 </ul>",
"{? 03/1944- | Inherent PF in non-Crew MMC (Romanian<sup>3/44+</sup>; Hungarian<sup>6/44+</sup>) | No Inherent PF | Inherent PF (Romanian<sup>3/44+</sup>; Hungarian<sup>6/44+</sup>) ?}",
"{? 07/1943- | Inherent ATMM in Romanian non-Crew Elite &amp; 1st Line MMC (-2 CC DRM) | No Inherent ATMM | Inherent ATMM<sup>(7/43+)</sup> ?}"
]
},
@ -214,9 +131,7 @@
"grenades": "Smoke",
"notes": [
"+1 Broken Morale vs Italians",
{ "caption": "1st Line &amp; Green", "notes": [
"1 PAATC"
] }
"1st Line &amp; Green: 1 PAATC"
]
},
@ -227,34 +142,19 @@
"grenades": "SMOKE",
"notes": [
"SMC PTC/Pin/Break NA",
"Leaders: <ul> <li> Replacement NA <li> Casualty MC &rarr; elimination <li> Morale/Rally/Berserk as Commissar </ul>",
"Tank-Hunter Heroes &amp; ATMM",
"Banzai Charge (always Lax)",
"Elite &amp; 1st Line: Always Stealthy",
"Conscript: Always Lax",
"ATR/MMG/HMG Breakdown penalty",
"Always NA: <ul> <li> PAATC <li> Escape <li> RtPh Surrender <li> Disruption <li> Encircled lower Morale <li> Leader Creation </ul>",
"LLMC &rarr; LLTC if unbroken",
"Massacre OK",
"-1 Interrogation DRM",
"-2 Concealment drm",
"Enemy +2 search drm",
"Hand-to-Hand CC &amp; Hara-Kiri",
{ "caption": "Leaders", "notes": [
"Replacement NA",
"Casualty MC &rarr; elimination",
"Morale/Rally/Berserk as Commissar"
] },
{ "caption": "Elite &amp; 1st Line", "notes": [
"Always Stealthy"
] },
{ "caption": "Conscript", "notes": [
"Always Lax"
] },
{ "caption": "Always NA", "notes": [
"PAATC",
"Escape",
"RtPh Surrender",
"Disruption",
"Encircled lower Morale",
"Leader Creation"
] }
"Hand-to-Hand CC &amp; Hara-Kiri"
]
},
@ -271,11 +171,9 @@
"Deploy NA",
"Lax at Night",
"+1 Leader Creation drm",
"1st Line &amp; Conscript: 1 PAATC",
"Human Wave",
"Dare-Death Squads [EXC: 5-3-7]",
{ "caption": "1st Line &amp; Conscript", "notes": [
"1 PAATC"
] }
"Dare-Death Squads [EXC: 5-3-7]"
]
},
@ -292,22 +190,8 @@
]
},
"partisan": {
"th_color": [ "Red", "[EXC: ATR/MG]" ],
"notes": [
"Stealthy when Good Order",
"Never Elite/Inexperienced",
"ELR 5",
"Leadership NA for non-Partisan units",
"Massacre OK",
"RtPh Surrender NA",
"Disrupt NA"
]
},
"kfw-rok": {
"_comment_": "Errata (ASLJ 13 p48): KMC: '9/50+ black' s.b. '8/50+ black",
"th_color": "{! -07/1950 = Red | 08/1950-04/1951 = Red (ROK) ; Black (KMC) | 05/1951- = Black | ??? !}",
"th_color": "{! -08/1950 = Red | 09/1950-04/1951 = Red (ROK) ; Black (KMC) | 05/1951- = Black | ??? !}",
"oba": [ "{! 06/1950- = 10B | ??? !}", "3R",
"{? 09/1950- | Plentiful Ammo included | Plentiful Ammo included (KMC) | Plentiful Ammo included (ROK: 9/50+) ?}",
"{! 06/1950-08/1950 = ROK: 6B/3R !}"
@ -316,16 +200,8 @@
"hob_drm": "+3/+4 DRM",
"grenades": "SMOKE",
"notes": [
{ "caption": "Republic of Korea (ROK)", "notes": [
"{! 06/1946-04/1951 = Early KW ROK rules !}",
"1st Line MMC: <ul> <li> Battle-Harden to Fanatic </ul>",
"2nd Line &amp; Conscript MMC: <ul> <li> Always Lax <li> Deploy NA",
"{? -10/1950 | Human Bullets | | Human Bullets (pre-11/50) ?}"
] },
{ "caption": "Korean Marine Corps (KMC)", "notes": [
"{! 04/1949-07/1950 = Japanese-Armed KMC | 08/1950- = U.S.-Armed KMC !}",
"{? -01/1951 | SW B#/X#/ROF penalty | | SW B#/X#/ROF penalty (pre-2/51) ?}"
] }
"Republic of Korea (ROK): <ul> {! 06/1946-04/1951 = <li> Early KW ROK rules !} <li> 1st Line MMC Battle-Harden to Fanatic <li> 2nd Line &amp; Conscript MMC: <ul> <li> Always Lax <li> Deploy NA </ul> {? -10/1950 | <li> Human Bullets | | <li> Human Bullets (pre-11/50) ?} </ul>",
"Korean Marine Corps (KMC): <ul> {! 04/1949-07/1950 = <li> Japanese-Armed KMC | 08/1950- = <li> U.S.-Armed KMC !} {? -01/1951 | <li> SW B#/X#/ROF penalty | | <li> SW B#/X#/ROF penalty (pre-2/51) ?} </ul>"
]
},
@ -335,18 +211,9 @@
"hob_drm": "-1 DRM",
"grenades": "SMOKE",
"notes": [
{ "caption": "2nd Line MMC", "notes": [
"ELR Replacement &rarr; Disrupt"
] },
{ "caption": "Canadian", "notes": [
"{? 01/1952- | Squads have Assault Fire | | Squads have Assault Fire<sup>1/52+</sup> ?}"
] },
{ "caption": "Royal Marines", "notes": [
"Commandos",
"No Non-Qualified Use penalty for RCL",
"No Captured Use penalty for Communist SW",
"Self-Deploy (1TC) &amp; Self-Recombine OK"
] }
"2nd Line MMC: ELR Replacement &rarr; Disrupt",
"{? 01/1952- | Canadian squads have Assault Fire | | Canadian squads have Assault Fire<sup>1/52+</sup> ?}",
"Royal Marines: <ul> <li> Commandos <li> No Non-Qualified Use penalty for RCL <li> No Captured Use penalty for Communist SW <li> Self-Deploy (1TC) &amp; Self-Recombine OK </ul>"
]
},
@ -356,12 +223,8 @@
"hob_drm": [ "0 DRM", "+3 for Turkish" ],
"grenades": "SMOKE",
"notes": [
{ "caption": "2nd Line MMC", "notes": [
"ELR Replacement &rarr; Disrupt [EXC: Turkish]"
] },
{ "caption": "Ethiopian, French, Turkish", "notes": [
"Bayonet Charge NTC NA for leaders"
] }
"2nd Line MMC: ELR Replacement &rarr; Disrupt [EXC: Turkish]",
"Bayonet Charge NTC NA for Ethiopian, French, Turkish leaders"
]
},
@ -371,24 +234,11 @@
"hob_drm": "+2 DRM",
"grenades": null,
"notes": [
"As Russian <ul> <li> Elite Personnel always Stealthy <li> Elite Squads may Deploy <li> Commissars <li> Massacre OK <li> Human Wave by SSR only </ul>",
"Suicide Heroes",
"Starshell restrictions",
{ "caption": "As Russian", "notes": [
"Elite Personnel always Stealthy",
"Elite Squads may Deploy",
"Commissars",
"Massacre OK",
"Human Wave by SSR only"
] },
{ "caption": "Assault Engineers", "notes": [
"WP grenades"
] },
{ "caption": "Communist Partisans", "notes": [
"Neither Elite nor Conscript/Green",
"Always Stealthy",
"Massacre OK",
"Disrupt &amp; RtPh Surrender NA"
] }
"Assault Engineers: WP grenades",
"Communist Partisans: <ul> <li> Neither Elite nor Conscript/Green <li> Always Stealthy <li> Massacre OK <li> Disrupt &amp; RtPh Surrender NA </ul>"
]
},
@ -406,8 +256,9 @@
"Starshell restrictions",
"Armored Assault NA",
"Riders NA",
"Assault Engineers: WP grenades",
"{! 10/1950-03/1951 = Early KW CPVA rules !}",
"Leaders &amp; Political Officers increase Morale <br> as if Commissar",
"Leaders &amp; Political Officers increase Morale as if Commissar",
"SW B#/X#/ROF penalty",
"Restricted Fire",
"Infantry Platoon Movement",
@ -419,10 +270,7 @@
"Infantry Overrun NTC NA",
"Conceal if +2 Hindrance",
"Concealment -1 drm",
"Civilian Interrogation is always in effect",
{ "caption": "Assault Engineers", "notes": [
"WP grenades"
] }
"Civilian Interrogation is always in effect"
]
},
@ -433,13 +281,9 @@
"grenades": null,
"notes": [
"Dare-Death Squads (as if Chinese)",
"Elite and 1st Line MMC: Always Stealthy",
"Deploy NA [EXC: A20.5 &amp; A21.22]; Recombine OK",
{ "caption": "Elite and 1st Line MMC", "notes": [
"Always Stealthy"
] },
{ "caption": "Leaders", "notes": [
"Morale/Berserk/Rally as Commissar"
] }
"Leaders: Morale/Berserk/Rally as Commissar"
]
},

@ -27,14 +27,6 @@
"display_name": "New Zealand",
"ob_colors": [ "#f6edda","#e5cea0", "#e5cea0" ]
},
"british~australian": {
"display_name": "Australian",
"ob_colors": [ "#f6edda","#e5cea0", "#e5cea0" ]
},
"british~anzac": {
"display_name": "ANZAC",
"ob_colors": [ "#f6edda","#e5cea0", "#e5cea0" ]
},
"french": {
"display_name": "French",
@ -55,11 +47,6 @@
"ob_colors": [ "#edefef","#ced3d3", "#ced3d3" ]
},
"swedish": {
"display_name": "Swedish",
"ob_colors": [ "#89bfe9","#699fc9", "#699fc9" ]
},
"japanese": {
"display_name": "Japanese",
"ob_colors": [ "#fff200","#ffdb00", "#ffdb00" ]
@ -74,16 +61,12 @@
"ob_colors": [ "#d3edfc","#91cdf5", "#e0a22b" ]
},
"partisan": {
"display_name": "Partisan",
"ob_colors": [ "#eabe51","#d68d1a", "#d68d1a" ]
},
"polish": {
"display_name": "Polish",
"ob_colors": [ "#ecd8b0","#e8cfa4", "#84e8c2" ],
"type": "allied-minor"
},
"belgian": {
"display_name": "Belgian",
"ob_colors": [ "#a3ecd1","#82e3bd", "#61d8a6" ],
@ -135,6 +118,10 @@
"type": "axis-minor"
},
"anzac": {
"display_name": "ANZAC",
"ob_colors": [ "#f6edda","#e5cea0", "#e5cea0" ]
},
"thai": {
"display_name": "Thai",
"ob_colors": [ "#3ceb7c","#1de256", "#0ed93c" ]

@ -33,7 +33,7 @@
padding: 2px 5px ;
font-size: 105% ; font-weight: bold ;
">
{{INCLUDE:player_flag_large}}{{PLAYER_NAME}} {{VO_TYPE}} Notes
{{INCLUDE:player_flag}}{{PLAYER_NAME}} {{VO_TYPE}} Notes
{%if OB_MA_NOTES%}
<tr> <td style="padding:0 5px;">

@ -26,7 +26,7 @@
white-space: nowrap ;
">
{# CSS "white-space:nowrap" doesn't always work in VASSAL, we need to use <nobr> and &nbsp; here :-/ #}
<nobr>{{INCLUDE:player_flag_large}}{{PLAYER_NAME|nbsp}}&nbsp;{{VO_TYPES}}</nobr>
<nobr>{{INCLUDE:player_flag}}{{PLAYER_NAME|nbsp}}&nbsp;{{VO_TYPES}}</nobr>
{%for vo in OB_VO%}

@ -1,4 +1,4 @@
{# NOTE: This CSS is split out into a separate file so we can apply it when generating Chapter H notes as images. #}
/* NOTE: This CSS is split out into a separate file so we can apply it when generating Chapter H notes as images. */
img.piece { float: left ; margin-right: 0.5em ; }
@ -22,6 +22,3 @@ img.piece { float: left ; margin-right: 0.5em ; }
table.layout td { padding: 0 5px ; }
.content .rf { display: none ; }
{# FUDGE! VASSAL and modern browsers differ on how much left margin there should be :-/ The default setting works on VASSAL, but because I serve Chapter H as images (which get generated in a modern browser), we change things to suit that. Note that there are other places where unordered lists are used (e.g. multi-applicable notes), but since those are typically only shown in VASSAL, we let them use the VASSAL-preferred setting. #}
.content ul { margin-left: 5px ; }

@ -17,7 +17,7 @@
padding: 2px 5px ;
font-size: 105% ; font-weight: bold ;
">
{{INCLUDE:player_flag_large}}{{VO_NAME}}
{{INCLUDE:player_flag}}{{VO_NAME}}
<tr>
<td style="padding-top:2px;"> {{VO_NOTE_HTML}}

@ -14,16 +14,13 @@
padding: 2px 5px ;
font-size: 105% ; font-weight: bold ;
">
{{INCLUDE:player_flag_large}}Panzerfaust
<tr>
<td colspan="2" style="padding:2px 5px;">
Elite &amp; 1<sup>st</sup> Line MMC only.
{{INCLUDE:player_flag}}Panzerfaust
<tr>
<td valign="top" rowspan="2" style="padding:2px 5px;">
<b>PF check</b>: dr &le; 2 (&#9651;)
Elite &amp; 1<sup>st</sup> Line MMC only <br>
PF check: dr &le; 2 (&#9651;)
<table style="margin-left:10px;">
<tr>
<td style="width:25px;"> +1 <td> HS/crew
@ -39,17 +36,19 @@
<td valign="top" style="padding:0 5px;">
<table>
<tr>
<td class="c" width="50"> Range <td class="c"> <b>TH#</b>
<td> <b>Range</b> <td> <b>TH#</b>
<tr>
<td class="c"> 0 <td class="c"> 10
<tr>
<td class="c"> 1 <td class="c"> 8
<tr>
<td colspan="2" class="r" style="padding-top:5px;">
<b>TK#:</b> 31 <br>
16-3
</table>
<tr>
<td style="padding:0 5px;text-align:right;">
<b>TK#:</b> 31 <br>
16-3
</table>
</html>

@ -14,15 +14,13 @@
padding: 2px 5px ;
font-size: 105% ; font-weight: bold ;
">
{{INCLUDE:player_flag_large}}Panzerfaust
{{INCLUDE:player_flag}}Panzerfaust
<tr>
<td colspan="2" style="padding:2px 5px;">
Non-Crew MMC only.
<tr>
<td valign="top" rowspan="2" style="padding:2px 5px;">
<b>PF check</b>: dr &le; 2 (&#9651;)
Non-Crew MMC only <br>
PF check: dr &le; 2 (&#9651;)
<table style="margin-left:10px;">
<tr>
<td style="width:25px;"> +1 <td> HS/crew
@ -40,19 +38,21 @@
<td valign="top" style="padding:0 5px;">
<table>
<tr>
<td class="c" width="50"> Range <td class="c"> <b>TH#</b>
<td> <b>Range</b> <td> <b>TH#</b>
<tr>
<td class="c"> 0 <td class="c"> 10
<tr>
<td class="c"> 1 <td class="c"> 8
<tr>
<td class="c"> 2 <td class="c"> 6
<tr>
<td colspan="2" class="r" style="padding-top:5px;">
<b>TK#:</b> 31 <br>
16-3
</table>
<tr>
<td style="padding:0 5px;text-align:right;">
<b>TK#:</b> 31 <br>
16-3
</table>
</html>

@ -14,16 +14,13 @@
padding: 2px 5px ;
font-size: 105% ; font-weight: bold ;
">
{{INCLUDE:player_flag_large}}Panzerfaust
<tr>
<td colspan="2" style="padding:2px 5px;">
Non-Crew MMC only.
{{INCLUDE:player_flag}}Panzerfaust
<tr>
<td valign="top" rowspan="2" style="padding:2px 5px;">
<b>PF check</b>: dr &le; {%if SCENARIO_YEAR >= 1945%} 1 {%else%} 2 {%endif%} (&#9651;)
Non-Crew MMC only <br>
PF check: dr &le; {%if SCENARIO_YEAR >= 1945%} 1 {%else%} 2 {%endif%} (&#9651;)
<table style="margin-left:10px;">
<tr>
<td style="width:25px;"> +1 <td> HS/crew
@ -43,7 +40,7 @@
<td valign="top" style="padding:0 5px;">
<table>
<tr>
<td class="c" width="50"> Range <td class="c"> <b>TH#</b>
<td> <b>Range</b> <td> <b>TH#</b>
<tr>
<td class="c"> 0 <td class="c"> 10
<tr>
@ -52,14 +49,14 @@
<tr>
<td class="c"> 2 <td class="c"> 6
{%endif%}
<tr>
<td colspan="2" class="r" style="padding-top:5px;">
<b>TK#:</b> 31 <br>
16-3
</table>
<tr>
<td style="padding:0 5px;text-align:right;">
<b>TK#:</b> 31 <br>
16-3
</table>
</html>

@ -2,10 +2,7 @@
<head>
<meta charset="utf-8">
<style>
{{CSS:common}}
.pfk { color: #444 ; font-style: italic ; }
</style>
<style> {{CSS:common}} </style>
</head>
<table>
@ -17,12 +14,12 @@
padding: 2px 5px ;
font-size: 105% ; font-weight: bold ;
">
{{INCLUDE:player_flag_large}}Panzerfaust
{{INCLUDE:player_flag}}Panzerfaust
<tr>
<td valign="top" rowspan="2" style="padding:2px 5px;">
<b>PF check</b>: dr &le; {{PF_CHECK_DR}} (&#9651;)
PF check: dr &le; {{PF_CHECK_DR}} (&#9651;)
<table style="margin-left:10px;">
<tr>
<td style="width:25px;"> +1 <td> HS/crew
@ -32,21 +29,13 @@
<td> +1 <td> CX
<tr>
<td> +1 <td> non-AFV target
{%if not SCENARIO_YEAR%}
<tr>
<td> -1 <td> 1945
{%endif%}
{%if SCENARIO_YEAR == 1943 and SCENARIO_MONTH >= 8 and SCENARIO_MONTH <= 9 %}
<tr class="pfk">
<td> +1 <td> PFk
{%endif%}
</table>
original 6 = pinned
<td valign="top" style="padding:0 5px;">
<table>
<tr>
<td class="c"> Range <td class="c" width="35"> <b>TH#</b>
<td> <b>Range</b> <td> <b>TH#</b>
<tr>
<td class="c"> 0 <td class="c"> 10
<tr>
@ -60,17 +49,12 @@
{%endif%}
{%endif%}
</table>
original 12 = CR
<tr>
<td style="padding:5px 5px 0 5px;">
<div>
<b>TK#</b>: 31 <i>(16-3)</i>
</div>
<div class="pfk">
PFk TK#: 22 <i>(12-1)</i>
</div>
<td style="padding:0 5px;text-align:right;">
<b>TK#:</b> 31 <br>
16-3
</table>

@ -14,14 +14,14 @@
padding: 2px 5px ;
font-size: 105% ; font-weight: bold ;
">
{{INCLUDE:player_flag_large}}PIAT
{{INCLUDE:player_flag}}PIAT
<tr>
<td style="padding:0 5px;">
<table>
<tr>
<td class="c"> Range <td class="c" width="35"> <b>TH#</b>
<td> <b>Range</b> <td> <b>TH#</b>
<tr>
<td class="c"> 0 <td class="c"> 11
<tr>
@ -34,16 +34,14 @@
<td valign="top" style="padding:0 5px;">
<table>
<tr>
<td> <b>B#:</b>
<td class="r"> 10
<tr>
<td> <b>TK#:</b>
<td class="r"> 15
<tr>
<td colspan="2" class="r"> 8-3
<tr>
<td> &nbsp;
<tr>
<td> <b>B#:</b>
<td class="r"> 10
</table>
</table>

@ -1,2 +1,2 @@
{# Some versions of Java require <img> tags to have the width and height specified!?! #}
{%if PLAYER_FLAG%}<img src="{{PLAYER_FLAG}}?prefh={{PLAYER_FLAG_SIZE}}" width="{{PLAYER_FLAG_SIZE}}" height="{{PLAYER_FLAG_SIZE}}">&nbsp;{%endif%}
{%if PLAYER_FLAG%}<img src="{{PLAYER_FLAG}}" {{PLAYER_FLAG_SIZE}}>&nbsp;{%endif%}

@ -1,2 +0,0 @@
{# Some versions of Java require <img> tags to have the width and height specified!?! #}
{%if PLAYER_FLAG%}<img src="{{PLAYER_FLAG}}?prefh={{PLAYER_FLAG_SIZE_LARGE}}" width="{{PLAYER_FLAG_SIZE_LARGE}}" height="{{PLAYER_FLAG_SIZE_LARGE}}">&nbsp;{%endif%}

@ -26,7 +26,7 @@ td.description { font-size: 90% ; font-style: italic ; color: #808080 ; }
<tr>
{# Some versions of Java require <img> tags to have the width and height specified!?! #}
<td class="flag"> {%if PLAYER_FLAG_1%} <img src="{{PLAYER_FLAG_1}}?prefh={{PLAYER_FLAG_SIZE}}" width="{{PLAYER_FLAG_SIZE}}" height="{{PLAYER_FLAG_SIZE}}"> {%endif%}
<td class="flag"> {%if PLAYER_FLAG_1%} <img src="{{PLAYER_FLAG_1}}" {{PLAYER_FLAG_SIZE}}> {%endif%}
<td class="player"> {{PLAYER_1_NAME}}:
<td class="val"> &nbsp; ELR: {{PLAYER_1_ELR}}
<td class="val"> &nbsp; SAN: {{PLAYER_1_SAN}}
@ -39,7 +39,7 @@ td.description { font-size: 90% ; font-style: italic ; color: #808080 ; }
<tr>
{# Some versions of Java require <img> tags to have the width and height specified!?! #}
<td class="flag"> {%if PLAYER_FLAG_2%} <img src="{{PLAYER_FLAG_2}}?prefh={{PLAYER_FLAG_SIZE}}" width="{{PLAYER_FLAG_SIZE}}" height="{{PLAYER_FLAG_SIZE}}"> {%endif%}
<td class="flag"> {%if PLAYER_FLAG_2%} <img src="{{PLAYER_FLAG_2}}" {{PLAYER_FLAG_SIZE}}> {%endif%}
<td class="player"> {{PLAYER_2_NAME}}:
<td class="val"> &nbsp; ELR: {{PLAYER_2_ELR}}
<td class="val"> &nbsp; SAN: {{PLAYER_2_SAN}}

@ -14,14 +14,14 @@
padding: 2px 5px ;
font-size: 105% ; font-weight: bold ;
">
{{INCLUDE:player_flag_large}}Panzerschrek
{{INCLUDE:player_flag}}Panzerschrek
<tr>
<td style="padding:0 5px;">
<table>
<tr>
<td class="c"> Range <td class="c" width="35"> <b>TH#</b>
<td> <b>Range</b> <td> <b>TH#</b>
<tr>
<td class="c"> 0 <td class="c"> 11
<tr>
@ -36,16 +36,14 @@
<td valign="top" style="padding:0 5px;">
<table>
<tr>
<td> <b>X#:</b>
<td class="r"> 10
<tr>
<td> <b>TK#:</b>
<td class="r"> 26
<tr>
<td colspan="2" class="r"> 12-4
<tr>
<td> &nbsp;
<tr>
<td> <b>X#:</b>
<td class="r"> 10
</table>
</table>

@ -1,11 +1,9 @@
<html> <!-- vasl-templates:id {{SNIPPET_ID}} -->
{%if APP_NAME%}<!-- Generated by {{APP_NAME}} {{APP_VERSION}}{%if BUILD_GIT_INFO%} ({{BUILD_GIT_INFO}}){%endif%}:
{%if APP_NAME%}<!-- Generated by {{APP_NAME}} {{APP_VERSION}}:
Time: {{TIMESTAMP}}
VASSAL:{%if VASSAL_VERSION %} {{VASSAL_VERSION}} {%else%} - {%endif%}
VASL: {%if VASL_VERSION%} {{VASL_VERSION}} {%else%} - {%endif%}
--> {%endif%}
{%if ASA_ID %}<!-- vasl-templates:ASA_ID {{ASA_ID}} -->{%endif%}
{%if ROAR_ID %}<!-- vasl-templates:ROAR_ID {{ROAR_ID}} -->{%endif%}
<head>
<meta charset="utf-8">

@ -4,7 +4,7 @@
<meta charset="utf-8">
<style>
{{CSS:common}}
ul#ssr { padding: 0 5px 0 15px ; }
ul#ssr { padding: 0 5px 0 13px ; }
ul#ssr li { margin-top: 2px ; }
</style>
</head>

@ -14,12 +14,12 @@
padding: 2px 5px ;
font-size: 105% ; font-weight: bold ;
">
{{INCLUDE:player_flag_large}}Tank-Hunter Heroes
{{INCLUDE:player_flag}}Tank-Hunter Heroes
<tr>
<td valign="top" style="padding:2px 5px;">
<b>THH creation</b>: dr &le; 3 (&#9651;)
THH creation: dr &le; 3 (&#9651;)
<table style="margin-left:10px;">
<tr>
<td style="width:25px;"> +2 <td> if Conscript
@ -33,8 +33,8 @@
<tr>
{% if SCENARIO_YEAR %}
<td valign="top" style="padding:5px;">
<b>ATMM check</b>: dr &le; {%if SCENARIO_YEAR < 1944%} 2 {%else%} 3 {%endif%} (&#9651;)
<td valign="top" style="padding:10px 5px;">
ATMM check: dr &le; {%if SCENARIO_YEAR < 1944%} 2 {%else%} 3 {%endif%} (&#9651;)
{%endif%}
</table>

@ -1,116 +0,0 @@
<html> <!-- vasl-templates:id {{SNIPPET_ID}} -->
<head>
<meta charset="utf-8">
<style> {{CSS:common}} </style>
<style>
td {
width: 50px ; min-width: 50px ;
height: {%if TURN_TRACK_PREVIEW_MODE%} 50px {%else%} 43px {%endif%} ;
padding: 2px ;
border: 1px solid black ;
}
{% set RESET_TD = "min-width: unset ; height: unset ; padding: 0 ; border: none" %}
td.turn-no {
{{RESET_TD}} ; width: unset ;
text-align: center ; vertical-align: center ; font-size: 18px ; font-weight: bold ;
}
td.no-reinforce { {{RESET_TD}} ; width: 13px ; }
{# NOTE: We do the reinforcement flags as CSS backgrounds, since VASSAL is incredibly slow downloading normal images. #}
td.reinforce1 { {{RESET_TD}} ; width: 13px ; background: url("{{TURN_TRACK_FLAG_1}}") top left no-repeat ; vertical-align: top ; }
td.reinforce2 { {{RESET_TD}} ; width: 13px ; background: url("{{TURN_TRACK_FLAG_2}}") bottom right no-repeat ; vertical-align: bottom ; }
td.half-turn {
background: url("{{TURN_TRACK_HALF_TURN_IMAGE_URL}}") bottom right no-repeat ;
background-size: contain ; {# nb: doesn't work in VASSAL, the image file needs to be the correct size :-/ #}
}
{% if TURN_TRACK_PREVIEW_MODE %}
body { margin: 0 ; }
body ::selection {}
body ::moz-selection {}
body { user-select: none ; }
.reinforce1, .reinforce2 { opacity: 0 ; }
.flag-click { width: 13px ; height: 13px ; cursor: pointer ; }
.shading-click { cursor: pointer ; }
{%endif%}
</style>
</head>
{% if TURN_TRACK_PREVIEW_MODE %}
<script>
// notify the parent window of clicks
function onFlagClick( turnNo, playerNo ) {
window.parent.postMessage( {
type: "FlagClick",
turnNo: turnNo, uiPlayerNo: playerNo
}, "*" ) ;
}
function onShadingClick( turnNo ) {
window.parent.postMessage( {
type: "ShadingClick",
turnNo: turnNo
}, "*" ) ;
}
</script>
{%endif%}
<table class="turn-track">
{% for row in TURN_TRACK_SQUARES %}
<tr>
{% for turnSquare in row %}
<td id="turn-square-{{turnSquare[0]}}"
{%if turnSquare[0] == TURN_TRACK_HALF_TURN%} class="half-turn" {%endif%}
{% if turnSquare[3] %} style="background-color:{{turnSquare[3]}};" {%endif%}
>
<table style="width:100%;height:100%;"> <tr>
<td id="flag-{{turnSquare[0]}}_1" width="100%"
class = {% if turnSquare[1] %} "reinforce1" {%else%} "no-reinforce" {%endif%}
>
{% if TURN_TRACK_PREVIEW_MODE %}
<div class="flag-click"
onclick = "onFlagClick( {{turnSquare[0]}}, 1 )"
> </div>
{%endif%}
</td>
<td class="turn-no">
{% if TURN_TRACK_PREVIEW_MODE %}
<div class="shading-click"
onclick = "onShadingClick( {{turnSquare[0]}} )"
>
{{turnSquare[0]}}
</div>
{%else%}
{{turnSquare[0]}}
{%endif%}
</td>
<td id="flag-{{turnSquare[0]}}_2" width="100%"
class = {% if turnSquare[2] and turnSquare[0] != TURN_TRACK_HALF_TURN %} "reinforce2" {%else%} "no-reinforce" {%endif%}
>
{% if TURN_TRACK_PREVIEW_MODE and turnSquare[0] != TURN_TRACK_HALF_TURN %}
<div class="flag-click"
onclick = "onFlagClick( {{turnSquare[0]}}, 2 )"
> </div>
{%endif%}
</td>
</tr> </table>
</td>
{%endfor%}
</tr>
{%endfor%}
</table>
</html>

@ -738,7 +738,7 @@
{ "name": "Type 89 Heavy Grenade Launcher",
"type": "MTR",
"capabilities2": { "WP": [ [5,5] ], "s": [ [6,5] ] },
"capabilities2": { "WP": [ [5,5] ], "S": [ [6,5] ] },
"comments": [ "HE: Range &le; 2, ROF 1", "Smoke: [3-10]", "WP: [1-5], ROF 1" ],
"note_number": "1",
"id": "ina/o:900",
@ -788,7 +788,7 @@
},
"british~anzac": {
"anzac": {
"vehicles": [

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save