Compare commits
647 Commits
@ -0,0 +1,8 @@ |
||||
* |
||||
|
||||
! setup.py |
||||
! requirements*.txt |
||||
! vasl_templates/ |
||||
! vassal-shim/release/ |
||||
! docker/ |
||||
! LICENSE.txt |
@ -0,0 +1,86 @@ |
||||
# 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 |
||||
|
||||
# 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/ |
||||
|
||||
# clean up |
||||
RUN dnf clean all |
||||
|
||||
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - |
||||
|
||||
FROM base AS build |
||||
|
||||
# set up a virtualenv |
||||
RUN python3.11 -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 |
||||
|
||||
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - |
||||
|
||||
FROM base |
||||
|
||||
# copy the virtualenv from the build image |
||||
COPY --from=build /opt/venv /opt/venv |
||||
ENV PATH="/opt/venv/bin:$PATH" |
||||
|
||||
# install the application |
||||
WORKDIR /app |
||||
COPY vasl_templates/ ./vasl_templates/ |
||||
COPY vassal-shim/release/vassal-shim.jar ./vassal-shim/release/ |
||||
COPY setup.py requirements.txt requirements-dev.txt LICENSE.txt ./ |
||||
RUN pip3 install --editable . |
||||
|
||||
# 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/ |
||||
|
||||
# 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 ./ |
||||
CMD ./run.sh |
@ -1,20 +1,21 @@ |
||||
# VASL Templates |
||||
|
||||
<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> |
||||
[<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) |
||||
|
||||
*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="https://github.com/pacman-ghost/vasl-templates/raw/master/vasl_templates/webapp/static/help/images/ob_setup.png" width="200"> |
||||
[<img src="vasl_templates/webapp/static/help/images/ob_setup.png" width="200">](vasl_templates/webapp/static/help/images/ob_setup.png) |
||||
|
||||
You can find more examples of the program in action [here](https://github.com/pacman-ghost/vasl-templates/tree/master/examples/). |
||||
You can find more examples of the program in action [here](examples/). |
||||
|
||||
### Documentation |
||||
|
||||
* [User Guide](https://rawgit.com/pacman-ghost/vasl-templates/master/vasl_templates/webapp/static/help/index.html?tab=userguide) |
||||
* [Installation](https://rawgit.com/pacman-ghost/vasl-templates/master/vasl_templates/webapp/static/help/index.html?tab=installation) |
||||
* [Writing your own templates](https://rawgit.com/pacman-ghost/vasl-templates/master/vasl_templates/webapp/static/help/index.html?tab=templatepacks) |
||||
* [For developers](https://rawgit.com/pacman-ghost/vasl-templates/master/vasl_templates/webapp/static/help/index.html?tab=fordevelopers) |
||||
* [User Guide](https://vasl-templates.org/help?tab=userguide) |
||||
* [Installation](https://vasl-templates.org/help?tab=installation) |
||||
* [Quick Start Guide](https://vasl-templates.org/help?tab=quickstart) |
||||
* [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,118 +0,0 @@ |
||||
#!/usr/bin/env python3 |
||||
""" Compile the application and create a release. """ |
||||
|
||||
import sys |
||||
import os |
||||
import shutil |
||||
import glob |
||||
import getopt |
||||
from cx_Freeze import setup, Executable |
||||
|
||||
from vasl_templates.webapp.config.constants import APP_NAME, APP_VERSION, APP_DESCRIPTION |
||||
|
||||
BASE_DIR = os.path.split( os.path.abspath(__file__) )[ 0 ] |
||||
BUILD_DIR = os.path.join( BASE_DIR, "build" ) |
||||
|
||||
MAIN_ENTRY_POINT = "vasl_templates/main.py" |
||||
APP_ICON = os.path.join( BASE_DIR, "vasl_templates/webapp/static/images/app.ico" ) |
||||
|
||||
TARGET_NAMES = { |
||||
"win32": "vasl-templates.exe", |
||||
} |
||||
DEFAULT_TARGET_NAME = "vasl-templates" |
||||
|
||||
# --------------------------------------------------------------------- |
||||
|
||||
def get_extra_files(): |
||||
"""Get the extra files to include in the release.""" |
||||
def globfiles( fspec ): #pylint: disable=missing-docstring,unused-variable |
||||
fnames = glob.glob( fspec ) |
||||
return zip( fnames, fnames ) |
||||
extra_files = [] |
||||
extra_files.append( "LICENSE.txt" ) |
||||
return extra_files |
||||
|
||||
# --------------------------------------------------------------------- |
||||
|
||||
# parse the command-line options |
||||
output_fname = None |
||||
cleanup = True |
||||
opts,args = getopt.getopt( sys.argv[1:], "o:", ["output=","noclean"] ) |
||||
for opt,val in opts: |
||||
if opt in ["-o","--output"]: |
||||
output_fname = 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 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] ) ) |
||||
|
||||
# initialize the build options |
||||
build_options = { |
||||
"packages": [ "os", "asyncio", "jinja2" ], |
||||
"excludes": [ "tkinter" ], |
||||
"include_files": get_extra_files(), |
||||
} |
||||
|
||||
# freeze the application |
||||
# NOTE: It would be nice to be able to use py2exe to compile this for Windows (since it produces |
||||
# a single EXE instead of the morass of files cx-freeze generates) but py2exe only works up to |
||||
# Python 3.4, since the byte code format changed after that. |
||||
target = Executable( |
||||
MAIN_ENTRY_POINT, |
||||
base = "Win32GUI" if sys.platform == "win32" else None, |
||||
targetName = TARGET_NAMES.get( sys.platform, DEFAULT_TARGET_NAME ), |
||||
icon = APP_ICON |
||||
) |
||||
if os.path.isdir( BUILD_DIR ): |
||||
shutil.rmtree( BUILD_DIR ) |
||||
os.chdir( BASE_DIR ) |
||||
del sys.argv[1:] |
||||
sys.argv.append( "build" ) |
||||
# nb: cx-freeze doesn't report compile errors or anything like that :-/ |
||||
setup( |
||||
name = APP_NAME, |
||||
version = APP_VERSION, |
||||
description = APP_DESCRIPTION, |
||||
options = { |
||||
"build_exe": build_options |
||||
}, |
||||
executables = [ target ] |
||||
) |
||||
print() |
||||
|
||||
# locate the release files |
||||
files = os.listdir( BUILD_DIR ) |
||||
if len(files) != 1: |
||||
raise RuntimeError( "Unexpected freeze output." ) |
||||
dname = os.path.join( BUILD_DIR, files[0] ) |
||||
os.chdir( dname ) |
||||
|
||||
# remove some unwanted files |
||||
for fname in ["debug.cfg","logging.cfg"]: |
||||
fname = os.path.join( "lib/vasl_templates/webapp/config", fname ) |
||||
if os.path.isfile( fname ): |
||||
os.unlink( fname ) |
||||
|
||||
# create the release archive |
||||
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 ) |
@ -0,0 +1,5 @@ |
||||
# Chapter H Vehicle/Ordnance notes |
||||
|
||||
It is possible to include Chapter H notes in your VASL scenarios, but since this is copyrighted material, it is not included in releases, and you will need to set up the data yourself. |
||||
|
||||
The ZIP file in this directory contains placeholder files for the Chapter H notes, refer to the [documentation](https://vasl-templates.org/help/?tab=chapterh) for instructions on how to set things up. |
@ -0,0 +1 @@ |
||||
logging.yaml |
@ -0,0 +1,5 @@ |
||||
[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/ |
@ -0,0 +1,5 @@ |
||||
[Site Config] |
||||
|
||||
IS_CONTAINER = 1 |
||||
|
||||
WEBDRIVER_PATH = /usr/bin/geckodriver |
@ -0,0 +1,15 @@ |
||||
#!/bin/sh |
||||
|
||||
# set up the display (so we can run VASSAL and 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 |
@ -1 +1,189 @@ |
||||
{"SCENARIO_NAME":"Hill 621","SCENARIO_ID":"ASL E","SCENARIO_LOCATION":"Near Minsk, Russia","SCENARIO_DATE":"1944-06-29","SCENARIO_WIDTH":"","VICTORY_CONDITIONS_WIDTH":"240px","SSR_WIDTH":"300px","OB_VEHICLES_WIDTH_1":"","OB_ORDNANCE_WIDTH_1":"","OB_VEHICLES_WIDTH_2":"","OB_ORDNANCE_WIDTH_2":"","VICTORY_CONDITIONS":"The Russians win at Game End if they Control ≥ five Level 3 hill hexes on Board 2.","PLAYER_1":"russian","PLAYER_1_ELR":"4","PLAYER_1_SAN":"3","PLAYER_2":"german","PLAYER_2_ELR":"3","PLAYER_2_SAN":"4","SSR":["EC are Moderate, with no wind at start.","After \"At Start\" placement, each German infantry unit must take a TC. The only possible consequence of failure is that the unit must begin the scenario broken. Those units which break during this pre-game TC are not subject to DM in the initial German RPh.","The Germans receive one module of 80+mm Battalion Mortar OBA (HE and Smoke) with the radio in the initial OB.","The Germans receive one module of 100+mm OBA (HE and Smoke) with the Turn 4 reinforcements."],"OB_VEHICLES_1":[{"name":"T-34 M43"},{"name":"SU-152"},{"name":"SU-122"},{"name":"ZIS-5"}],"OB_VEHICLES_2":[{"name":"PzKpfw IVH"},{"name":"PzKpfw IIIN"},{"name":"StuG IIIG (L)"},{"name":"StuH 42"},{"name":"SPW 250/1"},{"name":"SPW 251/1"},{"name":"SPW 251/sMG"}],"OB_ORDNANCE_2":[{"name":"7.5cm PaK 40"},{"name":"5cm PaK 38"}],"SCENARIO_NOTES":[{"caption":"Download the scenario card from <a href=\"http://www.multimanpublishing.com/Support/ASLASLSK/ASLOfficialDownloads/tabid/109/Default.aspx\">Multi-Man Publishing</a> (ASL Classic pack).","width":"300px"}],"OB_SETUPS_1":[{"caption":"Set up on any whole hex of Board 3","width":""},{"caption":"Enter on Turn 2 on any single road hex <br>\non the east edge of Board 3","width":""},{"caption":"Enter on Turn 5 on any single road hex <br>\non the east edge of Board 3","width":""}],"OB_SETUPS_2":[{"caption":"Set up in any whole hex of Board 4","width":""},{"caption":"Enter on Turn 1 on any single road hex <br>\non any edge of Board 2","width":""},{"caption":"Enter on Turn 2 on any single road hex <br>\non the north <i>or</i> south edge of Board 4","width":""},{"caption":"Enter on Turn 4 on any single road hex <br>\non the west edge of Board 2","width":""},{"caption":"Enter on Turn 5 on any single road hex <br>\nalong the north, south or west edge of Board 2","width":""},{"caption":"Enter on Turn 8 along <br>\nthe west edge of Board 2","width":""}],"OB_NOTES_1":[],"OB_NOTES_2":[{"caption":"80+mm Battalion Mortar <br> OBA (HE/Smoke)","width":""},{"caption":"100+mm OBA (HE/Smoke)","width":""}]} |
||||
{ |
||||
"COMPASS": "right", |
||||
"SCENARIO_DATE": "1944-07-01", |
||||
"SCENARIO_WIDTH": "", |
||||
"ASA_ID": "56512", |
||||
"ROAR_ID": "129", |
||||
"PLAYERS_WIDTH": "", |
||||
"VICTORY_CONDITIONS_WIDTH": "", |
||||
"SSR_WIDTH": "400px", |
||||
"OB_VEHICLES_WIDTH_1": "", |
||||
"OB_VEHICLES_MA_NOTES_WIDTH_1": "300px", |
||||
"OB_ORDNANCE_WIDTH_1": "", |
||||
"OB_ORDNANCE_MA_NOTES_WIDTH_1": "300px", |
||||
"OB_VEHICLES_WIDTH_2": "", |
||||
"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.", |
||||
"SCENARIO_THEATER": "ETO", |
||||
"PLAYER_1": "russian", |
||||
"PLAYER_1_ELR": "4", |
||||
"PLAYER_1_SAN": "3", |
||||
"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.", |
||||
"The Germans receive one module of 80+mm Battalion Mortar OBA (HE and Smoke) with the radio in the initial OB.", |
||||
"The Germans receive one module of 100+mm OBA (HE and Smoke) with the Turn 4 reinforcements." |
||||
], |
||||
"OB_VEHICLES_1": [ |
||||
{ |
||||
"id": "ru/v:025", |
||||
"seq_id": 1, |
||||
"name": "T-34 M43" |
||||
}, |
||||
{ |
||||
"id": "ru/v:047", |
||||
"seq_id": 2, |
||||
"name": "SU-152" |
||||
}, |
||||
{ |
||||
"id": "ru/v:046", |
||||
"seq_id": 3, |
||||
"name": "SU-122" |
||||
}, |
||||
{ |
||||
"id": "ru/v:068", |
||||
"seq_id": 4, |
||||
"name": "ZIS-5" |
||||
} |
||||
], |
||||
"OB_VEHICLES_2": [ |
||||
{ |
||||
"id": "ge/v:027", |
||||
"seq_id": 1, |
||||
"name": "PzKpfw IVH" |
||||
}, |
||||
{ |
||||
"id": "ge/v:019", |
||||
"seq_id": 2, |
||||
"name": "PzKpfw IIIN" |
||||
}, |
||||
{ |
||||
"id": "ge/v:038", |
||||
"seq_id": 3, |
||||
"name": "StuG IIIG (L)" |
||||
}, |
||||
{ |
||||
"id": "ge/v:039", |
||||
"seq_id": 4, |
||||
"name": "StuH 42" |
||||
}, |
||||
{ |
||||
"id": "ge/v:065", |
||||
"seq_id": 5, |
||||
"name": "SPW 250/1" |
||||
}, |
||||
{ |
||||
"id": "ge/v:071", |
||||
"seq_id": 6, |
||||
"name": "SPW 251/1" |
||||
}, |
||||
{ |
||||
"id": "ge/v:072", |
||||
"seq_id": 7, |
||||
"name": "SPW 251/sMG" |
||||
} |
||||
], |
||||
"OB_ORDNANCE_2": [ |
||||
{ |
||||
"id": "ge/o:009", |
||||
"seq_id": 1, |
||||
"name": "7.5cm PaK 40" |
||||
}, |
||||
{ |
||||
"id": "ge/o:007", |
||||
"seq_id": 2, |
||||
"name": "5cm PaK 38" |
||||
} |
||||
], |
||||
"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).", |
||||
"width": "300px", |
||||
"id": 1 |
||||
} |
||||
], |
||||
"OB_SETUPS_1": [ |
||||
{ |
||||
"caption": "Set up on any whole hex of Board 3", |
||||
"width": "", |
||||
"id": 1 |
||||
}, |
||||
{ |
||||
"caption": "Enter on Turn 2 on any single road hex <br>\non the east edge of Board 3", |
||||
"width": "", |
||||
"id": 2 |
||||
}, |
||||
{ |
||||
"caption": "Enter on Turn 5 on any single road hex <br>\non the east edge of Board 3", |
||||
"width": "", |
||||
"id": 3 |
||||
} |
||||
], |
||||
"OB_SETUPS_2": [ |
||||
{ |
||||
"caption": "Set up in any whole hex of Board 4", |
||||
"width": "", |
||||
"id": 1 |
||||
}, |
||||
{ |
||||
"caption": "Enter on Turn 1 on any single road hex <br>\non any edge of Board 2", |
||||
"width": "", |
||||
"id": 2 |
||||
}, |
||||
{ |
||||
"caption": "Enter on Turn 2 on any single road hex <br>\non the north <i>or</i> south edge of Board 4", |
||||
"width": "", |
||||
"id": 3 |
||||
}, |
||||
{ |
||||
"caption": "Enter on Turn 4 on any single road hex <br>\non the west edge of Board 2", |
||||
"width": "", |
||||
"id": 4 |
||||
}, |
||||
{ |
||||
"caption": "Enter on Turn 5 on any single road hex <br>\nalong the north, south or west edge of Board 2", |
||||
"width": "", |
||||
"id": 5 |
||||
}, |
||||
{ |
||||
"caption": "Enter on Turn 8 along <br>\nthe west edge of Board 2", |
||||
"width": "", |
||||
"id": 6 |
||||
} |
||||
], |
||||
"OB_NOTES_1": [], |
||||
"OB_NOTES_2": [ |
||||
{ |
||||
"caption": "80+mm Battalion Mortar <br> OBA (HE/Smoke)", |
||||
"width": "", |
||||
"id": 1 |
||||
}, |
||||
{ |
||||
"caption": "100+mm OBA (HE/Smoke)", |
||||
"width": "", |
||||
"id": 2 |
||||
} |
||||
], |
||||
"_app_version": "v1.10", |
||||
"_last_update_time": "2022-09-12T02:46:18.035Z", |
||||
"_creation_time": "2020-09-27T03:46:56.089Z" |
||||
} |
Before Width: | Height: | Size: 971 KiB After Width: | Height: | Size: 3.8 MiB |
After Width: | Height: | Size: 109 KiB |
@ -1 +1,126 @@ |
||||
{"SCENARIO_NAME":"Hube's Pocket","SCENARIO_ID":"ASL G","SCENARIO_LOCATION":"Near Buchach, Southern Russia","SCENARIO_DATE":"1944-04-05","SCENARIO_WIDTH":"","VICTORY_CONDITIONS_WIDTH":"300px","SSR_WIDTH":"330px","OB_VEHICLES_WIDTH_1":"","OB_ORDNANCE_WIDTH_1":"","OB_VEHICLES_WIDTH_2":"","OB_ORDNANCE_WIDTH_2":"","VICTORY_CONDITIONS":"The Germans win immediately by exiting ≥ 10 vehicles <br>\noff the west edge in either one or two Convoys (see SSR 4).","PLAYER_1":"german","PLAYER_1_ELR":"4","PLAYER_1_SAN":"2","PLAYER_2":"russian","PLAYER_2_ELR":"3","PLAYER_2_SAN":"2","SSR":["The SPW 251/sMG inherent HS is a 3-4-8.","German inherent crews have a morale of 9.","No German unit may enter any hex of Board 4 prior to Turn 2.","All units of the 1st Panzer Army must enter in Convoy (E11.) on/after Turn 5 (some, none, or all may enter each Turn) along any single road hex along the east edge."],"OB_VEHICLES_1":[{"name":"PzKpfw IVH"},{"name":"PzKpfw VG"},{"name":"SPW 251/sMG"},{"name":"SPW 251/1"},{"name":"Buessing-NAG 4500"},{"name":"Opel 6700 (Blitz)"},{"name":"SdKfz 7"}],"OB_VEHICLES_2":[{"name":"T-34/85"},{"name":"T-34 M43"}],"SCENARIO_NOTES":[{"caption":"Download the scenario card from <a href=\"http://www.multimanpublishing.com/Support/ASLASLSK/ASLOfficialDownloads/tabid/109/Default.aspx\">Multi-Man Publishing</a> (ASL Classic pack).","width":""}],"OB_SETUPS_1":[{"caption":"Enter on Turn 1 along the west edge of Boards 2/5 (see SSR 3)","width":""},{"caption":"Enter per SSR 4","width":"200px"}],"OB_SETUPS_2":[{"caption":"Enter on Turn 1 along the north edge","width":""}],"OB_NOTES_1":[],"OB_NOTES_2":[]} |
||||
{ |
||||
"COMPASS": "down", |
||||
"SCENARIO_DATE": "1944-04-06", |
||||
"SCENARIO_WIDTH": "", |
||||
"ASA_ID": "56514", |
||||
"ROAR_ID": "131", |
||||
"PLAYERS_WIDTH": "", |
||||
"VICTORY_CONDITIONS_WIDTH": "", |
||||
"SSR_WIDTH": "330px", |
||||
"OB_VEHICLES_WIDTH_1": "", |
||||
"OB_VEHICLES_MA_NOTES_WIDTH_1": "300px", |
||||
"OB_ORDNANCE_WIDTH_1": "", |
||||
"OB_ORDNANCE_MA_NOTES_WIDTH_1": "300px", |
||||
"OB_VEHICLES_WIDTH_2": "", |
||||
"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).", |
||||
"SCENARIO_THEATER": "ETO", |
||||
"PLAYER_1": "german", |
||||
"PLAYER_1_ELR": "4", |
||||
"PLAYER_1_SAN": "2", |
||||
"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.", |
||||
"No German unit may enter any hex of Board 4 prior to Turn 2.", |
||||
"All units of the 1st Panzer Army must enter in Convoy (E11.) on/after Turn 5 (some, none, or all may enter each Turn) along any single road hex along the east edge." |
||||
], |
||||
"OB_VEHICLES_1": [ |
||||
{ |
||||
"id": "ge/v:027", |
||||
"seq_id": 1, |
||||
"name": "PzKpfw IVH" |
||||
}, |
||||
{ |
||||
"id": "ge/v:030", |
||||
"seq_id": 2, |
||||
"name": "PzKpfw VG" |
||||
}, |
||||
{ |
||||
"id": "ge/v:072", |
||||
"seq_id": 3, |
||||
"name": "SPW 251/sMG" |
||||
}, |
||||
{ |
||||
"id": "ge/v:071", |
||||
"seq_id": 4, |
||||
"name": "SPW 251/1" |
||||
}, |
||||
{ |
||||
"id": "ge/v:116", |
||||
"seq_id": 5, |
||||
"name": "Buessing-NAG 4500" |
||||
}, |
||||
{ |
||||
"id": "ge/v:115", |
||||
"seq_id": 6, |
||||
"name": "Opel 6700 (Blitz)" |
||||
}, |
||||
{ |
||||
"id": "ge/v:118", |
||||
"seq_id": 7, |
||||
"name": "SdKfz 7" |
||||
} |
||||
], |
||||
"OB_VEHICLES_2": [ |
||||
{ |
||||
"id": "ru/v:027", |
||||
"seq_id": 1, |
||||
"name": "T-34/85" |
||||
}, |
||||
{ |
||||
"id": "ru/v:025", |
||||
"seq_id": 2, |
||||
"name": "T-34 M43" |
||||
} |
||||
], |
||||
"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).", |
||||
"width": "", |
||||
"id": 1 |
||||
} |
||||
], |
||||
"OB_SETUPS_1": [ |
||||
{ |
||||
"caption": "Enter on Turn 1 along the west edge of Boards 2/5 (see SSR 3)", |
||||
"width": "", |
||||
"id": 1 |
||||
}, |
||||
{ |
||||
"caption": "Enter per SSR 4", |
||||
"width": "200px", |
||||
"id": 2 |
||||
} |
||||
], |
||||
"OB_SETUPS_2": [ |
||||
{ |
||||
"caption": "Enter on Turn 1 along the north edge", |
||||
"width": "", |
||||
"id": 1 |
||||
} |
||||
], |
||||
"OB_NOTES_1": [], |
||||
"OB_NOTES_2": [], |
||||
"_app_version": "v1.10", |
||||
"_last_update_time": "2022-09-12T02:22:47.511Z", |
||||
"_creation_time": "2020-09-27T04:11:07.200Z" |
||||
} |
Before Width: | Height: | Size: 1.7 MiB After Width: | Height: | Size: 1.8 MiB |
After Width: | Height: | Size: 79 KiB |
@ -1 +1,173 @@ |
||||
{"SCENARIO_NAME":"The Streets Of Stalingrad","SCENARIO_ID":"ASL C","SCENARIO_LOCATION":"Stalingrad, Russia","SCENARIO_DATE":"1942-10-04","SCENARIO_WIDTH":"","VICTORY_CONDITIONS_WIDTH":"400px","SSR_WIDTH":"500px","OB_VEHICLES_WIDTH_1":"","OB_ORDNANCE_WIDTH_1":"","OB_VEHICLES_WIDTH_2":"","OB_ORDNANCE_WIDTH_2":"","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 ≥ 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>","PLAYER_1":"russian","PLAYER_1_ELR":"3","PLAYER_1_SAN":"6","PLAYER_2":"german","PLAYER_2_ELR":"4","PLAYER_2_SAN":"6","SSR":["Roll a die to determine who moves first.","Set up the forces of Scenario A prior to placing the units of Scenario B.","Each non-prisoner Russian unit is Fanatic (A10.8) while in building X3.","Building X3 is a Factory.","German armor may delay entry one Game Turn and thereafter enter on any southern or eastern mapboard hex.","Prior to play, both players may agree that if the game is a draw by the standard victory conditions, then the Russian loses unless he has a favorable 3:1 ratio of unbroken squads at the end of play."],"OB_VEHICLES_1":[{"name":"T-34 M43"},{"name":"T-34 M41"}],"OB_VEHICLES_2":[{"name":"StuG IIIG"},{"name":"StuG IIIB"}],"SCENARIO_NOTES":[{"caption":"Download the scenario card from <a href=\"http://www.multimanpublishing.com/Support/ASLASLSK/ASLOfficialDownloads/tabid/109/Default.aspx\">Multi-Man Publishing</a> (ASL Classic pack).","width":""}],"OB_SETUPS_1":[{"caption":"Set up in building N4","width":""},{"caption":"Set up in building J2","width":""},{"caption":"Set up in building M2","width":""},{"caption":"Set up in building N2","width":""},{"caption":"Set up in building F3","width":"180px"},{"caption":"Set up first in building X3","width":"190px"},{"caption":"Set up last in buildings P8, P5, Q4 and R1","width":""},{"caption":"Enter on Turn 2 on I1","width":""}],"OB_SETUPS_2":[{"caption":"Set up in building F5","width":""},{"caption":"Set up in building K5","width":""},{"caption":"Set up in building I7","width":""},{"caption":"Set up in building M7","width":"170px"},{"caption":"Set up in building M9","width":""},{"caption":"Set up in buildings AA4, CC3 and/or Y8","width":""},{"caption":"Set up in buildings U3, T4, R7 and/or T7","width":""},{"caption":"Set up in buildings Y8, CC7 and/or AA4","width":""},{"caption":"Enter on Turn 3 on Y10 <br>\nand/or GG5-GG6","width":""}],"OB_NOTES_1":[],"OB_NOTES_2":[]} |
||||
{ |
||||
"COMPASS": "up", |
||||
"SCENARIO_DATE": "1942-10-06", |
||||
"SCENARIO_WIDTH": "", |
||||
"ASA_ID": "56510", |
||||
"ROAR_ID": "127", |
||||
"PLAYERS_WIDTH": "", |
||||
"VICTORY_CONDITIONS_WIDTH": "450px", |
||||
"SSR_WIDTH": "500px", |
||||
"OB_VEHICLES_WIDTH_1": "", |
||||
"OB_VEHICLES_MA_NOTES_WIDTH_1": "300px", |
||||
"OB_ORDNANCE_WIDTH_1": "", |
||||
"OB_ORDNANCE_MA_NOTES_WIDTH_1": "300px", |
||||
"OB_VEHICLES_WIDTH_2": "", |
||||
"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>", |
||||
"SCENARIO_THEATER": "ETO", |
||||
"PLAYER_1": "russian", |
||||
"PLAYER_1_ELR": "3", |
||||
"PLAYER_1_SAN": "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.", |
||||
"Each non-prisoner Russian unit is Fanatic (A10.8) while in building X3.", |
||||
"Building X3 is a Factory.", |
||||
"German armor may delay entry one Game Turn and thereafter enter on any southern or eastern mapboard hex.", |
||||
"Prior to play, both players may agree that if the game is a draw by the standard victory conditions, then the Russian loses unless he has a favorable 3:1 ratio of unbroken squads at the end of play." |
||||
], |
||||
"OB_VEHICLES_1": [ |
||||
{ |
||||
"id": "ru/v:025", |
||||
"seq_id": 1, |
||||
"name": "T-34 M43" |
||||
}, |
||||
{ |
||||
"id": "ru/v:023", |
||||
"seq_id": 2, |
||||
"name": "T-34 M41" |
||||
} |
||||
], |
||||
"OB_VEHICLES_2": [ |
||||
{ |
||||
"id": "ge/v:037", |
||||
"seq_id": 1, |
||||
"name": "StuG IIIG" |
||||
}, |
||||
{ |
||||
"id": "ge/v:036", |
||||
"seq_id": 2, |
||||
"name": "StuG IIIB" |
||||
} |
||||
], |
||||
"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).", |
||||
"width": "", |
||||
"id": 1 |
||||
} |
||||
], |
||||
"OB_SETUPS_1": [ |
||||
{ |
||||
"caption": "Set up in building N4", |
||||
"width": "", |
||||
"id": 1 |
||||
}, |
||||
{ |
||||
"caption": "Set up in building J2", |
||||
"width": "", |
||||
"id": 2 |
||||
}, |
||||
{ |
||||
"caption": "Set up in building M2", |
||||
"width": "", |
||||
"id": 3 |
||||
}, |
||||
{ |
||||
"caption": "Set up in building N2", |
||||
"width": "", |
||||
"id": 4 |
||||
}, |
||||
{ |
||||
"caption": "Set up in building F3", |
||||
"width": "180px", |
||||
"id": 5 |
||||
}, |
||||
{ |
||||
"caption": "Set up first in building X3", |
||||
"width": "190px", |
||||
"id": 6 |
||||
}, |
||||
{ |
||||
"caption": "Set up last in buildings P8, P5, Q4 and R1", |
||||
"width": "", |
||||
"id": 7 |
||||
}, |
||||
{ |
||||
"caption": "Enter on Turn 2 on I1", |
||||
"width": "", |
||||
"id": 8 |
||||
} |
||||
], |
||||
"OB_SETUPS_2": [ |
||||
{ |
||||
"caption": "Set up in building F5", |
||||
"width": "", |
||||
"id": 1 |
||||
}, |
||||
{ |
||||
"caption": "Set up in building K5", |
||||
"width": "", |
||||
"id": 2 |
||||
}, |
||||
{ |
||||
"caption": "Set up in building I7", |
||||
"width": "", |
||||
"id": 3 |
||||
}, |
||||
{ |
||||
"caption": "Set up in building M7", |
||||
"width": "170px", |
||||
"id": 4 |
||||
}, |
||||
{ |
||||
"caption": "Set up in building M9", |
||||
"width": "", |
||||
"id": 5 |
||||
}, |
||||
{ |
||||
"caption": "Set up in buildings AA4, CC3 and/or Y8", |
||||
"width": "", |
||||
"id": 6 |
||||
}, |
||||
{ |
||||
"caption": "Set up in buildings U3, T4, R7 and/or T7", |
||||
"width": "", |
||||
"id": 7 |
||||
}, |
||||
{ |
||||
"caption": "Set up in buildings Y8, CC7 and/or AA4", |
||||
"width": "", |
||||
"id": 8 |
||||
}, |
||||
{ |
||||
"caption": "Enter on Turn 3 on Y10 <br>\nand/or GG5-GG6", |
||||
"width": "", |
||||
"id": 9 |
||||
} |
||||
], |
||||
"OB_NOTES_1": [], |
||||
"OB_NOTES_2": [], |
||||
"_app_version": "v1.10", |
||||
"_last_update_time": "2022-09-12T02:58:13.237Z", |
||||
"_creation_time": "2020-09-27T04:44:48.473Z" |
||||
} |
Before Width: | Height: | Size: 573 KiB After Width: | Height: | Size: 902 KiB |
After Width: | Height: | Size: 61 KiB |
@ -0,0 +1,221 @@ |
||||
#!/usr/bin/env python3 |
||||
""" Compile the application and create a release. """ |
||||
|
||||
import sys |
||||
import os |
||||
import shutil |
||||
import subprocess |
||||
import tempfile |
||||
import time |
||||
import datetime |
||||
import json |
||||
import re |
||||
import getopt |
||||
|
||||
from PyInstaller.__main__ import run as run_pyinstaller |
||||
|
||||
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) ) ) ) |
||||
|
||||
# --------------------------------------------------------------------- |
||||
|
||||
def get_git_info(): |
||||
"""Get the git branch/commit we're building from.""" |
||||
|
||||
# get the latest commit ID |
||||
proc = subprocess.run( |
||||
[ "git", "log" ], |
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE, encoding="utf-8", |
||||
check=True |
||||
) |
||||
buf = proc.stdout.split( "\n" )[0] |
||||
mo = re.search( r"^commit ([a-z0-9]+)$", buf ) |
||||
last_commit_id = mo.group(1) |
||||
|
||||
# get the current git branch |
||||
proc = subprocess.run( |
||||
[ "git", "branch" ], |
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE, encoding="utf-8", |
||||
check=True |
||||
) |
||||
lines = [ s for s in proc.stdout.split("\n") if s.startswith("* ") ] |
||||
if len(lines) != 1: |
||||
raise RuntimeError( "Can't parse git branch status." ) |
||||
branch_name = lines[0][2:] |
||||
if branch_name.startswith( "(HEAD detached at" ) and branch_name.endswith( ")" ): |
||||
branch_name = branch_name[18:-1] |
||||
|
||||
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:] ) |
After Width: | Height: | Size: 3.4 KiB |
@ -0,0 +1,107 @@ |
||||
#!/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:] ) |
@ -0,0 +1,207 @@ |
||||
""" 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,2 +1,3 @@ |
||||
[pytest] |
||||
addopts = --pylint |
||||
norecursedirs = _work_ |
||||
|
@ -0,0 +1,7 @@ |
||||
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 |
@ -0,0 +1,10 @@ |
||||
# python 3.11.4 |
||||
|
||||
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 |
@ -0,0 +1,396 @@ |
||||
#!/usr/bin/env bash |
||||
# Helper script that builds and launches the Docker container. |
||||
|
||||
# --------------------------------------------------------------------- |
||||
|
||||
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}" |
||||
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. |
||||
EOM |
||||
} |
||||
|
||||
# --------------------------------------------------------------------- |
||||
|
||||
main "$@" |
@ -0,0 +1,189 @@ |
||||
#!/usr/bin/env python3 |
||||
""" Manage VASL build files. """ |
||||
|
||||
import os |
||||
import zipfile |
||||
import itertools |
||||
|
||||
from lxml import etree |
||||
import click |
||||
|
||||
# --------------------------------------------------------------------- |
||||
|
||||
class BuildFile: |
||||
"""Wrapper around a VASL module's buildFile.""" |
||||
|
||||
def __init__( self, build_file ): |
||||
self.doc_root = etree.fromstring( build_file ) #pylint: disable=c-extension-no-member |
||||
self.attribs = self.doc_root.attrib |
||||
|
||||
def dump( self, line_nos=False, images=False ): |
||||
"""Dump the BuildFile.""" |
||||
|
||||
# dump the module header |
||||
click.echo( "Name: {}".format( self.attribs.get( "name" ) ) ) |
||||
click.echo( "Description: {}".format( self.attribs.get( "description" ) ) ) |
||||
click.echo( "Version: {}".format( self.attribs.get( "version" ) ) ) |
||||
click.echo( "VASSAL: {}".format( self.attribs.get( "VassalVersion" ) ) ) |
||||
click.echo( "Next slot ID: {}".format( self.attribs.get( "nextPieceSlotId" ) ) ) |
||||
click.echo() |
||||
|
||||
# initialize |
||||
opts = { "extract_images": images } |
||||
|
||||
def dump_node( node, depth=0 ): |
||||
"""Dump an XML node and its children.""" |
||||
|
||||
# dump each child node |
||||
for child in node: |
||||
|
||||
# get the attributes we want to dump |
||||
attribs = get_attrib_vals( child, opts ) |
||||
|
||||
if depth == 0: |
||||
# this is a top-level node, show it with a header |
||||
header = click.style( "===", fg="green" ) |
||||
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() |
||||
# dump any attributes |
||||
if attribs: |
||||
for key,val in attribs: |
||||
click.echo( "{} = {}".format( key, val ) ) |
||||
click.echo() |
||||
else: |
||||
# this a lower-level node, show it normally |
||||
val = click.style( child.tag, fg="yellow" ) |
||||
tab = " " * (depth-1) |
||||
click.echo( tab+val, nl=False ) |
||||
if line_nos: |
||||
click.echo( ":{}".format( click.style( str(child.sourceline), fg="cyan" ) ), nl=False ) |
||||
if attribs: |
||||
attribs = [ "{}={}".format( k, v ) for k,v in attribs ] |
||||
click.echo( ": {}".format( " ; ".join( attribs ) ) ) |
||||
else: |
||||
click.echo() |
||||
|
||||
# dump child nodes |
||||
dump_node( child, depth+1 ) |
||||
|
||||
if depth == 1 and len(list(node.getchildren())) > 0: #pylint: disable=len-as-condition |
||||
click.echo() |
||||
|
||||
# dump the XML document |
||||
dump_node( self.doc_root ) |
||||
|
||||
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - |
||||
|
||||
def _get_node_cdata( node, opts ): #pylint: disable=unused-argument |
||||
"""Get the CDATA for a node.""" |
||||
return "cdata", node.text |
||||
|
||||
def _get_pieceslot_images( node, opts ): |
||||
"""Get any image paths in a PieceSlot.""" |
||||
|
||||
# check if we need to do this |
||||
if not opts["extract_images"]: |
||||
return None, None |
||||
|
||||
# IMPORTANT! The data in the build file looks like a serialized object, so we use |
||||
# a bunch of heuristics to try to identify the fields we want :-/ This means that |
||||
# we might sometimes return the wrong results :-( |
||||
|
||||
# split the data into fields |
||||
val = node.text.replace( "\\/", "/" ) |
||||
fields = val.split( ";" ) # fields seem to be semicolon-separated |
||||
fields = [ f.split(",") for f in fields ] # fields can have comma-separated sub-fields |
||||
fields = [ f.strip() for f in itertools.chain(*fields) ] |
||||
fields = [ f for f in fields if f ] |
||||
|
||||
# identify fields that look like an image path |
||||
valid_prefixes = ( "ru/", "ge/", "am/", "br/", "it/", "ja/", "ch/", "sh/", "fr/", "al/", "ax/", "hu/", "fi/", |
||||
"po/", "ss/", # nb: for BFP |
||||
"nk/", # nb: for K:FW |
||||
) |
||||
def is_image_path( val ): |
||||
"""Check if a value looks like an image path.""" |
||||
if val.endswith( (".gif",".png") ): |
||||
return True |
||||
if val.startswith( valid_prefixes ): |
||||
return True |
||||
return False |
||||
fields = [ f for f in fields if is_image_path(f) ] |
||||
|
||||
# return the final results |
||||
return "images", ";".join(fields) if fields else None |
||||
|
||||
# which attributes to dump for each type of XML node in the build file |
||||
NODE_ATTRIBS_TO_DUMP = { |
||||
"VASL.build.module.ASLMap":[ "mapName" ], |
||||
"VASSAL.build.module.ChartWindow": [ "name" ], |
||||
"VASSAL.build.module.Map": [ "mapName"], |
||||
"VASSAL.build.module.PieceWindow": [ "name" ], |
||||
"VASSAL.build.widget.TabWidget": [ "entryName?" ], |
||||
"VASSAL.build.widget.ListWidget": [ "entryName?" ], |
||||
"VASSAL.build.widget.Chart": [ "chartName", "fileName" ], |
||||
"VASSAL.build.widget.PanelWidget": [ "entryName?", "nColumns" ], |
||||
"VASSAL.build.widget.BoxWidget": [ "entryName" ], |
||||
"VASSAL.build.widget.PieceSlot": [ "gpid", "entryName", _get_pieceslot_images ], |
||||
"VASSAL.build.module.PrototypeDefinition": [ "name" ], |
||||
"VASSAL.build.module.documentation.HelpFile": [ "title", "fileName" ], |
||||
"VASSAL.build.module.documentation.AboutScreen": [ "title", "fileName" ], |
||||
"VASSAL.build.module.documentation.BrowserHelpFile": [ "title", "fileName" ], |
||||
"option": [ "name" ], # nb: these appear under VASSAL.build.module.GlobalOptions |
||||
"entry": [ "name", _get_node_cdata ], # nb: these appear under VASL.build.module.map.MassRemover |
||||
} |
||||
|
||||
def get_attrib_vals( node, opts ): |
||||
"""Get the attribute values we're interested in from an XML node.""" |
||||
|
||||
# figure out which attributes we're interested in |
||||
attribs = NODE_ATTRIBS_TO_DUMP.get( node.tag, [] ) |
||||
if attribs == "*": |
||||
attribs = node.attrib.keys() |
||||
|
||||
# get the attribute values |
||||
def get_attr_val( attr ): |
||||
"""Get the value for the specified attribute.""" |
||||
if callable( attr ): |
||||
return attr( node , opts ) |
||||
if attr.endswith( "?" ): |
||||
# nb: this is an optional attribute (we don't show it if not present) |
||||
attr = attr[:-1] |
||||
return attr, node.attrib.get( attr ) |
||||
else: |
||||
# nb: we expect this attribute to be present, return a "missing" marker if it's not |
||||
return attr, node.attrib.get( attr, "???" ) |
||||
vals = [ get_attr_val(a) for a in attribs ] |
||||
|
||||
# return the final results |
||||
return [ (k,v) for k,v in vals if v is not None ] |
||||
|
||||
# --------------------------------------------------------------------- |
||||
|
||||
@click.command() |
||||
@click.argument( "input-file", type=click.File("rb") ) |
||||
@click.option( "-l","--line-nos", is_flag=True, help="Include line numbers for each XML node." ) |
||||
@click.option( "-i","--images", is_flag=True, help="Show images paths for each PieceSlot." ) |
||||
def main( input_file, line_nos, images ): |
||||
"""Dump a VASL build file.""" |
||||
|
||||
# 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" ) |
||||
else: |
||||
# nope - read the build file from the specified file |
||||
build_file = input_file.read() |
||||
|
||||
# load and dump the build file |
||||
build_file = BuildFile( build_file ) |
||||
build_file.dump( line_nos=line_nos, images=images ) |
||||
|
||||
# --------------------------------------------------------------------- |
||||
|
||||
if __name__ == "__main__": |
||||
main() #pylint: disable=no-value-for-parameter |
@ -0,0 +1,73 @@ |
||||
"""Implement the "about" dialog.""" |
||||
|
||||
import sys |
||||
import os |
||||
import time |
||||
import io |
||||
import re |
||||
|
||||
from PyQt5 import uic, QtCore |
||||
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 |
||||
|
||||
# --------------------------------------------------------------------- |
||||
|
||||
class AboutDialog( QDialog ): |
||||
"""Show the about box.""" |
||||
|
||||
def __init__( self, parent ) : |
||||
|
||||
# initialize |
||||
super().__init__( parent=parent ) |
||||
|
||||
# initialize the UI |
||||
base_dir = os.path.split( __file__ )[0] |
||||
fname = os.path.join( base_dir, "ui/about.ui" ) |
||||
uic.loadUi( fname, self ) |
||||
self.setFixedSize( self.size() ) |
||||
self.close_button.clicked.connect( self.on_close ) |
||||
|
||||
# initialize the UI |
||||
if IS_FROZEN: |
||||
dname = os.path.join( sys._MEIPASS, "vasl_templates/webapp" ) #pylint: disable=no-member,protected-access |
||||
else: |
||||
dname = os.path.join( os.path.split(__file__)[0], "webapp" ) |
||||
fname = os.path.join( dname, "static/images/app.ico" ) |
||||
self.app_icon.setPixmap( QIcon( fname ).pixmap(64,64) ) |
||||
self.app_icon.mouseReleaseEvent = self.on_app_icon_clicked |
||||
self.app_icon.setCursor( QCursor( QtCore.Qt.PointingHandCursor ) ) |
||||
|
||||
# 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() |
||||
buf.write( "Built {}".format( |
||||
time.strftime( "%d %B %Y %H:%M", time.localtime( build_info["timestamp"] ) ) |
||||
) ) |
||||
if "git_info" in build_info: |
||||
buf.write( " <small><tt>({})</tt></small>".format( build_info["git_info"] ) ) |
||||
buf.write( "." ) |
||||
self.build_info.setText( buf.getvalue() ) |
||||
else: |
||||
self.build_info.setText( "" ) |
||||
mo = re.search( r"^https?://(.+)", APP_HOME_URL ) |
||||
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 |
||||
"""Click handler.""" |
||||
QDesktopServices.openUrl( QUrl( APP_HOME_URL ) ) |
||||
|
||||
def on_close( self ): |
||||
"""Close the dialog.""" |
||||
self.close() |
@ -0,0 +1,96 @@ |
||||
""" Manage loading and saving files. """ |
||||
|
||||
import os |
||||
|
||||
from PyQt5.QtWidgets import QFileDialog |
||||
|
||||
# --------------------------------------------------------------------- |
||||
|
||||
# NOTE: While loading/saving files works fine when handled by the embedded browser, |
||||
# we can't get the full path of the file loaded (because of browser security). |
||||
# This means that we can't do things like default to saving a scenario to the same file |
||||
# it was loaded from, or retrying a failed save. This is such a lousy UX, |
||||
# we handle load/save operations ourself, where we can manage things like this. |
||||
|
||||
class FileDialog: |
||||
"""Manage loading and saving files.""" |
||||
|
||||
def __init__( self, parent, object_name, default_extn, filters, default_fname ): |
||||
self.parent = parent |
||||
self.object_name = object_name |
||||
self.default_extn = default_extn |
||||
self.filters = filters |
||||
self.curr_fname = default_fname |
||||
# 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.""" |
||||
|
||||
# ask the user which file to load |
||||
fname, _ = QFileDialog.getOpenFileName( |
||||
self.parent, "Load {}".format( self.object_name ), |
||||
self._get_start_path(), |
||||
self.filters |
||||
) |
||||
if not fname: |
||||
return None |
||||
|
||||
# load the file |
||||
try: |
||||
with open( fname, "rb" ) as fp: |
||||
data = fp.read() |
||||
except Exception as ex: #pylint: disable=broad-except |
||||
self.parent.showErrorMsg( "Can't load the {}:\n\n{}".format( self.object_name, ex ) ) |
||||
return None |
||||
if not binary: |
||||
data = data.decode( "utf-8" ) |
||||
self.curr_fname = fname |
||||
self._curr_dir = os.path.dirname( fname ) |
||||
|
||||
return data |
||||
|
||||
def save_file( self, data ): |
||||
"""Save data to a file.""" |
||||
|
||||
# initialize |
||||
if isinstance( data, str ): |
||||
data = data.encode( "utf-8" ) |
||||
|
||||
while True: # nb: keep trying until the save succeeds or the user cancels the operation |
||||
|
||||
# ask the user where to save the file |
||||
fname, _ = QFileDialog.getSaveFileName( |
||||
self.parent, "Save {}".format( self.object_name ), |
||||
self._get_start_path(), |
||||
self.filters |
||||
) |
||||
if not fname: |
||||
return False |
||||
|
||||
# check the file extension |
||||
extn = os.path.splitext( fname )[1] |
||||
if not extn: |
||||
fname += self.default_extn |
||||
elif fname.endswith( "." ): |
||||
fname = fname[:-1] |
||||
|
||||
# save the file |
||||
try: |
||||
with open( fname, "wb", ) as fp: |
||||
fp.write( data ) |
||||
except Exception as ex: #pylint: disable=broad-except |
||||
self.parent.showErrorMsg( "Can't save the {}:\n\n{}".format( self.object_name, ex ) ) |
||||
continue |
||||
self.curr_fname = fname |
||||
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 |
After Width: | Height: | Size: 3.8 KiB |
@ -0,0 +1,261 @@ |
||||
"""Implement the "server settings" dialog.""" |
||||
|
||||
import os |
||||
import shutil |
||||
import logging |
||||
import traceback |
||||
|
||||
from PyQt5 import uic |
||||
from PyQt5.QtWidgets import QDialog, QFileDialog, QGroupBox |
||||
from PyQt5.QtGui import QIcon |
||||
|
||||
from vasl_templates.main import app_settings |
||||
from vasl_templates.main_window import MainWindow |
||||
from vasl_templates.utils import show_msg_store |
||||
from vasl_templates.webapp.vassal import VassalShim, SUPPORTED_VASSAL_VERSIONS_DISPLAY |
||||
from vasl_templates.webapp.vasl_mod import set_vasl_mod, SUPPORTED_VASL_MOD_VERSIONS_DISPLAY |
||||
from vasl_templates.webapp.utils import MsgStore |
||||
|
||||
# --------------------------------------------------------------------- |
||||
|
||||
_EXE_FSPEC = [ "Executable files (*.exe)" ] if os.name == "nt" else [] |
||||
|
||||
SERVER_SETTINGS = { |
||||
"vassal-dir": { "type": "dir", "name": "VASSAL directory" }, |
||||
"vasl-mod": { "type": "file", "name": "VASL module", "fspec": ["VASL module files (*.vmod)"] }, |
||||
"vasl-extns-dir": { "type": "dir", "name": "VASL extensions directory" }, |
||||
"boards-dir": { "type": "dir", "name": "VASL boards directory" }, |
||||
"java-path": { "type": "file", "name": "Java executable", "allow_on_path": True, "fspec": _EXE_FSPEC }, |
||||
"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 }, |
||||
} |
||||
|
||||
# --------------------------------------------------------------------- |
||||
|
||||
class ServerSettingsDialog( QDialog ): |
||||
"""Let the user configure the server settings.""" |
||||
|
||||
def __init__( self, parent ) : |
||||
|
||||
# initialize |
||||
super().__init__( parent=parent ) |
||||
|
||||
# initialize the UI |
||||
base_dir = os.path.split( __file__ )[0] |
||||
dname = os.path.join( base_dir, "ui/server_settings.ui" ) |
||||
uic.loadUi( dname, self ) |
||||
self.setFixedSize( self.size() ) |
||||
|
||||
# initialize the UI |
||||
for key in SERVER_SETTINGS: |
||||
btn = getattr( self, "select_{}_button".format( key.replace("-","_") ), None ) |
||||
if btn: |
||||
btn.setIcon( QIcon( os.path.join( base_dir, "resources/file_browser.png" ) ) ) |
||||
self.vassal_dir.setToolTip( "Supported versions: {}".format( SUPPORTED_VASSAL_VERSIONS_DISPLAY ) ) |
||||
self.vasl_mod.setToolTip( "Supported versions: {}".format( SUPPORTED_VASL_MOD_VERSIONS_DISPLAY ) ) |
||||
self.webdriver_path.setToolTip( "Configure either geckodriver or chromedriver here." ) |
||||
|
||||
# initialize the UI |
||||
for attr in dir(self): |
||||
attr = getattr( self, attr ) |
||||
if isinstance( attr, QGroupBox ): |
||||
attr.setStyleSheet( "QGroupBox { font-weight: bold; } " ) |
||||
|
||||
# initialize click handlers |
||||
def make_click_handler( func, *args ): #pylint: disable=missing-docstring |
||||
# FUDGE! Python looks up variables passed in to a lambda when it is *invoked*, so we need |
||||
# this intermediate function to create lambda's with their arguments at *creation time*. |
||||
return lambda: func( *args ) |
||||
for key,vals in SERVER_SETTINGS.items(): |
||||
key2 = key.replace( "-", "_" ) |
||||
btn = getattr( self, "select_{}_button".format( key2 ), None ) |
||||
if btn: |
||||
ctrl = self._get_control( key ) |
||||
if vals["type"] == "dir": |
||||
func = make_click_handler( self._on_select_dir, ctrl, vals["name"] ) |
||||
elif vals["type"] == "file": |
||||
func = make_click_handler( self._on_select_file, ctrl, vals["name"], vals["fspec"] ) |
||||
else: |
||||
assert False |
||||
btn.clicked.connect( func ) |
||||
self.ok_button.clicked.connect( self.on_ok ) |
||||
self.cancel_button.clicked.connect( self.on_cancel ) |
||||
|
||||
# initialize handlers |
||||
self.chapter_h_notes_dir.textChanged.connect( self.on_chapter_h_notes_dir_changed ) |
||||
|
||||
# load the current server settings |
||||
for key in SERVER_SETTINGS: |
||||
val = app_settings.value( "ServerSettings/"+key ) or "" |
||||
ctrl = self._get_control( key ) |
||||
ctrl.setText( str(val).strip() ) |
||||
|
||||
def _on_select_dir( self, ctrl, name ): |
||||
"""Ask the user to select a directory.""" |
||||
dname = QFileDialog.getExistingDirectory( |
||||
self, "Select {}".format( name ), |
||||
ctrl.text(), |
||||
QFileDialog.ShowDirsOnly |
||||
) |
||||
if dname: |
||||
ctrl.setText( dname ) |
||||
|
||||
def _on_select_file( self, ctrl, name, fspec ): |
||||
"""Ask the user to select a file.""" |
||||
assert isinstance( fspec, list ) |
||||
fspec = fspec[:] |
||||
fspec.append( "All files ({})".format( "*.*" if os.name == "nt" else "*" ) ) |
||||
fname = QFileDialog.getOpenFileName( |
||||
self, "Select {}".format( name ), |
||||
ctrl.text(), |
||||
";;".join( fspec ) |
||||
)[0] |
||||
if fname: |
||||
ctrl.setText( fname ) |
||||
|
||||
def on_ok( self ): |
||||
"""Accept the new server settings.""" |
||||
|
||||
# save a copy of the current settings |
||||
prev_settings = { |
||||
key: app_settings.value( "ServerSettings/"+key, "" ) |
||||
for key in SERVER_SETTINGS |
||||
} |
||||
|
||||
# unload the dialog |
||||
# NOTE: Typing an unknown path into QFileDialog.getExistingDirectory() causes that directory |
||||
# to be created!?!? It doesn't really matter, since the user could have also manually typed |
||||
# an unknown path into an edit box, so we need to validate everything anyway. |
||||
new_settings = {} |
||||
for key, vals in SERVER_SETTINGS.items(): |
||||
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 ] |
||||
val = func( ctrl, *args, **kwargs ) |
||||
if val is None: |
||||
# nb: something failed validation, an error message has already been shown |
||||
return |
||||
new_settings[ key ] = val |
||||
|
||||
# install the new settings |
||||
for key in SERVER_SETTINGS: |
||||
app_settings.setValue( "ServerSettings/"+key, new_settings[key] ) |
||||
try: |
||||
install_server_settings( False ) |
||||
except Exception as ex: #pylint: disable=broad-except |
||||
logging.error( traceback.format_exc() ) |
||||
MainWindow.showErrorMsg( "Couldn't install the server settings:\n\n{}".format( ex ) ) |
||||
# rollback the changes |
||||
for key,val in prev_settings.items(): |
||||
app_settings.setValue( "ServerSettings/"+key, val ) |
||||
try: |
||||
install_server_settings( False ) |
||||
except Exception as ex2: #pylint: disable=broad-except |
||||
logging.error( traceback.format_exc() ) |
||||
MainWindow.showErrorMsg( "Couldn't rollback the server settings:\n\n{}".format( ex2 ) ) |
||||
return |
||||
self.close() |
||||
|
||||
# check if any key settings were changed |
||||
KEY_SETTINGS = [ "vassal-dir", "vasl-mod", "vasl-extns-dir", "chapter-h-notes-dir" ] |
||||
changed = [ |
||||
key for key in KEY_SETTINGS |
||||
if app_settings.value( "ServerSettings/"+key, "" ) != prev_settings[key] |
||||
] |
||||
if len(changed) == 1: |
||||
MainWindow.showInfoMsg( "The {} was changed - you should restart the program.".format( |
||||
SERVER_SETTINGS[ changed[0] ][ "name" ] |
||||
) ) |
||||
elif len(changed) > 1: |
||||
MainWindow.showInfoMsg( "Some key settings were changed - you should restart the program." ) |
||||
|
||||
def on_cancel( self ): |
||||
"""Cancel the dialog.""" |
||||
self.close() |
||||
|
||||
def _update_ui( self ): |
||||
"""Update the UI.""" |
||||
rc = self.chapter_h_notes_dir.text().strip() != "" |
||||
self.chapter_h_image_scaling_label.setEnabled( rc ) |
||||
self.chapter_h_image_scaling_label2.setEnabled( rc ) |
||||
self.chapter_h_image_scaling.setEnabled( rc ) |
||||
|
||||
def on_chapter_h_notes_dir_changed( self, val ): #pylint: disable=unused-argument |
||||
"""Called when the Chapter H notes directory is changed.""" |
||||
self._update_ui() |
||||
|
||||
@staticmethod |
||||
def _unload_dir( ctrl, name, allow_urls=False ): |
||||
"""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() |
||||
return None |
||||
return dname |
||||
|
||||
@staticmethod |
||||
def _unload_file( ctrl, name, allow_on_path=False ): |
||||
"""Unload and validate a file path.""" |
||||
fname = ctrl.text().strip() |
||||
def is_valid( fname ): #pylint: disable=missing-docstring |
||||
if not os.path.isabs(fname) and allow_on_path: |
||||
return shutil.which( fname ) is not None |
||||
return os.path.isfile( fname ) |
||||
if fname and not is_valid(fname): |
||||
if not os.path.isabs(fname) and allow_on_path: |
||||
MainWindow.showErrorMsg( "Can't find the {} on the PATH:\n {}".format( name, fname ) ) |
||||
else: |
||||
MainWindow.showErrorMsg( "Can't find the {}:\n {}".format( name, fname ) ) |
||||
ctrl.setFocus() |
||||
return None |
||||
return fname |
||||
|
||||
@staticmethod |
||||
def _unload_int( ctrl, name ): |
||||
"""Unload and validate an integer value.""" |
||||
val = ctrl.text().strip() |
||||
if val and not val.isdigit(): |
||||
MainWindow.showErrorMsg( "{} must be a numeric value.".format( name ) ) |
||||
ctrl.setFocus() |
||||
return None |
||||
return val |
||||
|
||||
def _get_control( self, key ): |
||||
"""Return the UI control for the specified server setting.""" |
||||
return getattr( self, key.replace("-","_") ) |
||||
|
||||
# --------------------------------------------------------------------- |
||||
|
||||
def install_server_settings( is_startup ): |
||||
"""Install the server settings.""" |
||||
|
||||
# install the server settings |
||||
from vasl_templates.webapp import app |
||||
for key in SERVER_SETTINGS: |
||||
key2 = key.replace( "-", "_" ).upper() |
||||
app.config[ key2 ] = app_settings.value( "ServerSettings/"+key ) |
||||
|
||||
# initialize |
||||
if is_startup: |
||||
msg_store = None # nb: we let the web page show startup messages |
||||
else: |
||||
msg_store = MsgStore() |
||||
|
||||
# load the VASL module |
||||
fname = app_settings.value( "ServerSettings/vasl-mod" ) |
||||
set_vasl_mod( fname, msg_store ) |
||||
|
||||
# check the VASSAL version |
||||
VassalShim.check_vassal_version( msg_store ) |
||||
|
||||
# show any messages |
||||
if msg_store: |
||||
show_msg_store( msg_store ) |
@ -0,0 +1,64 @@ |
||||
#!/usr/bin/env python3 |
||||
""" Check how scenarios at the ASL Scenario Archive are connected to those at ROAR. """ |
||||
|
||||
import sys |
||||
import json |
||||
|
||||
from vasl_templates.webapp.scenarios import _match_roar_scenario, \ |
||||
_asa_scenarios, _build_asa_scenario_index, _roar_scenarios, _build_roar_scenario_index |
||||
|
||||
# --------------------------------------------------------------------- |
||||
|
||||
def asa_string( s ): |
||||
"""Return an ASL Scenario Archive scenario as a string.""" |
||||
return "[{}] {} ({})".format( |
||||
s["scenario_id"], s.get("title"), s.get("sc_id") |
||||
) |
||||
|
||||
def roar_string( s ): |
||||
"""Return ROAR scenario as a string.""" |
||||
return "[{}] {} ({})".format( |
||||
s["roar_id"], s.get("name"), s.get("scenario_id") |
||||
) |
||||
|
||||
# --------------------------------------------------------------------- |
||||
|
||||
# load the ASL Scenario Archive scenarios |
||||
fname = sys.argv[1] |
||||
with open( fname, "r", encoding="utf-8" ) as fp: |
||||
asa_data = json.load( fp ) |
||||
_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 ) |
||||
_build_roar_scenario_index( _roar_scenarios, roar_data, None ) |
||||
|
||||
# try to connect each ASA scenario to ROAR |
||||
exact_matches, multiple_matches, unmatched = [], [], [] |
||||
for scenario in asa_data["scenarios"]: |
||||
matches = _match_roar_scenario( scenario ) |
||||
if not matches: |
||||
unmatched.append( scenario ) |
||||
elif len(matches) == 1: |
||||
exact_matches.append( scenario ) |
||||
else: |
||||
multiple_matches.append( [ scenario, matches ] ) |
||||
|
||||
# output the results |
||||
print( "ASL Scenario Archive scenarios: {}".format( len(asa_data["scenarios"]) ) ) |
||||
print() |
||||
print( "Exact matches: {}".format( len(exact_matches) ) ) |
||||
print() |
||||
print( "Multiple matches: {}".format( len(multiple_matches) ) ) |
||||
if multiple_matches: |
||||
for scenario,matches in multiple_matches: |
||||
print( " {}:".format( asa_string(scenario) ) ) |
||||
for match in matches: |
||||
print( " - {}".format( roar_string( match ) ) ) |
||||
print() |
||||
print( "Unmatched: {}".format( len(unmatched) ) ) |
||||
if unmatched: |
||||
for scenario in unmatched: |
||||
print( " {}".format( asa_string(scenario) ) ) |
@ -0,0 +1,268 @@ |
||||
#!/usr/bin/env python3 |
||||
"""Dump the log file analysis reports generated by the VASSAL shim.""" |
||||
|
||||
import os |
||||
import itertools |
||||
|
||||
import click |
||||
import tabulate |
||||
|
||||
from vasl_templates.webapp.lfa import parse_analysis_report, DEFAULT_LFA_DICE_HOTNESS_WEIGHTS |
||||
|
||||
EXPECTED_DISTRIB = { |
||||
"DR": { 2: 2.8, 3: 5.6, 4: 8.3, 5: 11.1, 6: 13.9, 7: 16.7, 8: 13.9, 9: 11.1, 10: 8.3, 11: 5.6, 12: 2.8 }, |
||||
"dr": { 1: 16.7, 2: 16.7, 3: 16.7, 4: 16.7, 5: 16.7, 6: 16.7 }, |
||||
} |
||||
|
||||
# --------------------------------------------------------------------- |
||||
|
||||
@click.command() |
||||
@click.option( "--file","-f","fname", required=True, help="Log file analysis report." ) |
||||
@click.option( "--players/--no-players","-p", help="Dump the players." ) |
||||
@click.option( "--events/--no-events","-e", help="Dump the events." ) |
||||
@click.option( "--roll-type","-r","roll_type", help="Roll type filter (e.g. IFT or MC)." ) |
||||
@click.option( "--window","-w","window_size", default=1, help="Moving average window size." ) |
||||
def main( fname, players, events, roll_type, window_size ): |
||||
"""Dump a Log File Analysis report (generated by the VASSAL shim).""" |
||||
|
||||
# initialize |
||||
if not os.path.isfile( fname ): |
||||
raise RuntimeError( "Can't find the report file: {}".format( fname ) ) |
||||
|
||||
# parse the report |
||||
report = parse_analysis_report( fname ) |
||||
|
||||
# dump each log file |
||||
for log_file in report["logFiles"]: |
||||
|
||||
# output a header for the next log file |
||||
print( "=== {} {}".format( log_file["filename"], 80*"=" )[ :80 ] ) |
||||
print() |
||||
|
||||
# dump the scenario details |
||||
scenario_name = log_file["scenario"].get( "scenarioName" ) |
||||
if scenario_name: |
||||
print( "Scenario: {}".format( scenario_name ), end="" ) |
||||
scenario_id = log_file["scenario"].get( "scenarioId" ) |
||||
if scenario_id: |
||||
print( " ({})".format( scenario_id ), end="" ) |
||||
print() |
||||
|
||||
# dump the players |
||||
if players: |
||||
print( "Players:" ) |
||||
max_id_len = max( len(k) for k in report["players"] ) |
||||
fmt = "- {:%d} = {}" % max_id_len |
||||
for player_id,player_name in report["players"].items(): |
||||
print( fmt.format( player_id, player_name ) ) |
||||
|
||||
# dump the DR/dr distributions |
||||
dump_distrib( report["players"], log_file, roll_type ) |
||||
print() |
||||
|
||||
# dump the time-plot |
||||
if events or roll_type: |
||||
dump_time_plot( report["players"], log_file, roll_type, window_size ) |
||||
|
||||
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - |
||||
|
||||
def dump_distrib( players, log_file, roll_type ): #pylint: disable=too-many-locals,too-many-branches,too-many-statements |
||||
"""Dump the DR/dr distributions.""" |
||||
|
||||
# initialize |
||||
stats = { p: { |
||||
"DR": { "nRolls": 0, "rollTotal": 0 }, |
||||
"dr": { "nRolls": 0, "rollTotal": 0 }, |
||||
} for p in players |
||||
} |
||||
distrib = { p: { |
||||
"DR": { k: 0 for k in range(2,12+1) }, |
||||
"dr": { k: 0 for k in range(1,6+1) }, |
||||
} for p in players |
||||
} |
||||
|
||||
# process events |
||||
for evt in log_file["events"]: |
||||
|
||||
# check if we should process the next event |
||||
if evt["eventType"] != "roll": |
||||
continue |
||||
if roll_type and evt["rollType"].lower() != roll_type.lower(): |
||||
continue |
||||
|
||||
# update the stats |
||||
player_id = evt["playerId"] |
||||
key = "DR" if isinstance( evt["rollValue"], list ) else "dr" |
||||
stats[ player_id ][ key ][ "nRolls" ] += 1 |
||||
val = roll_total( evt["rollValue"] ) |
||||
stats[ player_id ][ key ][ "rollTotal" ] += val |
||||
distrib[ player_id ][ key ][ val ] += 1 |
||||
|
||||
# calculate averages |
||||
avg = lambda x, y: x / y if y != 0 else 0 |
||||
for player_id in players: |
||||
for key in ["DR","dr"]: |
||||
stats[ player_id ][ key ][ "rollAverage" ] = avg( |
||||
stats[player_id][key].pop("rollTotal"), |
||||
stats[player_id][key]["nRolls"] |
||||
) |
||||
|
||||
# calculate chi-squared and hotness |
||||
for player_id in players: |
||||
for key in ["DR","dr"]: |
||||
stats[ player_id ][ key ][ "chiSquared" ] = chi_squared( |
||||
distrib[player_id][ key ], |
||||
EXPECTED_DISTRIB[ key ] |
||||
) |
||||
stats[ player_id ][ key ][ "hotness" ] = hotness( |
||||
distrib[player_id][ key ], |
||||
EXPECTED_DISTRIB[ key ], |
||||
DEFAULT_LFA_DICE_HOTNESS_WEIGHTS[ key ], |
||||
) |
||||
|
||||
# output the results |
||||
for key in ["dr","DR"]: |
||||
print() |
||||
print( "=== {} distribution ===".format( key ) ) |
||||
vals = range(2,12+1) if key == "DR" else range(1,6+1) |
||||
results = [ itertools.chain( [""], vals, ["total","average","chi2","hotness"] ) ] |
||||
total_rolls = sum( stats[p][key]["nRolls"] for p in players ) |
||||
for player_id,player_name in players.items(): |
||||
# add a row for the stats |
||||
row = [ player_name ] |
||||
has_vals = False |
||||
for val in vals: |
||||
nRolls = distrib[player_id][key][val] |
||||
if nRolls != 0: |
||||
row.append( nRolls ) |
||||
has_vals = True |
||||
else: |
||||
row.append( "" ) |
||||
val2 = stats[player_id][key]["nRolls"] |
||||
val2a = val2 / total_rolls if total_rolls != 0 else 0 |
||||
row.append( "{} ({}%)".format( val2, int(100*val2a+0.5) ) ) |
||||
row.append( fpfmt( stats[player_id][key]["rollAverage"], 1 ) ) |
||||
row.append( fpfmt( stats[player_id][key]["chiSquared"], 3 ) ) |
||||
results.append( row ) |
||||
# add a row for the averages |
||||
if has_vals: |
||||
row = [ "" ] |
||||
for val in vals: |
||||
nRolls = distrib[player_id][key][val] |
||||
if nRolls: |
||||
val2 = avg( distrib[player_id][key][val], stats[player_id][key]["nRolls"] ) |
||||
row.append( fpfmt( 100*val2, 1 ) ) |
||||
else: |
||||
row.append( "" ) |
||||
results.append( row ) |
||||
# add a row for the dice hotness |
||||
row = [ "" ] |
||||
partials = stats[ player_id ][ key ][ "hotness" ] |
||||
if partials: |
||||
for val in partials: |
||||
row.append( fpfmt( val, 3 ) ) |
||||
row.extend( [ "", "", "" ] ) |
||||
row.append( fpfmt( sum(partials), 3 ) ) |
||||
results.append( row ) |
||||
print( tabulate.tabulate( results, headers="firstrow" ) ) |
||||
|
||||
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - |
||||
|
||||
def dump_time_plot( players, log_file, roll_type, window_size ): |
||||
"""Dump the time-plot values.""" |
||||
|
||||
# initialize |
||||
rolls = [] |
||||
windows = { p: [] for p in players } |
||||
|
||||
def dump_rolls(): |
||||
"""Dump the buffered ROLL events.""" |
||||
print( tabulate.tabulate( rolls, tablefmt="plain" ) ) |
||||
|
||||
def onTurnTrack( evt ): #pylint: disable=unused-variable,possibly-unused-variable |
||||
"""Process a TURN TRACK event.""" |
||||
nonlocal rolls |
||||
if rolls: |
||||
dump_rolls() |
||||
rolls = [] |
||||
print() |
||||
print( "--- {} Turn {} {} ---".format( evt["side"], evt["turnNo"], evt["phase"] ) ) |
||||
print() |
||||
|
||||
def onRoll( evt ) : #pylint: disable=unused-variable,possibly-unused-variable |
||||
"""Process a ROLL event""" |
||||
# check if we should process this ROLL event |
||||
if roll_type: |
||||
if evt["rollType"].lower() != roll_type.lower(): |
||||
return |
||||
player_id = evt[ "playerId" ] |
||||
if window_size == 1: |
||||
# add the raw roll |
||||
if isinstance( evt["rollValue"], int ): |
||||
val = evt["rollValue"] |
||||
else: |
||||
val = ", ".join( str(v) for v in evt["rollValue"] ) |
||||
rolls.append( [ players[player_id], evt["rollType"], val ] ) |
||||
else: |
||||
# add the moving average |
||||
windows[ player_id ].append( roll_total( evt["rollValue"] ) ) |
||||
if len(windows[player_id]) < window_size: |
||||
return |
||||
val = sum( windows[player_id] ) / len(windows[player_id]) |
||||
del windows[player_id][0] |
||||
rolls.append( [ players[player_id], val ] ) |
||||
|
||||
# process events |
||||
print( "=== EVENTS ===" ) |
||||
print() |
||||
for evt in log_file["events"]: |
||||
eventType = evt["eventType"] |
||||
locals()[ "on" + eventType[0].upper() + eventType[1:] ]( evt ) |
||||
if rolls: |
||||
dump_rolls() |
||||
|
||||
# --------------------------------------------------------------------- |
||||
|
||||
def chi_squared( observed, expected ): |
||||
"""Calculate the chi-squared for a set of values.""" |
||||
nRolls = sum( observed.values() ) |
||||
if nRolls == 0: |
||||
return None |
||||
assert observed.keys() == expected.keys() |
||||
return sum( |
||||
( observed[val]/nRolls - expected[val]/100 ) ** 2 / (expected[val]/100) |
||||
for val in expected |
||||
) |
||||
|
||||
def hotness( observed, expected, weights ): |
||||
"""Calculate the hotness for a set of values.""" |
||||
nRolls = sum( observed.values() ) |
||||
if nRolls == 0: |
||||
return None |
||||
assert observed.keys() == expected.keys() == weights.keys() |
||||
partials = [] |
||||
sign = lambda val: -1 if val < 0 else +1 |
||||
for val in expected: |
||||
diff = observed[val]/nRolls - expected[val]/100 |
||||
partials.append( sign(diff) * diff**2 * weights[val] / (expected[val]/100) ) |
||||
return partials |
||||
|
||||
def roll_total( roll ): |
||||
"""Calculate the total of a roll.""" |
||||
if isinstance( roll, list ): |
||||
assert all( isinstance(r,int) for r in roll ) |
||||
return sum( roll ) |
||||
else: |
||||
assert isinstance( roll, int ) |
||||
return roll |
||||
|
||||
def fpfmt( val, nDigits ): |
||||
"""Format a floating point number.""" |
||||
if val is None: |
||||
return "-" |
||||
return ("{:.%df}" % nDigits).format( val ) |
||||
|
||||
# --------------------------------------------------------------------- |
||||
|
||||
if __name__ == "__main__": |
||||
main() #pylint: disable=no-value-for-parameter |
@ -0,0 +1,83 @@ |
||||
#!/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( "}" ) |
@ -0,0 +1,301 @@ |
||||
#!/usr/bin/env python3 |
||||
""" Create placeholder files for the Chapter H notes. """ |
||||
|
||||
import os |
||||
import zipfile |
||||
import json |
||||
import re |
||||
import glob |
||||
|
||||
import click |
||||
|
||||
nationalities = None |
||||
|
||||
# --------------------------------------------------------------------- |
||||
|
||||
@click.command() |
||||
@click.option( "--output","-o", "output_fname", help="Output ZIP file to generate." ) |
||||
def main( output_fname ): # pylint: disable=too-many-locals,too-many-branches |
||||
"""Create a ZIP file with placeholder files for each Chapter H note and multi-applicable note.""" |
||||
|
||||
def log( fmt, *args ): #pylint: disable=missing-docstring |
||||
print( fmt.format( *args ) ) |
||||
return make_chapter_h_placeholders( output_fname, log=log ) |
||||
|
||||
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - |
||||
|
||||
def make_chapter_h_placeholders( output_fname, log=None \ |
||||
): #pylint: disable=too-many-locals,too-many-statements,too-many-branches |
||||
"""Create a ZIP file with placeholder files for each Chapter H note and multi-applicable note.""" |
||||
|
||||
# initialize |
||||
if not output_fname: |
||||
raise RuntimeError( "Output ZIP file not specified." ) |
||||
if not log: |
||||
def log_nothing( fmt, *args ): #pylint: disable=missing-docstring,unused-argument |
||||
pass |
||||
log = log_nothing |
||||
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 ) |
||||
|
||||
# load the vehicle/ordnance data files |
||||
base_dir = os.path.join( os.path.split(__file__)[0], "../webapp/data/" ) |
||||
for vo_type in ("vehicles","ordnance"): |
||||
dname = os.path.join( base_dir, vo_type ) |
||||
for root,_,fnames in os.walk( dname ): |
||||
for fname in fnames: |
||||
fname = os.path.join( root, fname ) |
||||
if os.path.splitext( fname )[1] != ".json": |
||||
continue |
||||
if os.path.splitext( fname )[0].endswith( ".lend-lease" ): |
||||
# NOTE: Doing this means we will miss any pieces explicitly defined in a lend-lease file |
||||
# (instead of being copied from an existing piece), but we can live with that... :-/ |
||||
continue |
||||
dname2, fname2 = os.path.split( fname ) |
||||
if os.path.split( dname2 )[1] == "kfw": |
||||
continue # nb: we do these files later |
||||
nat = os.path.splitext( fname2 )[0] |
||||
if nat == "common": |
||||
nat = os.path.split( dname2 )[1] |
||||
if nat == "free-french" or nat.startswith("kfw-"): |
||||
continue |
||||
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 } |
||||
|
||||
# insert the K:FW vehicles/ordnance |
||||
kfw_vo_data = load_kfw_vo_data() |
||||
results["kfw-un"] = { |
||||
"vehicles": { |
||||
"notes": kfw_vo_data["kfw-un"]["vehicles"][0], |
||||
"ma_notes": kfw_vo_data["kfw-un"]["vehicles"][1] |
||||
}, |
||||
"ordnance": { |
||||
"notes": kfw_vo_data["kfw-un"]["ordnance"][0], |
||||
"ma_notes": kfw_vo_data["kfw-un"]["ordnance"][1] |
||||
} |
||||
} |
||||
results["kfw-comm"] = { |
||||
"vehicles": { |
||||
"notes": kfw_vo_data["kfw-comm"]["vehicles"][0], |
||||
"ma_notes": kfw_vo_data["kfw-comm"]["vehicles"][1] |
||||
}, |
||||
"ordnance": { |
||||
"notes": kfw_vo_data["kfw-comm"]["ordnance"][0], |
||||
"ma_notes": kfw_vo_data["kfw-comm"]["ordnance"][1] |
||||
} |
||||
} |
||||
|
||||
# load the extensions |
||||
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]: |
||||
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" ) |
||||
|
||||
# generate the placeholder files |
||||
with zipfile.ZipFile( output_fname, "w" ) as zip_file: |
||||
nats = sorted( results.keys() ) |
||||
for nat in nats: #pylint: disable=too-many-nested-blocks |
||||
for vo_type in ("vehicles","ordnance"): |
||||
log( "Generating {} {}...", nat, vo_type ) |
||||
for note_type in ("notes","ma_notes"): |
||||
|
||||
# get the next set of note ID's |
||||
vals = results[nat].get( vo_type, {} ).get( note_type ) |
||||
if not vals: |
||||
continue |
||||
log( "- {}: {}", note_type, ", ".join( str(v) for v in vals ) ) |
||||
|
||||
for val in vals: |
||||
|
||||
# generate the filename for the next note placeholder |
||||
if isinstance(val, str): |
||||
# NOTE: Filenames are always lower-case, unless the note ID itself is lower-case, |
||||
# in which case we indicate this with a trailing underscore |
||||
if re.search( r"^([-a-z]+:)?[A-Z][A-Za-z]?$", val ): |
||||
val = val.lower() |
||||
elif re.search( r"^[a-z]{1,2}?$", val ): |
||||
val += "_" |
||||
if nat == "landing-craft": |
||||
fname = "{}/{}.{}".format( nat, val, "png" if note_type == "notes" else "html" ) |
||||
else: |
||||
fname = "{}/{}/{}.{}".format( nat, vo_type, val, "png" if note_type == "notes" else "html" ) |
||||
|
||||
# add the placeholder file to the ZIP |
||||
fname = fname.replace( ":", "/" ) |
||||
zip_file.writestr( fname, b"" ) |
||||
log( "" ) |
||||
|
||||
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - |
||||
|
||||
def load_vo_data( fname, nat ): |
||||
"""Load a vehicle/ordnance data file.""" |
||||
|
||||
# initialize |
||||
notes, ma_notes = set(), set() |
||||
|
||||
# load the file |
||||
with open( fname, "r", encoding="utf-8" ) as fp: |
||||
vo_data = json.load( fp ) |
||||
for vo_entry in vo_data: |
||||
if "note_number" in vo_entry: |
||||
notes.add( |
||||
_extract_note_number( vo_entry["note_number"] ) |
||||
) |
||||
if "notes" in vo_entry and not _ignore_ma_notes(nat): |
||||
ma_notes.update( |
||||
_extract_ma_note_ids( vo_entry["notes"] ) |
||||
) |
||||
|
||||
return sorted(notes), sorted(ma_notes) |
||||
|
||||
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - |
||||
|
||||
def load_kfw_vo_data(): |
||||
"""Load the K:FW vehicle/ordnance data files.""" |
||||
|
||||
# load the K:FW vehicles |
||||
un_veh_notes, un_veh_ma_notes = set(), set() |
||||
dname = os.path.join( os.path.split(__file__)[0], "../webapp/data/vehicles/kfw" ) |
||||
for fname in ( "us-rok-ounc.json", "bcfk.json", "un-common.json" ): |
||||
notes, ma_notes = load_vo_data( os.path.join(dname,fname), None ) |
||||
un_veh_notes.update( notes ) |
||||
un_veh_ma_notes.update( ma_notes ) |
||||
comm_veh_notes, comm_veh_ma_notes = set(), set() |
||||
for fname in ( "kpa.json", ): |
||||
notes, ma_notes = load_vo_data( os.path.join(dname,"kpa.json"), None ) |
||||
comm_veh_notes.update( notes ) |
||||
comm_veh_ma_notes.update( ma_notes ) |
||||
|
||||
# load the K:FW ordnance |
||||
un_ord_notes, un_ord_ma_notes = set(), set() |
||||
dname = os.path.join( os.path.split(__file__)[0], "../webapp/data/ordnance/kfw" ) |
||||
for fname in ( "us-rok-ounc.json", "bcfk.json", "un-common.json" ): |
||||
notes, ma_notes = load_vo_data( os.path.join(dname,fname), None ) |
||||
un_ord_notes.update( notes ) |
||||
un_ord_ma_notes.update( ma_notes ) |
||||
comm_ord_notes, comm_ord_ma_notes = set(), set() |
||||
for fname in ( "kpa.json", "cpva.json" ): |
||||
notes, ma_notes = load_vo_data( os.path.join(dname,fname), None ) |
||||
comm_ord_notes.update( notes ) |
||||
comm_ord_ma_notes.update( ma_notes ) |
||||
|
||||
return { |
||||
"kfw-un": { |
||||
"vehicles": ( un_veh_notes, un_veh_ma_notes ), |
||||
"ordnance": ( un_ord_notes, un_ord_ma_notes ) |
||||
}, |
||||
"kfw-comm": { |
||||
"vehicles": ( comm_veh_notes, comm_veh_ma_notes ), |
||||
"ordnance": ( comm_ord_notes, comm_ord_ma_notes ) |
||||
} |
||||
} |
||||
|
||||
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - |
||||
|
||||
def load_vo_data_from_extension( fname ): |
||||
"""Load a vehicle/ordnance extension data file.""" |
||||
|
||||
# initialize |
||||
results = {} |
||||
|
||||
# get the extension ID |
||||
with open( fname, "r", encoding="utf-8" ) as fp: |
||||
data = json.load( fp ) |
||||
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: |
||||
|
||||
if not isinstance( data[nat], dict ): |
||||
continue |
||||
|
||||
results[nat] = {} |
||||
for vo_type in ("vehicles","ordnance"): |
||||
notes, ma_notes = set(), set() |
||||
for vo_entry in data[nat].get(vo_type,[]): |
||||
# load the vehicle/ordnance's note number |
||||
if "note_number" in vo_entry: |
||||
notes.add( |
||||
_extract_note_number( vo_entry["note_number"] ) |
||||
) |
||||
if "notes" in vo_entry and not _ignore_ma_notes(nat,extn_id): |
||||
ma_notes.update( |
||||
_extract_ma_note_ids( vo_entry["notes"] ) |
||||
) |
||||
results[ nat ][ vo_type ] = { |
||||
"notes": [ "{}:{}".format( extn_id, n ) for n in sorted(notes) ], |
||||
"ma_notes": [ "{}:{}".format( extn_id, n ) for n in sorted(ma_notes) ] |
||||
} |
||||
|
||||
return results |
||||
|
||||
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - |
||||
|
||||
MA_NOTE_REGEXES = [ |
||||
re.compile( r"^([A-Z]{1,2})$" ), |
||||
re.compile( r"^([A-Z]{1,2})\u2020" ), |
||||
re.compile( r"^([a-z])$" ), |
||||
re.compile( r"^([a-z])\u2020" ), |
||||
re.compile( r"^([A-Z][a-z])$" ), |
||||
re.compile( r"^([A-Za-z])<sup>" ), |
||||
re.compile( r"^<s>([A-Za-z])</s>$" ), |
||||
] |
||||
|
||||
REDIRECTED_MA_NOTE_REGEX = re.compile( |
||||
r"^((Ge|Ru|US|Br|Fr|Jp|Ch|Gr|AllM|AxM) ([A-Z]{1,2}|[0-9]{1,2}|Note \d+|<s>P</s>))\u2020?(<sup>\d</sup>)?$" |
||||
) |
||||
|
||||
def _extract_note_number( val ): |
||||
"""Extract a vehicle/ordnance's note number.""" |
||||
mo = re.search( r"^\d+(\.\d)?", val ) |
||||
return mo.group() |
||||
|
||||
def _extract_ma_note_ids( val ): |
||||
"""Extract a vehicle/ordnance's multi-applicable note ID's.""" |
||||
ma_note_ids = [] |
||||
for ma_note in val: |
||||
if REDIRECTED_MA_NOTE_REGEX.search( ma_note ): |
||||
continue |
||||
matches = [ regex.search(ma_note) for regex in MA_NOTE_REGEXES ] |
||||
matches = [ mo.group(1) for mo in matches if mo ] |
||||
assert len(matches) == 1 |
||||
ma_note_ids.append( matches[0] ) |
||||
return ma_note_ids |
||||
|
||||
def _ignore_ma_notes( nat, extn_id=None ): |
||||
if extn_id == "adf-bj" and nat == "american": |
||||
return True |
||||
if extn_id is None and nationalities.get( nat, {} ).get( "type" ) in ("allied-minor","axis-minor"): |
||||
return True |
||||
return False |
||||
|
||||
# --------------------------------------------------------------------- |
||||
|
||||
if __name__ == "__main__": |
||||
main() #pylint: disable=no-value-for-parameter |
@ -0,0 +1,27 @@ |
||||
"""Test generating the Chapter H placeholder files.""" |
||||
|
||||
import os |
||||
from zipfile import ZipFile |
||||
|
||||
from vasl_templates.tools.make_chapter_h_placeholders import make_chapter_h_placeholders |
||||
from vasl_templates.webapp.utils import TempFile |
||||
|
||||
# --------------------------------------------------------------------- |
||||
|
||||
def test_make_chapter_h_placeholders(): |
||||
"""Test generating the Chapter H placeholder files.""" |
||||
|
||||
with TempFile() as temp_file: |
||||
|
||||
# generate the Chapter H placeholder files |
||||
make_chapter_h_placeholders( temp_file.name ) |
||||
|
||||
# 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 ] |
||||
|
||||
# check the results |
||||
with ZipFile( temp_file.name, "r" ) as zip_file: |
||||
zip_fnames = sorted( zip_file.namelist() ) |
||||
assert zip_fnames == expected |
@ -0,0 +1,258 @@ |
||||
#!/usr/bin/env python3 |
||||
""" Stress-test the shared WebDriver. """ |
||||
|
||||
import os |
||||
import threading |
||||
import signal |
||||
import http.client |
||||
import time |
||||
import datetime |
||||
import base64 |
||||
import random |
||||
import json |
||||
import logging |
||||
from collections import defaultdict |
||||
|
||||
import click |
||||
from selenium.webdriver.common.action_chains import ActionChains |
||||
from selenium.webdriver.common.keys import Keys |
||||
|
||||
from vasl_templates.webapp.webdriver import WebDriver |
||||
from vasl_templates.webapp.tests.test_scenario_persistence import load_scenario |
||||
from vasl_templates.webapp.tests.utils import wait_for, find_child, find_snippet_buttons, \ |
||||
select_tab, select_menu_option, click_dialog_button, set_stored_msg, get_stored_msg |
||||
|
||||
shutdown_event = threading.Event() |
||||
thread_count = None |
||||
|
||||
stats_lock = threading.Lock() |
||||
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( "--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 ): |
||||
"""Stress-test the shared WebDriver.""" |
||||
|
||||
# initialize |
||||
logging.disable( logging.CRITICAL ) |
||||
|
||||
# read the VASL scenario file |
||||
vsav_data = None |
||||
if update_vsav > 0: |
||||
with open( vsav_fname, "rb" ) as fp: |
||||
vsav_data = fp.read() |
||||
|
||||
# prepare the test threads |
||||
threads = [] |
||||
for i in range(0,snippet_images): |
||||
threads.append( threading.Thread( |
||||
target = snippet_images_thread, |
||||
name = "snippet-images/{:02d}".format( 1+i ), |
||||
args = ( webapp_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 ) |
||||
) ) |
||||
|
||||
# launch the test threads |
||||
start_time = time.time() |
||||
global thread_count |
||||
thread_count = len(threads) |
||||
for thread in threads: |
||||
thread.start() |
||||
|
||||
# wait for Ctrl-C |
||||
def on_sigint( signum, stack ): #pylint: disable=missing-docstring,unused-argument |
||||
print( "\n*** SIGINT received ***\n" ) |
||||
shutdown_event.set() |
||||
signal.signal( signal.SIGINT, on_sigint ) |
||||
while not shutdown_event.is_set(): |
||||
time.sleep( 1 ) |
||||
|
||||
# wait for the test threads to shutdown |
||||
for thread in threads: |
||||
print( "Waiting for thread to finish:", thread ) |
||||
thread.join() |
||||
elapsed_time = time.time() - start_time |
||||
print() |
||||
|
||||
# output stats |
||||
print( "=== STATS ===") |
||||
print() |
||||
print( "Total run time: {}".format( datetime.timedelta( seconds=int(elapsed_time) ) ) ) |
||||
for key,val in stats.items(): |
||||
print( "- {:<14} {}".format( key+":", val[0] ), end="" ) |
||||
if val[0] > 0: |
||||
print( " (avg={:.3f}s)".format( float(val[1])/val[0] ) ) |
||||
else: |
||||
print() |
||||
|
||||
# --------------------------------------------------------------------- |
||||
|
||||
def snippet_images_thread( webapp_url ): |
||||
"""Test generating snippet images.""" |
||||
|
||||
with WebDriver() as webdriver: |
||||
|
||||
# initialize |
||||
webdriver = webdriver.driver |
||||
init_webapp( webdriver, webapp_url, |
||||
[ "snippet_image_persistence", "scenario_persistence" ] |
||||
) |
||||
|
||||
# load a scenario (so that we get some sortable's) |
||||
scenario_data = { |
||||
"SCENARIO_NOTES": [ { "caption": "Scenario note #1" } ], |
||||
"OB_SETUPS_1": [ { "caption": "OB setup note #1" } ], |
||||
"OB_NOTES_1": [ { "caption": "OB note #1" } ], |
||||
"OB_SETUPS_2": [ { "caption": "OB setup note #2" } ], |
||||
"OB_NOTES_2": [ { "caption": "OB note #2" } ], |
||||
} |
||||
load_scenario( scenario_data, webdriver ) |
||||
|
||||
# locate all the "generate snippet" buttons |
||||
snippet_btns = find_snippet_buttons( webdriver ) |
||||
tab_ids = list( snippet_btns.keys() ) |
||||
|
||||
while not shutdown_event.is_set(): |
||||
|
||||
try: |
||||
# clear the return buffer |
||||
ret_buffer = find_child( "#_snippet-image-persistence_", webdriver ) |
||||
assert ret_buffer.tag_name == "textarea" |
||||
webdriver.execute_script( "arguments[0].value = arguments[1]", ret_buffer, "" ) |
||||
|
||||
# generate a snippet |
||||
tab_id = random.choice( tab_ids ) |
||||
btn = random.choice( snippet_btns[ tab_id ] ) |
||||
log( "Getting snippet image: {}", btn.get_attribute("data-id") ) |
||||
select_tab( tab_id, webdriver ) |
||||
start_time = time.time() |
||||
ActionChains( webdriver ) \ |
||||
.key_down( Keys.SHIFT ) \ |
||||
.click( btn ) \ |
||||
.key_up( Keys.SHIFT ) \ |
||||
.perform() |
||||
|
||||
# wait for the snippet image to be generated |
||||
wait_for( 10*thread_count, lambda: ret_buffer.get_attribute( "value" ) ) |
||||
_, img_data = ret_buffer.get_attribute( "value" ).split( "|", 1 ) |
||||
elapsed_time = time.time() - start_time |
||||
|
||||
# update the stats |
||||
with stats_lock: |
||||
stats["snippet image"][0] += 1 |
||||
stats["snippet image"][1] += elapsed_time |
||||
|
||||
# FUDGE! Generating the snippet image for a sortable entry is sometimes interpreted as |
||||
# a request to edit the entry (Selenium problem?) - we dismiss the dialog here and continue. |
||||
dlg = find_child( ".ui-dialog", webdriver ) |
||||
if dlg and dlg.is_displayed(): |
||||
click_dialog_button( "Cancel", webdriver ) |
||||
|
||||
except ( ConnectionRefusedError, ConnectionResetError, http.client.RemoteDisconnected ): |
||||
if shutdown_event.is_set(): |
||||
break |
||||
raise |
||||
|
||||
# check the generated snippet |
||||
img_data = base64.b64decode( img_data ) |
||||
log( "Received snippet image: #bytes={}", len(img_data) ) |
||||
assert img_data[:6] == b"\x89PNG\r\n" |
||||
|
||||
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - |
||||
|
||||
def update_vsav_thread( webapp_url, vsav_fname, vsav_data ): |
||||
"""Test updating VASL scenario files.""" |
||||
|
||||
# initialize |
||||
vsav_data_b64 = base64.b64encode( vsav_data ).decode( "utf-8" ) |
||||
|
||||
with WebDriver() as webdriver: |
||||
|
||||
# initialize |
||||
webdriver = webdriver.driver |
||||
init_webapp( webdriver, webapp_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 ) |
||||
load_scenario( saved_scenario, webdriver ) |
||||
|
||||
while not shutdown_event.is_set(): |
||||
|
||||
try: |
||||
|
||||
# send the VSAV data to the front-end to be updated |
||||
log( "Updating VSAV: {}", vsav_fname ) |
||||
set_stored_msg( "_vsav-persistence_", vsav_data_b64, webdriver ) |
||||
select_menu_option( "update_vsav", webdriver ) |
||||
start_time = time.time() |
||||
|
||||
# wait for the front-end to receive the data |
||||
wait_for( 2*thread_count, |
||||
lambda: get_stored_msg( "_vsav-persistence_", webdriver ) == "" |
||||
) |
||||
|
||||
# wait for the updated data to arrive |
||||
wait_for( 60*thread_count, |
||||
lambda: get_stored_msg( "_vsav-persistence_", webdriver ) != "" |
||||
) |
||||
elapsed_time = time.time() - start_time |
||||
|
||||
# get the updated VSAV data |
||||
updated_vsav_data = get_stored_msg( "_vsav-persistence_", webdriver ) |
||||
if updated_vsav_data.startswith( "ERROR: " ): |
||||
raise RuntimeError( updated_vsav_data ) |
||||
updated_vsav_data = base64.b64decode( updated_vsav_data ) |
||||
|
||||
# check the updated VSAV |
||||
log( "Received updated VSAV data: #bytes={}", len(updated_vsav_data) ) |
||||
assert updated_vsav_data[:2] == b"PK" |
||||
|
||||
# update the stats |
||||
with stats_lock: |
||||
stats["update vsav"][0] += 1 |
||||
stats["update vsav"][1] += elapsed_time |
||||
|
||||
except (ConnectionRefusedError, ConnectionResetError, http.client.RemoteDisconnected): |
||||
if shutdown_event.is_set(): |
||||
break |
||||
raise |
||||
|
||||
# --------------------------------------------------------------------- |
||||
|
||||
def log( fmt, *args, **kwargs ): |
||||
"""Log a message.""" |
||||
now = time.time() |
||||
msec = now - int(now) |
||||
now = "{}.{:03d}".format( time.strftime("%H:%M:%S",time.localtime(now)), int(msec*1000) ) |
||||
msg = fmt.format( *args, **kwargs ) |
||||
msg = "{} | {:17} | {}".format( now, threading.current_thread().name, msg ) |
||||
print( msg ) |
||||
|
||||
# --------------------------------------------------------------------- |
||||
|
||||
def init_webapp( webdriver, webapp_url, options ): |
||||
"""Initialize the webapp.""" |
||||
log( "Initializing the webapp." ) |
||||
url = webapp_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 ) |
||||
|
||||
# --------------------------------------------------------------------- |
||||
|
||||
if __name__ == "__main__": |
||||
main() #pylint: disable=no-value-for-parameter |
@ -0,0 +1,188 @@ |
||||
<?xml version="1.0" encoding="UTF-8"?> |
||||
<ui version="4.0"> |
||||
<class>Dialog</class> |
||||
<widget class="QDialog" name="Dialog"> |
||||
<property name="windowModality"> |
||||
<enum>Qt::ApplicationModal</enum> |
||||
</property> |
||||
<property name="geometry"> |
||||
<rect> |
||||
<x>0</x> |
||||
<y>0</y> |
||||
<width>460</width> |
||||
<height>215</height> |
||||
</rect> |
||||
</property> |
||||
<property name="windowTitle"> |
||||
<string>About</string> |
||||
</property> |
||||
<property name="modal"> |
||||
<bool>true</bool> |
||||
</property> |
||||
<widget class="QLabel" name="app_icon"> |
||||
<property name="geometry"> |
||||
<rect> |
||||
<x>15</x> |
||||
<y>10</y> |
||||
<width>64</width> |
||||
<height>64</height> |
||||
</rect> |
||||
</property> |
||||
<property name="frameShape"> |
||||
<enum>QFrame::NoFrame</enum> |
||||
</property> |
||||
<property name="text"> |
||||
<string/> |
||||
</property> |
||||
</widget> |
||||
<widget class="QWidget" name="verticalLayoutWidget"> |
||||
<property name="geometry"> |
||||
<rect> |
||||
<x>80</x> |
||||
<y>19</y> |
||||
<width>361</width> |
||||
<height>58</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> |
||||
<pointsize>12</pointsize> |
||||
</font> |
||||
</property> |
||||
<property name="text"> |
||||
<string>*** APP NAME ***</string> |
||||
</property> |
||||
</widget> |
||||
</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> |
||||
</font> |
||||
</property> |
||||
<property name="text"> |
||||
<string>*** BUILD INFO ***</string> |
||||
</property> |
||||
</widget> |
||||
</item> |
||||
</layout> |
||||
</widget> |
||||
<widget class="QWidget" name="horizontalLayoutWidget"> |
||||
<property name="geometry"> |
||||
<rect> |
||||
<x>19</x> |
||||
<y>172</y> |
||||
<width>425</width> |
||||
<height>31</height> |
||||
</rect> |
||||
</property> |
||||
<layout class="QHBoxLayout" name="horizontalLayout"> |
||||
<item> |
||||
<spacer name="horizontalSpacer"> |
||||
<property name="orientation"> |
||||
<enum>Qt::Horizontal</enum> |
||||
</property> |
||||
<property name="sizeHint" stdset="0"> |
||||
<size> |
||||
<width>309</width> |
||||
<height>20</height> |
||||
</size> |
||||
</property> |
||||
</spacer> |
||||
</item> |
||||
<item> |
||||
<widget class="QPushButton" name="close_button"> |
||||
<property name="sizePolicy"> |
||||
<sizepolicy hsizetype="Fixed" vsizetype="Fixed"> |
||||
<horstretch>0</horstretch> |
||||
<verstretch>0</verstretch> |
||||
</sizepolicy> |
||||
</property> |
||||
<property name="text"> |
||||
<string>Close</string> |
||||
</property> |
||||
<property name="default"> |
||||
<bool>true</bool> |
||||
</property> |
||||
</widget> |
||||
</item> |
||||
</layout> |
||||
</widget> |
||||
<widget class="QLabel" name="home_url"> |
||||
<property name="geometry"> |
||||
<rect> |
||||
<x>80</x> |
||||
<y>122</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>*** HOME URL ***</string> |
||||
</property> |
||||
<property name="openExternalLinks"> |
||||
<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> |
||||
<height>17</height> |
||||
</rect> |
||||
</property> |
||||
<property name="text"> |
||||
<string>Licensed under the GNU Affero General Public License (v3).</string> |
||||
</property> |
||||
</widget> |
||||
</widget> |
||||
<resources/> |
||||
<connections/> |
||||
</ui> |
@ -0,0 +1,575 @@ |
||||
<?xml version="1.0" encoding="UTF-8"?> |
||||
<ui version="4.0"> |
||||
<class>Dialog</class> |
||||
<widget class="QDialog" name="Dialog"> |
||||
<property name="windowModality"> |
||||
<enum>Qt::ApplicationModal</enum> |
||||
</property> |
||||
<property name="geometry"> |
||||
<rect> |
||||
<x>0</x> |
||||
<y>0</y> |
||||
<width>831</width> |
||||
<height>271</height> |
||||
</rect> |
||||
</property> |
||||
<property name="windowTitle"> |
||||
<string>Server settings</string> |
||||
</property> |
||||
<property name="modal"> |
||||
<bool>true</bool> |
||||
</property> |
||||
<widget class="QGroupBox" name="groupBox"> |
||||
<property name="geometry"> |
||||
<rect> |
||||
<x>10</x> |
||||
<y>170</y> |
||||
<width>401</width> |
||||
<height>91</height> |
||||
</rect> |
||||
</property> |
||||
<property name="title"> |
||||
<string>Support programs</string> |
||||
</property> |
||||
<widget class="QWidget" name="formLayoutWidget"> |
||||
<property name="geometry"> |
||||
<rect> |
||||
<x>10</x> |
||||
<y>30</y> |
||||
<width>381</width> |
||||
<height>58</height> |
||||
</rect> |
||||
</property> |
||||
<layout class="QFormLayout" name="formLayout_3"> |
||||
<property name="horizontalSpacing"> |
||||
<number>2</number> |
||||
</property> |
||||
<property name="verticalSpacing"> |
||||
<number>2</number> |
||||
</property> |
||||
<item row="0" column="0"> |
||||
<widget class="QLabel" name="label_4"> |
||||
<property name="text"> |
||||
<string>&Java:</string> |
||||
</property> |
||||
<property name="buddy"> |
||||
<cstring>java_path</cstring> |
||||
</property> |
||||
</widget> |
||||
</item> |
||||
<item row="0" column="1"> |
||||
<layout class="QHBoxLayout" name="horizontalLayout_5"> |
||||
<property name="spacing"> |
||||
<number>2</number> |
||||
</property> |
||||
<item> |
||||
<widget class="QLineEdit" name="java_path"/> |
||||
</item> |
||||
<item> |
||||
<widget class="QPushButton" name="select_java_path_button"> |
||||
<property name="sizePolicy"> |
||||
<sizepolicy hsizetype="Fixed" vsizetype="Fixed"> |
||||
<horstretch>0</horstretch> |
||||
<verstretch>0</verstretch> |
||||
</sizepolicy> |
||||
</property> |
||||
<property name="minimumSize"> |
||||
<size> |
||||
<width>22</width> |
||||
<height>22</height> |
||||
</size> |
||||
</property> |
||||
<property name="maximumSize"> |
||||
<size> |
||||
<width>22</width> |
||||
<height>22</height> |
||||
</size> |
||||
</property> |
||||
<property name="text"> |
||||
<string/> |
||||
</property> |
||||
</widget> |
||||
</item> |
||||
</layout> |
||||
</item> |
||||
<item row="1" column="0"> |
||||
<widget class="QLabel" name="label_5"> |
||||
<property name="text"> |
||||
<string>&Web driver:</string> |
||||
</property> |
||||
<property name="buddy"> |
||||
<cstring>webdriver_path</cstring> |
||||
</property> |
||||
</widget> |
||||
</item> |
||||
<item row="1" column="1"> |
||||
<layout class="QHBoxLayout" name="horizontalLayout_6"> |
||||
<property name="spacing"> |
||||
<number>2</number> |
||||
</property> |
||||
<item> |
||||
<widget class="QLineEdit" name="webdriver_path"/> |
||||
</item> |
||||
<item> |
||||
<widget class="QPushButton" name="select_webdriver_path_button"> |
||||
<property name="minimumSize"> |
||||
<size> |
||||
<width>22</width> |
||||
<height>22</height> |
||||
</size> |
||||
</property> |
||||
<property name="maximumSize"> |
||||
<size> |
||||
<width>22</width> |
||||
<height>22</height> |
||||
</size> |
||||
</property> |
||||
<property name="text"> |
||||
<string/> |
||||
</property> |
||||
</widget> |
||||
</item> |
||||
</layout> |
||||
</item> |
||||
</layout> |
||||
</widget> |
||||
</widget> |
||||
<widget class="QGroupBox" name="groupBox_2"> |
||||
<property name="geometry"> |
||||
<rect> |
||||
<x>10</x> |
||||
<y>10</y> |
||||
<width>401</width> |
||||
<height>151</height> |
||||
</rect> |
||||
</property> |
||||
<property name="title"> |
||||
<string>VASSAL/VASL</string> |
||||
</property> |
||||
<widget class="QWidget" name="layoutWidget"> |
||||
<property name="geometry"> |
||||
<rect> |
||||
<x>10</x> |
||||
<y>30</y> |
||||
<width>381</width> |
||||
<height>116</height> |
||||
</rect> |
||||
</property> |
||||
<layout class="QFormLayout" name="formLayout"> |
||||
<property name="verticalSpacing"> |
||||
<number>2</number> |
||||
</property> |
||||
<item row="0" column="0"> |
||||
<widget class="QLabel" name="label_2"> |
||||
<property name="text"> |
||||
<string>VA&SSAL installation:</string> |
||||
</property> |
||||
<property name="buddy"> |
||||
<cstring>vassal_dir</cstring> |
||||
</property> |
||||
</widget> |
||||
</item> |
||||
<item row="0" column="1"> |
||||
<layout class="QHBoxLayout" name="horizontalLayout_3"> |
||||
<property name="spacing"> |
||||
<number>2</number> |
||||
</property> |
||||
<item> |
||||
<widget class="QLineEdit" name="vassal_dir"/> |
||||
</item> |
||||
<item> |
||||
<widget class="QPushButton" name="select_vassal_dir_button"> |
||||
<property name="minimumSize"> |
||||
<size> |
||||
<width>22</width> |
||||
<height>22</height> |
||||
</size> |
||||
</property> |
||||
<property name="maximumSize"> |
||||
<size> |
||||
<width>22</width> |
||||
<height>22</height> |
||||
</size> |
||||
</property> |
||||
<property name="text"> |
||||
<string/> |
||||
</property> |
||||
</widget> |
||||
</item> |
||||
</layout> |
||||
</item> |
||||
<item row="1" column="0"> |
||||
<widget class="QLabel" name="label"> |
||||
<property name="text"> |
||||
<string>&VASL module:</string> |
||||
</property> |
||||
<property name="buddy"> |
||||
<cstring>vasl_mod</cstring> |
||||
</property> |
||||
</widget> |
||||
</item> |
||||
<item row="1" column="1"> |
||||
<layout class="QHBoxLayout" name="horizontalLayout_7"> |
||||
<property name="spacing"> |
||||
<number>2</number> |
||||
</property> |
||||
<item> |
||||
<widget class="QLineEdit" name="vasl_mod"/> |
||||
</item> |
||||
<item> |
||||
<widget class="QPushButton" name="select_vasl_mod_button"> |
||||
<property name="sizePolicy"> |
||||
<sizepolicy hsizetype="Fixed" vsizetype="Fixed"> |
||||
<horstretch>0</horstretch> |
||||
<verstretch>0</verstretch> |
||||
</sizepolicy> |
||||
</property> |
||||
<property name="minimumSize"> |
||||
<size> |
||||
<width>22</width> |
||||
<height>22</height> |
||||
</size> |
||||
</property> |
||||
<property name="maximumSize"> |
||||
<size> |
||||
<width>22</width> |
||||
<height>22</height> |
||||
</size> |
||||
</property> |
||||
<property name="text"> |
||||
<string/> |
||||
</property> |
||||
<property name="autoDefault"> |
||||
<bool>true</bool> |
||||
</property> |
||||
</widget> |
||||
</item> |
||||
</layout> |
||||
</item> |
||||
<item row="2" column="0"> |
||||
<widget class="QLabel" name="label_6"> |
||||
<property name="text"> |
||||
<string>VASL e&xtensions:</string> |
||||
</property> |
||||
<property name="buddy"> |
||||
<cstring>vasl_extns_dir</cstring> |
||||
</property> |
||||
</widget> |
||||
</item> |
||||
<item row="2" column="1"> |
||||
<layout class="QHBoxLayout" name="horizontalLayout_8"> |
||||
<property name="spacing"> |
||||
<number>2</number> |
||||
</property> |
||||
<item> |
||||
<widget class="QLineEdit" name="vasl_extns_dir"/> |
||||
</item> |
||||
<item> |
||||
<widget class="QPushButton" name="select_vasl_extns_dir_button"> |
||||
<property name="sizePolicy"> |
||||
<sizepolicy hsizetype="Fixed" vsizetype="Fixed"> |
||||
<horstretch>0</horstretch> |
||||
<verstretch>0</verstretch> |
||||
</sizepolicy> |
||||
</property> |
||||
<property name="minimumSize"> |
||||
<size> |
||||
<width>22</width> |
||||
<height>22</height> |
||||
</size> |
||||
</property> |
||||
<property name="maximumSize"> |
||||
<size> |
||||
<width>22</width> |
||||
<height>22</height> |
||||
</size> |
||||
</property> |
||||
<property name="text"> |
||||
<string/> |
||||
</property> |
||||
</widget> |
||||
</item> |
||||
</layout> |
||||
</item> |
||||
<item row="3" column="0"> |
||||
<widget class="QLabel" name="label_3"> |
||||
<property name="text"> |
||||
<string>VASL &boards:</string> |
||||
</property> |
||||
<property name="buddy"> |
||||
<cstring>boards_dir</cstring> |
||||
</property> |
||||
</widget> |
||||
</item> |
||||
<item row="3" column="1"> |
||||
<layout class="QHBoxLayout" name="horizontalLayout_4"> |
||||
<property name="spacing"> |
||||
<number>2</number> |
||||
</property> |
||||
<item> |
||||
<widget class="QLineEdit" name="boards_dir"/> |
||||
</item> |
||||
<item> |
||||
<widget class="QPushButton" name="select_boards_dir_button"> |
||||
<property name="sizePolicy"> |
||||
<sizepolicy hsizetype="Fixed" vsizetype="Fixed"> |
||||
<horstretch>0</horstretch> |
||||
<verstretch>0</verstretch> |
||||
</sizepolicy> |
||||
</property> |
||||
<property name="minimumSize"> |
||||
<size> |
||||
<width>22</width> |
||||
<height>22</height> |
||||
</size> |
||||
</property> |
||||
<property name="maximumSize"> |
||||
<size> |
||||
<width>22</width> |
||||
<height>22</height> |
||||
</size> |
||||
</property> |
||||
<property name="text"> |
||||
<string/> |
||||
</property> |
||||
</widget> |
||||
</item> |
||||
</layout> |
||||
</item> |
||||
</layout> |
||||
</widget> |
||||
</widget> |
||||
<widget class="QGroupBox" name="groupBox_3"> |
||||
<property name="geometry"> |
||||
<rect> |
||||
<x>420</x> |
||||
<y>10</y> |
||||
<width>401</width> |
||||
<height>121</height> |
||||
</rect> |
||||
</property> |
||||
<property name="title"> |
||||
<string>User data</string> |
||||
</property> |
||||
<widget class="QWidget" name="formLayoutWidget_2"> |
||||
<property name="geometry"> |
||||
<rect> |
||||
<x>10</x> |
||||
<y>30</y> |
||||
<width>381</width> |
||||
<height>86</height> |
||||
</rect> |
||||
</property> |
||||
<layout class="QFormLayout" name="formLayout_4"> |
||||
<property name="horizontalSpacing"> |
||||
<number>2</number> |
||||
</property> |
||||
<property name="verticalSpacing"> |
||||
<number>2</number> |
||||
</property> |
||||
<item row="0" column="0"> |
||||
<widget class="QLabel" name="label_7"> |
||||
<property name="text"> |
||||
<string>Chapter &H notes:</string> |
||||
</property> |
||||
<property name="buddy"> |
||||
<cstring>chapter_h_notes_dir</cstring> |
||||
</property> |
||||
</widget> |
||||
</item> |
||||
<item row="0" column="1"> |
||||
<layout class="QHBoxLayout" name="horizontalLayout_2"> |
||||
<item> |
||||
<widget class="QLineEdit" name="chapter_h_notes_dir"/> |
||||
</item> |
||||
<item> |
||||
<widget class="QPushButton" name="select_chapter_h_notes_dir_button"> |
||||
<property name="minimumSize"> |
||||
<size> |
||||
<width>22</width> |
||||
<height>22</height> |
||||
</size> |
||||
</property> |
||||
<property name="maximumSize"> |
||||
<size> |
||||
<width>22</width> |
||||
<height>22</height> |
||||
</size> |
||||
</property> |
||||
<property name="text"> |
||||
<string/> |
||||
</property> |
||||
</widget> |
||||
</item> |
||||
</layout> |
||||
</item> |
||||
<item row="3" column="0"> |
||||
<widget class="QLabel" name="label_9"> |
||||
<property name="text"> |
||||
<string>User &files:</string> |
||||
</property> |
||||
<property name="buddy"> |
||||
<cstring>user_files_dir</cstring> |
||||
</property> |
||||
</widget> |
||||
</item> |
||||
<item row="3" column="1"> |
||||
<layout class="QHBoxLayout" name="horizontalLayout_10"> |
||||
<item> |
||||
<widget class="QLineEdit" name="user_files_dir"/> |
||||
</item> |
||||
<item> |
||||
<widget class="QPushButton" name="select_user_files_dir_button"> |
||||
<property name="minimumSize"> |
||||
<size> |
||||
<width>22</width> |
||||
<height>22</height> |
||||
</size> |
||||
</property> |
||||
<property name="maximumSize"> |
||||
<size> |
||||
<width>22</width> |
||||
<height>22</height> |
||||
</size> |
||||
</property> |
||||
<property name="text"> |
||||
<string/> |
||||
</property> |
||||
</widget> |
||||
</item> |
||||
</layout> |
||||
</item> |
||||
<item row="2" column="1"> |
||||
<layout class="QHBoxLayout" name="horizontalLayout"> |
||||
<property name="spacing"> |
||||
<number>2</number> |
||||
</property> |
||||
<item> |
||||
<widget class="QLabel" name="chapter_h_image_scaling_label"> |
||||
<property name="text"> |
||||
<string>Image s&caling:</string> |
||||
</property> |
||||
<property name="buddy"> |
||||
<cstring>chapter_h_image_scaling</cstring> |
||||
</property> |
||||
</widget> |
||||
</item> |
||||
<item> |
||||
<widget class="QLineEdit" name="chapter_h_image_scaling"> |
||||
<property name="sizePolicy"> |
||||
<sizepolicy hsizetype="Fixed" vsizetype="Fixed"> |
||||
<horstretch>0</horstretch> |
||||
<verstretch>0</verstretch> |
||||
</sizepolicy> |
||||
</property> |
||||
<property name="minimumSize"> |
||||
<size> |
||||
<width>30</width> |
||||
<height>0</height> |
||||
</size> |
||||
</property> |
||||
<property name="maximumSize"> |
||||
<size> |
||||
<width>30</width> |
||||
<height>16777215</height> |
||||
</size> |
||||
</property> |
||||
<property name="inputMask"> |
||||
<string/> |
||||
</property> |
||||
</widget> |
||||
</item> |
||||
<item> |
||||
<widget class="QLabel" name="chapter_h_image_scaling_label2"> |
||||
<property name="text"> |
||||
<string>%</string> |
||||
</property> |
||||
</widget> |
||||
</item> |
||||
<item> |
||||
<spacer name="horizontalSpacer_2"> |
||||
<property name="orientation"> |
||||
<enum>Qt::Horizontal</enum> |
||||
</property> |
||||
<property name="sizeHint" stdset="0"> |
||||
<size> |
||||
<width>40</width> |
||||
<height>20</height> |
||||
</size> |
||||
</property> |
||||
</spacer> |
||||
</item> |
||||
</layout> |
||||
</item> |
||||
</layout> |
||||
</widget> |
||||
</widget> |
||||
<widget class="QWidget" name="horizontalLayoutWidget_3"> |
||||
<property name="geometry"> |
||||
<rect> |
||||
<x>650</x> |
||||
<y>230</y> |
||||
<width>173</width> |
||||
<height>31</height> |
||||
</rect> |
||||
</property> |
||||
<layout class="QHBoxLayout" name="horizontalLayout_11"> |
||||
<property name="spacing"> |
||||
<number>2</number> |
||||
</property> |
||||
<item> |
||||
<widget class="QPushButton" name="ok_button"> |
||||
<property name="sizePolicy"> |
||||
<sizepolicy hsizetype="Fixed" vsizetype="Fixed"> |
||||
<horstretch>0</horstretch> |
||||
<verstretch>0</verstretch> |
||||
</sizepolicy> |
||||
</property> |
||||
<property name="text"> |
||||
<string>OK</string> |
||||
</property> |
||||
<property name="default"> |
||||
<bool>true</bool> |
||||
</property> |
||||
</widget> |
||||
</item> |
||||
<item> |
||||
<widget class="QPushButton" name="cancel_button"> |
||||
<property name="sizePolicy"> |
||||
<sizepolicy hsizetype="Fixed" vsizetype="Fixed"> |
||||
<horstretch>0</horstretch> |
||||
<verstretch>0</verstretch> |
||||
</sizepolicy> |
||||
</property> |
||||
<property name="text"> |
||||
<string>Cancel</string> |
||||
</property> |
||||
</widget> |
||||
</item> |
||||
</layout> |
||||
</widget> |
||||
</widget> |
||||
<tabstops> |
||||
<tabstop>vassal_dir</tabstop> |
||||
<tabstop>select_vassal_dir_button</tabstop> |
||||
<tabstop>vasl_mod</tabstop> |
||||
<tabstop>select_vasl_mod_button</tabstop> |
||||
<tabstop>vasl_extns_dir</tabstop> |
||||
<tabstop>select_vasl_extns_dir_button</tabstop> |
||||
<tabstop>boards_dir</tabstop> |
||||
<tabstop>select_boards_dir_button</tabstop> |
||||
<tabstop>chapter_h_notes_dir</tabstop> |
||||
<tabstop>select_chapter_h_notes_dir_button</tabstop> |
||||
<tabstop>chapter_h_image_scaling</tabstop> |
||||
<tabstop>user_files_dir</tabstop> |
||||
<tabstop>select_user_files_dir_button</tabstop> |
||||
<tabstop>java_path</tabstop> |
||||
<tabstop>select_java_path_button</tabstop> |
||||
<tabstop>webdriver_path</tabstop> |
||||
<tabstop>select_webdriver_path_button</tabstop> |
||||
<tabstop>ok_button</tabstop> |
||||
<tabstop>cancel_button</tabstop> |
||||
</tabstops> |
||||
<resources/> |
||||
<connections/> |
||||
</ui> |
@ -1,88 +1,101 @@ |
||||
""" Web channel handler. """ |
||||
|
||||
import os |
||||
import base64 |
||||
|
||||
from PyQt5.QtWidgets import QFileDialog |
||||
from PyQt5.QtWidgets import QApplication |
||||
from PyQt5.QtGui import QImage |
||||
|
||||
from vasl_templates.webapp.config.constants import APP_NAME |
||||
from vasl_templates.file_dialog import FileDialog |
||||
|
||||
# --------------------------------------------------------------------- |
||||
|
||||
class WebChannelHandler: |
||||
"""Handle web channel requests.""" |
||||
|
||||
_FILE_FILTERS = "Scenario files (*.json);;All files (*)" |
||||
|
||||
def __init__( self, window ): |
||||
|
||||
# initialize |
||||
self._window = window |
||||
|
||||
# NOTE: While loading/saving scenarios works fine when handled by the embedded browser, |
||||
# we can't get the full path of the file saved loaded (because of browser security). |
||||
# This means that we can't e.g. default saving a scenario to the same file it was loaded from. |
||||
# This is such a lousy UX, we handle load/save operations ourself, where we can manage this. |
||||
self._curr_scenario_fname = None |
||||
def __init__( self, parent ): |
||||
self.parent = parent |
||||
self.scenario_file_dialog = FileDialog( |
||||
self.parent, |
||||
"scenario", ".json", |
||||
"Scenario files (*.json);;All files (*)", |
||||
None |
||||
) |
||||
self.updated_vsav_file_dialog = FileDialog( |
||||
self.parent, |
||||
"VASL scenario", ".vsav", |
||||
"VASL scenario files (*.vsav);;All files (*)", |
||||
"scenario.vsav" |
||||
) |
||||
self.log_file_analysis_dialog = FileDialog( |
||||
self.parent, |
||||
"log file analysis", ".csv", |
||||
"Analysis files (*.csv);;All files (*)", |
||||
None |
||||
) |
||||
|
||||
def on_new_scenario( self ): |
||||
"""Called when the scenario is reset.""" |
||||
self._curr_scenario_fname = None |
||||
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 |
||||
|
||||
# ask the user which file to load |
||||
fname, _ = QFileDialog.getOpenFileName( |
||||
self._window, "Load scenario", |
||||
os.path.split(self._curr_scenario_fname)[0] if self._curr_scenario_fname else None, |
||||
WebChannelHandler._FILE_FILTERS |
||||
) |
||||
if not fname: |
||||
return None |
||||
|
||||
# load the scenario |
||||
try: |
||||
with open( fname, "r", encoding="utf-8" ) as fp: |
||||
data = fp.read() |
||||
except Exception as ex: #pylint: disable=broad-except |
||||
self._window.showErrorMsg( "Can't load the scenario:\n\n{}".format( ex ) ) |
||||
return None |
||||
self._curr_scenario_fname = fname |
||||
|
||||
return data |
||||
|
||||
def save_scenario( self, data ): |
||||
def save_scenario( self, fname, data ): |
||||
"""Called when the user wants to save a scenario.""" |
||||
|
||||
# ask the user where to save the scenario |
||||
fname, _ = QFileDialog.getSaveFileName( |
||||
self._window, "Save scenario", |
||||
self._curr_scenario_fname, |
||||
WebChannelHandler._FILE_FILTERS |
||||
) |
||||
if not fname: |
||||
return False |
||||
|
||||
# check the file extension |
||||
extn = os.path.splitext( fname )[1] |
||||
if not extn: |
||||
fname += ".json" |
||||
elif fname.endswith( "." ): |
||||
fname = fname[:-1] |
||||
|
||||
# save the file |
||||
try: |
||||
with open( fname, "w", encoding="utf-8" ) as fp: |
||||
fp.write( data ) |
||||
except Exception as ex: #pylint: disable=broad-except |
||||
self._window.showErrorMsg( "Can't save the scenario:\n\n{}".format( ex ) ) |
||||
return False |
||||
self._curr_scenario_fname = fname |
||||
|
||||
return True |
||||
|
||||
def on_scenario_name_change( self, val ): |
||||
"""Update the main window title to show the scenario name.""" |
||||
self._window.setWindowTitle( |
||||
"{} - {}".format( APP_NAME, val ) if val else APP_NAME |
||||
) |
||||
prev_curr_fname = self.scenario_file_dialog.curr_fname |
||||
if not self.scenario_file_dialog.curr_fname: |
||||
# NOTE: We are tracking the current scenario filename ourself, so we only use the filename |
||||
# passed to us by the web page if a new scenario is being saved for the first time. |
||||
self.scenario_file_dialog.curr_fname = fname |
||||
rc = self.scenario_file_dialog.save_file( data ) |
||||
if not rc: |
||||
self.scenario_file_dialog.curr_fname = prev_curr_fname |
||||
return None |
||||
return self.scenario_file_dialog.curr_fname |
||||
|
||||
def on_update_scenario_status( self, caption, is_dirty ): |
||||
"""Update the main window title to show the scenario details.""" |
||||
title = APP_NAME |
||||
if caption: |
||||
title += " - {}".format( caption ) |
||||
if is_dirty: |
||||
title += " (*)" |
||||
self.parent.setWindowTitle( title ) |
||||
|
||||
def on_snippet_image( self, img_data ): |
||||
"""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: |
||||
img = QImage.fromData( base64.b64decode( img_data ) ) |
||||
QApplication.clipboard().setImage( img ) |
||||
|
||||
def load_vsav( self ): |
||||
"""Called when the user wants to load a VASL scenario to update.""" |
||||
data = self.updated_vsav_file_dialog.load_file( True ) |
||||
if data is None: |
||||
return None, None |
||||
fname = os.path.split( self.updated_vsav_file_dialog.curr_fname )[1] |
||||
return fname, data |
||||
|
||||
def save_updated_vsav( self, fname, data ): |
||||
"""Called when a VASL scenario has been updated and is ready to be saved.""" |
||||
dname = os.path.split( self.updated_vsav_file_dialog.curr_fname )[0] |
||||
self.updated_vsav_file_dialog.curr_fname = os.path.join( dname, fname ) |
||||
return self.updated_vsav_file_dialog.save_file( data ) |
||||
|
||||
def save_log_file_analysis( self, data ): |
||||
"""Called when the user wants to save a log file analysis.""" |
||||
prev_curr_fname = self.log_file_analysis_dialog.curr_fname |
||||
if not self.log_file_analysis_dialog.curr_fname: |
||||
self.log_file_analysis_dialog.curr_fname = "analysis.csv" |
||||
rc = self.log_file_analysis_dialog.save_file( data ) |
||||
if not rc: |
||||
self.log_file_analysis_dialog.curr_fname = prev_curr_fname |
||||
|
@ -1,59 +1,267 @@ |
||||
""" Initialize the package. """ |
||||
|
||||
import sys |
||||
import os |
||||
import signal |
||||
import threading |
||||
import time |
||||
import configparser |
||||
import logging |
||||
import logging.config |
||||
|
||||
from flask import Flask |
||||
from flask import Flask, request |
||||
import flask.cli |
||||
import yaml |
||||
|
||||
from vasl_templates.webapp.config.constants import APP_NAME, APP_VERSION, BASE_DIR |
||||
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(): |
||||
"""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() |
||||
|
||||
# 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 ) |
||||
|
||||
# load the vehicle/ordnance listings |
||||
from vasl_templates.webapp.vo import load_vo_listings #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 ): |
||||
"""Load config settings from a file.""" |
||||
if not os.path.isfile( fname ): |
||||
return |
||||
config_parser = configparser.ConfigParser() |
||||
config_parser.optionxform = str # preserve case for the keys :-/ |
||||
config_parser.read( fname ) |
||||
app.config.update( dict( config_parser.items( section) ) ) |
||||
|
||||
def load_debug_config( fname ): |
||||
"""Configure the application.""" |
||||
config_parser.read( fname ) |
||||
app.config.update( dict( config_parser.items( "Debug" ) ) ) |
||||
_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 |
||||
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() |
||||
|
||||
# --------------------------------------------------------------------- |
||||
|
||||
# initialize Flask |
||||
app = Flask( __name__ ) |
||||
|
||||
# load the application configuration |
||||
config_dir = os.path.join( BASE_DIR, "config" ) |
||||
config_parser = configparser.ConfigParser() |
||||
config_parser.optionxform = str # preserve case for the keys :-/ |
||||
config_parser.read( os.path.join( config_dir, "app.cfg" ) ) |
||||
app.config.update( dict( config_parser.items( "System" ) ) ) |
||||
|
||||
# load any debug configuration |
||||
_fname = os.path.join( config_dir, "debug.cfg" ) |
||||
if os.path.isfile( _fname ) : |
||||
load_debug_config( _fname ) |
||||
# set config defaults |
||||
# NOTE: These are defined here since they are used by both the back- and front-ends. |
||||
app.config[ "ASA_SCENARIO_URL" ] = "https://aslscenarioarchive.com/scenario.php?id={ID}" |
||||
app.config[ "ASA_PUBLICATION_URL" ] = "https://aslscenarioarchive.com/viewPub.php?id={ID}" |
||||
app.config[ "ASA_PUBLISHER_URL" ] = "https://aslscenarioarchive.com/viewPublisher.php?id={ID}" |
||||
app.config[ "ASA_GET_SCENARIO_URL" ] = "https://aslscenarioarchive.com/rest/scenario/list/{ID}" |
||||
app.config[ "ASA_MAX_VASL_SETUP_SIZE" ] = 200 # nb: KB |
||||
app.config[ "ASA_MAX_SCREENSHOT_SIZE" ] = 200 # nb: KB |
||||
|
||||
# initialize logging |
||||
_fname = os.path.join( config_dir, "logging.yaml" ) |
||||
_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" ) as fp: |
||||
logging.config.dictConfig( yaml.safe_load( fp ) ) |
||||
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" ) |
||||
_load_config( _fname, "System" ) |
||||
|
||||
# load any site configuration |
||||
_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. |
||||
|
||||
# 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 |
||||
|
||||
# load the application |
||||
import vasl_templates.webapp.main #pylint: disable=cyclic-import |
||||
import vasl_templates.webapp.vo #pylint: disable=cyclic-import |
||||
import vasl_templates.webapp.snippets #pylint: disable=cyclic-import |
||||
import vasl_templates.webapp.files #pylint: disable=cyclic-import |
||||
import vasl_templates.webapp.vassal #pylint: disable=cyclic-import |
||||
import vasl_templates.webapp.vo_notes #pylint: disable=cyclic-import |
||||
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 |
||||
|
||||
# --------------------------------------------------------------------- |
||||
# 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 ) |
||||
|
||||
@app.context_processor |
||||
def inject_template_params(): |
||||
"""Inject template parameters into Jinja2.""" |
||||
return { |
||||
"APP_NAME": APP_NAME, |
||||
"APP_VERSION": APP_VERSION, |
||||
} |
||||
# register startup initialization |
||||
app.before_request( _on_request ) |
||||
|
@ -1,2 +1,3 @@ |
||||
site.cfg |
||||
debug.cfg |
||||
logging.yaml |
||||
|
@ -1,10 +1,18 @@ |
||||
""" Application constants. """ |
||||
|
||||
import sys |
||||
import os |
||||
|
||||
APP_NAME = "VASL Templates" |
||||
APP_VERSION = "v0.1" |
||||
APP_VERSION = "v1.13" # 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" |
||||
|
||||
BASE_DIR = os.path.abspath( os.path.join( os.path.split(__file__)[0], ".." ) ) |
||||
if getattr( sys, "frozen", False ): |
||||
IS_FROZEN = True |
||||
BASE_DIR = os.path.split( sys.executable )[0] |
||||
else: |
||||
IS_FROZEN = False |
||||
BASE_DIR = os.path.abspath( os.path.join( os.path.split(__file__)[0], ".." ) ) |
||||
DATA_DIR = os.path.join( BASE_DIR, "data" ) |
||||
|
@ -0,0 +1,10 @@ |
||||
[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 = ... |
@ -0,0 +1,69 @@ |
||||
# 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 |
||||
|
||||
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" |
||||
|
||||
root: |
||||
level: "WARNING" |
||||
handlers: [ "console", "file" ] |
||||
loggers: |
||||
werkzeug: |
||||
level: "ERROR" |
||||
handlers: [ "console", "file" ] |
||||
propagate: 0 |
||||
javascript: |
||||
level: "INFO" |
||||
handlers: [ "console", "file" ] |
||||
propagate: 0 |
||||
qt: |
||||
level: "INFO" |
||||
handlers: [ "console", "file" ] |
||||
propagate: 0 |
||||
vasl_mod: |
||||
level: "WARNING" |
||||
handlers: [ "console", "file" ] |
||||
propagate: 0 |
||||
vassal_shim: |
||||
level: "WARNING" |
||||
handlers: [ "console", "file" ] |
||||
propagate: 0 |
||||
update_vsav: |
||||
level: "WARNING" |
||||
handlers: [ "console", "file" ] |
||||
propagate: 0 |
||||
analyze_vsav: |
||||
level: "WARNING" |
||||
handlers: [ "console", "file" ] |
||||
propagate: 0 |
||||
analyze_vlog: |
||||
level: "WARNING" |
||||
handlers: [ "console", "file" ] |
||||
propagate: 0 |
||||
webdriver: |
||||
level: "WARNING" |
||||
handlers: [ "console", "file" ] |
||||
propagate: 0 |
||||
downloads: |
||||
level: "WARNING" |
||||
handlers: [ "console", "file" ] |
||||
propagate: 0 |
||||
user_profile: |
||||
level: "WARNING" |
||||
handlers: [ "console", "file" ] |
||||
propagate: 0 |
@ -1,30 +0,0 @@ |
||||
# This is a sample config file for Python logging - rename it as logging.yaml. |
||||
|
||||
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" ] |
||||
javascript: |
||||
level: "INFO" |
||||
handlers: [ "console" ] |
||||
qt: |
||||
level: "INFO" |
||||
handlers: [ "console" ] |
@ -0,0 +1,16 @@ |
||||
[Site Config] |
||||
|
||||
; 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... |
||||
BOARDS_DIR = ...configure the VASL boards directory... |
||||
|
||||
; configure support programs |
||||
; JAVA_PATH = ...configure the Java executable here (optional, must be in the PATH otherwise)... |
||||
WEBDRIVER_PATH = ...configure either geckodriver or chromedriver here... |
||||
|
||||
; configure your user data |
||||
CHAPTER_H_NOTES_DIR = ...configure your Chapter H vehicle/ordnance images and multi-applicable notes... |
||||
; CHAPTER_H_IMAGE_SCALING = ...optional scaling percentage for Chapter H images... |
||||
USER_FILES_DIR = ...configure your user files directory... |
@ -0,0 +1,48 @@ |
||||
{ |
||||
|
||||
"_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" |
||||
}, |
||||
|
||||
"_comment_": "This section maps player nationalities from those at the ASL Scenario Archive (must be lower-case) to our nationality ID's.", |
||||
"nat-mappings": { |
||||
"australian": "anzac", |
||||
"belgians": "belgian", |
||||
"canada": "british~canadian", |
||||
"canadian": "british~canadian", |
||||
"china cmd": "chinese~gmd", |
||||
"commonwealth": "anzac", |
||||
"filipinos": "filipino", |
||||
"finland": "finnish", |
||||
"free french": "free-french", |
||||
"germany": "german", |
||||
"gurkha": "british", |
||||
"gurkhas": "british", |
||||
"ina": "indonesian", |
||||
"japan": "japanese", |
||||
"kpa": "kfw-kpa", |
||||
"nkpa": "kfw-kpa", |
||||
"north korea": "kfw-kpa", |
||||
"philippine": "filipino", |
||||
"poland": "polish", |
||||
"republic of korea": "kfw-rok", |
||||
"rok": "kfw-rok", |
||||
"rumanian": "romanian", |
||||
"russia": "russian", |
||||
"russians": "russian", |
||||
"slovak": "slovakian", |
||||
"siamese": "thai", |
||||
"soviet": "russian", |
||||
"ss": "german", |
||||
"u.s.": "american", |
||||
"usmc": "american", |
||||
"yugoslav": "yugoslavian", |
||||
"vichy": "french" |
||||
} |
||||
|
||||
} |
@ -1,15 +1,18 @@ |
||||
{ |
||||
|
||||
"SCENARIO_THEATER": "ETO", |
||||
|
||||
"PLAYER_1": "german", |
||||
"PLAYER_1_ELR": "5", |
||||
"PLAYER_1_SAN": "2", |
||||
"PLAYER_2": "russian", |
||||
"PLAYER_2_ELR": "5", |
||||
"PLAYER_2_SAN": "2", |
||||
|
||||
"VICTORY_CONDITIONS_WIDTH": "300px", |
||||
"SSR_WIDTH": "300px", |
||||
|
||||
"OB_VEHICLES_MA_NOTES_WIDTH_1": "300px", |
||||
"OB_ORDNANCE_MA_NOTES_WIDTH_1": "300px", |
||||
"OB_VEHICLES_MA_NOTES_WIDTH_2": "300px", |
||||
"OB_ORDNANCE_MA_NOTES_WIDTH_2": "300px", |
||||
|
||||
"_SCENARIO_NOTE_WIDTH": "200px" |
||||
|
||||
} |
||||
|
@ -0,0 +1,40 @@ |
||||
<html> <!-- vasl-templates:id {{SNIPPET_ID}} --> |
||||
|
||||
<head> |
||||
<meta charset="utf-8"> |
||||
<style> {{CSS:common}} </style> |
||||
</head> |
||||
|
||||
<table> |
||||
|
||||
<tr> |
||||
<td colspan="2" style=" |
||||
background: {{OB_COLOR}} ; |
||||
border-bottom: 1px solid {{OB_COLOR_2}} ; |
||||
padding: 2px 5px ; |
||||
font-size: 105% ; font-weight: bold ; |
||||
"> |
||||
{{INCLUDE:player_flag_large}}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 ≤ {%if SCENARIO_YEAR < 1944%} 2 {%else%} 3 {%endif%} (△) <br> |
||||
<table style="margin-left:10px;"> |
||||
<tr> |
||||
<td style="width:25px;"> +1 <td> HS |
||||
<tr> |
||||
<td> +1 <td> 1st Line |
||||
<tr> |
||||
<td> +1 <td> CX |
||||
<tr> |
||||
<td> +1 <td> vs. non-armored vehicle |
||||
</table> |
||||
original 6 = pinned (CCV reduced by 1) <br> |
||||
|
||||
</table> |
||||
|
||||
</html> |
@ -0,0 +1,55 @@ |
||||
<html> <!-- vasl-templates:id {{SNIPPET_ID}} --> |
||||
|
||||
<head> |
||||
<meta charset="utf-8"> |
||||
<style> |
||||
{{CSS:common}} |
||||
</style> |
||||
</head> |
||||
|
||||
<table> |
||||
|
||||
<tr> |
||||
<td colspan="2" style=" |
||||
background: {{OB_COLOR}} ; |
||||
border-bottom: 1px solid {{OB_COLOR_2}} ; |
||||
padding: 2px 5px ; |
||||
font-size: 105% ; font-weight: bold ; |
||||
"> |
||||
{{INCLUDE:player_flag_large}}Bazooka '44 |
||||
|
||||
<tr> |
||||
|
||||
<td style="padding:0 5px;"> |
||||
<table> |
||||
<tr> |
||||
<td class="c"> Range <td class="c" width="35"> <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"> 7 |
||||
<tr> |
||||
<td class="c"> 3 <td class="c"> 6 |
||||
<tr> |
||||
<td class="c"> 4 <td class="c"> 3 |
||||
</table> |
||||
|
||||
<td valign="top" style="padding:0 5px;"> |
||||
<table> |
||||
<tr> |
||||
<td> <b>TK#:</b> |
||||
<td class="r"> 16 |
||||
<tr> |
||||
<td colspan="2" class="r"> 8-4 |
||||
<tr> |
||||
<td> |
||||
<tr> |
||||
<td> <b>X#:</b> |
||||
<td class="r"> 11 |
||||
</table> |
||||
|
||||
</table> |
||||
|
||||
</html> |
@ -0,0 +1,57 @@ |
||||
<html> <!-- vasl-templates:id {{SNIPPET_ID}} --> |
||||
|
||||
<head> |
||||
<meta charset="utf-8"> |
||||
<style> |
||||
{{CSS:common}} |
||||
</style> |
||||
</head> |
||||
|
||||
<table> |
||||
|
||||
<tr> |
||||
<td colspan="2" style=" |
||||
background: {{OB_COLOR}} ; |
||||
border-bottom: 1px solid {{OB_COLOR_2}} ; |
||||
padding: 2px 5px ; |
||||
font-size: 105% ; font-weight: bold ; |
||||
"> |
||||
{{INCLUDE:player_flag_large}}Bazooka Type 51 |
||||
|
||||
<tr> |
||||
|
||||
<td style="padding:0 5px;"> |
||||
<table> |
||||
<tr> |
||||
<td class="c"> Range <td class="c" width="35"> <b>TH#</b> |
||||
<tr> |
||||
<td class="c"> 0 <td class="c"> 10 |
||||
<tr> |
||||
<td class="c"> 1 <td class="c"> 9 |
||||
<tr> |
||||
<td class="c"> 2 <td class="c"> 8 |
||||
<tr> |
||||
<td class="c"> 3 <td class="c"> 7 |
||||
<tr> |
||||
<td class="c"> 4 <td class="c"> 5 |
||||
<tr> |
||||
<td class="c"> 5 <td class="c"> 3 |
||||
</table> |
||||
|
||||
<td valign="top" style="padding:0 5px;"> |
||||
<table> |
||||
<tr> |
||||
<td> <b>TK#:</b> |
||||
<td class="r"> 22 |
||||
<tr> |
||||
<td colspan="2" class="r"> 12-5 |
||||
<tr> |
||||
<td> |
||||
<tr> |
||||
<td> <b>X#:</b> |
||||
<td class="r"> 10 |
||||
</table> |
||||
|
||||
</table> |
||||
|
||||
</html> |
@ -0,0 +1,60 @@ |
||||
<html> <!-- vasl-templates:id {{SNIPPET_ID}} --> |
||||
|
||||
<head> |
||||
<meta charset="utf-8"> |
||||
<style> |
||||
{{CSS:common}} |
||||
</style> |
||||
</head> |
||||
|
||||
<table> |
||||
|
||||
<tr> |
||||
<td colspan="2" style=" |
||||
background: {{OB_COLOR}} ; |
||||
border-bottom: 1px solid {{OB_COLOR_2}} ; |
||||
padding: 2px 5px ; |
||||
font-size: 105% ; font-weight: bold ; |
||||
"> |
||||
{{INCLUDE:player_flag_large}}Bazooka '45 |
||||
|
||||
<tr> |
||||
|
||||
<td style="padding:0 5px;"> |
||||
<table> |
||||
<tr> |
||||
<td class="c"> Range <td class="c" width="35"> <b>TH#</b> |
||||
<tr> |
||||
<td class="c"> 0 <td class="c"> 11 |
||||
<tr> |
||||
<td class="c"> 1 <td class="c"> 10 |
||||
<tr> |
||||
<td class="c"> 2 <td class="c"> 9 |
||||
<tr> |
||||
<td class="c"> 3 <td class="c"> 8 |
||||
<tr> |
||||
<td class="c"> 4 <td class="c"> 6 |
||||
<tr> |
||||
<td class="c"> 5 <td class="c"> 4 |
||||
</table> |
||||
|
||||
<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> |
||||
<tr> |
||||
<td> <b>X#:</b> |
||||
<td class="r"> 11 |
||||
<tr> |
||||
<td> <b>WP#:</b> |
||||
<td class="r"> 6 |
||||
</table> |
||||
|
||||
</table> |
||||
|
||||
</html> |
@ -0,0 +1,62 @@ |
||||
<html> <!-- vasl-templates:id {{SNIPPET_ID}} --> |
||||
|
||||
<head> |
||||
<meta charset="utf-8"> |
||||
<style> |
||||
{{CSS:common}} |
||||
</style> |
||||
</head> |
||||
|
||||
<table> |
||||
|
||||
<tr> |
||||
<td colspan="2" style=" |
||||
background: {{OB_COLOR}} ; |
||||
border-bottom: 1px solid {{OB_COLOR_2}} ; |
||||
padding: 2px 5px ; |
||||
font-size: 105% ; font-weight: bold ; |
||||
"> |
||||
{{INCLUDE:player_flag_large}}Bazooka '50 |
||||
|
||||
<tr> |
||||
|
||||
<td style="padding:0 5px;"> |
||||
<table> |
||||
<tr> |
||||
<td class="c"> Range <td class="c" width="35"> <b>TH#</b> |
||||
<tr> |
||||
<td class="c"> 0 <td class="c"> 11 |
||||
<tr> |
||||
<td class="c"> 1 <td class="c"> 10 |
||||
<tr> |
||||
<td class="c"> 2 <td class="c"> 9 |
||||
<tr> |
||||
<td class="c"> 3 <td class="c"> 8 |
||||
<tr> |
||||
<td class="c"> 4 <td class="c"> 6 |
||||
<tr> |
||||
<td class="c"> 5 <td class="c"> 4 |
||||
</table> |
||||
|
||||
<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> |
||||
<tr> |
||||
<td> <b>X#:</b> |
||||
<td class="r"> 11 |
||||
{%if SCENARIO_YEAR >= 1952%} |
||||
<tr> |
||||
<td> <b>WP#:</b> |
||||
<td class="r"> 6 |
||||
{%endif%} |
||||
</table> |
||||
|
||||
</table> |
||||
|
||||
</html> |
@ -0,0 +1,25 @@ |
||||
body { |
||||
{%if SNIPPET_FONT_FAMILY%} font-family: "{{SNIPPET_FONT_FAMILY}}" ; {%endif%} |
||||
{%if SNIPPET_FONT_SIZE%} font-size: {{SNIPPET_FONT_SIZE}} ; {%endif%} |
||||
} |
||||
|
||||
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 ; } |
||||
td.c { text-align: center ; } |
||||
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 ; } |
@ -0,0 +1,5 @@ |
||||
<html> <!-- vasl-templates:id {{SNIPPET_ID}} --> |
||||
|
||||
<img src="{{IMAGES_BASE_URL}}/compass/{{COMPASS}}.png"> |
||||
|
||||
</html> |
@ -0,0 +1,11 @@ |
||||
<html> <!-- vasl-templates:id {{SNIPPET_ID}} --> |
||||
|
||||
<!-- vasl-templates:name Blank space --> |
||||
<!-- vasl-templates:description Generates a white label that can be used to cover up and hide things in your scenario. --> |
||||
|
||||
<table> |
||||
<tr> |
||||
<td style="width:{{WIDTH:60px/4|Width}};height:{{HEIGHT:60px/4|Height}};background:white;"> |
||||
</table> |
||||
|
||||
</html> |
@ -0,0 +1,41 @@ |
||||
<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}}"> 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" %} ≥ 11 |
||||
{% elif LEVEL == "B" %} 11 |
||||
{% elif LEVEL == "C" %} 12 |
||||
{%else%} ??? {%endif%} |
||||
<li> Search Casualties |
||||
</ul> |
||||
|
||||
</table> |
@ -0,0 +1,47 @@ |
||||
<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: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;"' %} |
||||
<td> |
||||
<h3>Panzerfaust</h3> |
||||
<table class="pf"> |
||||
<tr> <td class="key"> German (-'44) |
||||
<td class="val" {%if (PLAYER_1 == "german" or PLAYER_2 == "german") and SCENARIO_YEAR and SCENARIO_YEAR < 1944%} {{HILITE_STYLE}} {%endif%} > #squads |
||||
<tr> <td class="key"> German ('44) |
||||
<td class="val" {%if (PLAYER_1 == "german" or PLAYER_2 == "german") and SCENARIO_YEAR == 1944%} {{HILITE_STYLE}} {%endif%}> 1½ × #squads (FRD) |
||||
<tr> <td class="key"> German ('45) |
||||
<td class="val" {%if (PLAYER_1 == "german" or PLAYER_2 == "german") and SCENARIO_YEAR == 1945%} {{HILITE_STYLE}} {%endif%}> 2 × #squads |
||||
<tr> <td class="key"> Finnish (7/44+) |
||||
<td class="val" {%if (PLAYER_1 == "finnish" or PLAYER_2 == "finnish") and (SCENARIO_YEAR >= 1945 or (SCENARIO_YEAR == 1944 and SCENARIO_MONTH >= 7))%} {{HILITE_STYLE}} {%endif%}> 1½ × # Elite/1<sup>st</sup> Line MMC squads (FRD) |
||||
<tr> <td class="key"> Hungarian (6/44+) |
||||
<td class="val" {%if (PLAYER_1 == "hungarian" or PLAYER_2 == "hungarian") and (SCENARIO_YEAR >= 1945 or (SCENARIO_YEAR == 1944 and SCENARIO_MONTH >= 6))%} {{HILITE_STYLE}} {%endif%}> #squads |
||||
<tr> <td class="key"> Romanian (3-12/44) |
||||
<td class="val" {%if (PLAYER_1 == "romanian" or PLAYER_2 == "romanian") and SCENARIO_YEAR == 1944 and SCENARIO_MONTH >= 3%} {{HILITE_STYLE}} {%endif%}> 1½ × #squads |
||||
<tr> <td class="key"> Romanian ('45) |
||||
<td class="val" {%if (PLAYER_1 == "romanian" or PLAYER_2 == "romanian") and SCENARIO_YEAR == 1945%} {{HILITE_STYLE}} {%endif%}> #squads |
||||
</table> |
||||
<em> Squads or squad-equivalents. </em> |
||||
|
||||
<td style="padding-left: 1em;"> |
||||
<h3>Tank-Hunter Heroes</h3> |
||||
<table class="thh"> |
||||
<tr> <td class="key"> before '43 |
||||
<td class="val" {%if (PLAYER_1 == "japanese" or PLAYER_2 == "japanese") and SCENARIO_YEAR and SCENARIO_YEAR < 1943%} {{HILITE_STYLE}} {%endif%}> 10% of #squads (FRU) <br> <small><em>(20% vs. Russians)</em></small> |
||||
<tr> <td class="key"> '43 |
||||
<td class="val" {%if (PLAYER_1 == "japanese" or PLAYER_2 == "japanese") and SCENARIO_YEAR == 1943%} {{HILITE_STYLE}} {%endif%}> 20% of #squads (FRU) |
||||
<tr> <td class="key"> '44 |
||||
<td class="val" {%if (PLAYER_1 == "japanese" or PLAYER_2 == "japanese") and SCENARIO_YEAR == 1944%} {{HILITE_STYLE}} {%endif%}> 33% of #squads (FRU) |
||||
<tr> <td class="key"> '45 |
||||
<td class="val" {%if (PLAYER_1 == "japanese" or PLAYER_2 == "japanese") and SCENARIO_YEAR == 1945%} {{HILITE_STYLE}} {%endif%}> 50% of #squads (FRU) |
||||
</table> |
||||
<em> Squads only, not squad-equivalents. </em> |
||||
</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}} |
@ -0,0 +1,49 @@ |
||||
<html> <!-- vasl-templates:id {{SNIPPET_ID}} --> |
||||
|
||||
<!-- vasl-templates:name Grid --> |
||||
<!-- vasl-templates:description Generates a grid. --> |
||||
|
||||
<!-- caption = {{CAPTION*:/25|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}} --> |
||||
<!-- color = {{PLAYER_COLOR_DROPLIST:|Border color}} ; border = {{BORDER_STYLE:solid::dotted::dashed::double::groove::ridge::inset::outset|Border style}} --> |
||||
|
||||
<head> |
||||
<style> |
||||
{{CSS:common}} |
||||
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%} |
||||
{%endif%} {%endfor%} |
||||
</table> |
||||
|
||||
</html> |
@ -0,0 +1,62 @@ |
||||
<html> <!-- vasl-templates:id {{SNIPPET_ID}} --> |
||||
|
||||
<!-- vasl-templates:name Hidden Guns --> |
||||
<!-- vasl-templates:description HIP Guns for Solo Play, taken from <a href="http://vftt.co.uk/vfttpdfs.asp"><i>View From The Trenches</i></a>, Issue 34/35. --> |
||||
|
||||
<head> |
||||
<meta charset="utf-8"> |
||||
<style> |
||||
{{CSS:common}} |
||||
td { margin: 0 ; padding: 0 5px ; text-align: center ; } |
||||
td.header { background: #f0f0f0 ; border-bottom: 1px solid #c0c0c0 ; padding: 2px 5px ; font-size: 105% ; font-weight: bold ; } |
||||
td.header2 { background: #f0f0f0 ; border-bottom: 1px dotted #c0c0c0 ; padding: 2px 5px ; font-weight: bold ; } |
||||
td.header3 { font-style: italic ; font-weight: bold ; } |
||||
td.status { font-weight: bold ; text-align: left ; } |
||||
</style> |
||||
</head> |
||||
|
||||
<table> |
||||
|
||||
<tr> |
||||
<td colspan="5" class="header"> |
||||
<center> Hidden Guns </center> |
||||
|
||||
<tr> |
||||
<td> |
||||
<td class="header2" colspan=2> Hidden |
||||
<td class="header2" colspan=2> Possible |
||||
<tr> |
||||
<td> |
||||
<td class="header3"> Fires |
||||
<td class="header3"> Flip |
||||
<td class="header3"> Fires |
||||
<td class="header3"> Remove |
||||
|
||||
<tr> |
||||
<td class="status"> H H H |
||||
<td> 2-5 <td> 6+ <td> <td> |
||||
<tr> |
||||
<td class="status"> H H P |
||||
<td> 2-6 <td> 7+ <td> 2-3 <td> 4-11 |
||||
<tr> |
||||
<td class="status"> H P P |
||||
<td> 2-7 <td> 8+ <td> 2-4 <td> 5-11 |
||||
<tr> |
||||
<td class="status"> H H |
||||
<td> 2-6 <td> 7+ <td> <td> |
||||
<tr> |
||||
<td class="status"> H P |
||||
<td> 2-8 <td> 9+ <td> 2-4 <td> 5-11 |
||||
<tr> |
||||
<td class="status"> P P P |
||||
<td> <td> <td> 2-5 <td> 6-10 |
||||
<tr> |
||||
<td class="status"> P P |
||||
<td> <td> <td> 2-6 <td> 7-10 |
||||
|
||||
</table> |
||||
|
||||
Leadership DRM's apply. |
||||
|
||||
</html> |
||||
|
@ -0,0 +1,49 @@ |
||||
<html> <!-- vasl-templates:id {{SNIPPET_ID}} --> |
||||
|
||||
<!-- vasl-templates:name Kakazu Ridge --> |
||||
<!-- vasl-templates:description Generates a box to hold units hidden away in a Cave Complex. --> |
||||
|
||||
<head> |
||||
<style> |
||||
{{CSS:common}} |
||||
/* {{CAVE_COMPLEX:Kakazu West::Kakazu Saddle::Kakazu Center::Kakazu Front::Kakazu Reverse::Kakazu East::Kakazu Village::(other)/10|Cave Complex}} */ |
||||
.box { |
||||
width: {{WIDTH:270px/5|Width}} ; |
||||
height: {{HEIGHT:150px/5|Height}} ; |
||||
border: 1px solid #ffdb00 ; |
||||
color: #404040 ; |
||||
} |
||||
.header { background: #fff200 ; padding: 2px 5px ; } |
||||
.header .name { font-size: 105% ; font-weight: bold ; } |
||||
.hexes { font-size: 90% ; font-style: italic ; color: #606060 ; } |
||||
</style> |
||||
</head> |
||||
|
||||
<div class="box"> |
||||
<div class="header"> |
||||
{% if CAVE_COMPLEX == "Kakazu West" %} |
||||
<span class="name"> {{CAVE_COMPLEX}} </span> (15) |
||||
<div class="hexes"> E10, F10-12, G10-14, H10-14, I11-12 <div> |
||||
{% elif CAVE_COMPLEX == "Kakazu Saddle" %} |
||||
<span class="name"> {{CAVE_COMPLEX}} </span> (12) |
||||
<div class="hexes"> J11-12, K11-14, L10-14, M9-13, N8-12 </div> |
||||
{% elif CAVE_COMPLEX == "Kakazu Center" %} |
||||
<span class="name"> {{CAVE_COMPLEX}} </span> (20) |
||||
<div class="hexes"> N13, O9-13, P8-12, Q8-13, R7-9 </div> |
||||
{% elif CAVE_COMPLEX == "Kakazu Front" %} |
||||
<span class="name"> {{CAVE_COMPLEX}} </span> (20) |
||||
<div class="hexes"> R10-11, S8-12, T8-12, U9-12, V8-12, W9-12, X9-11 </div> |
||||
{% elif CAVE_COMPLEX == "Kakazu Reverse" %} |
||||
<span class="name"> {{CAVE_COMPLEX}} </span> (15) |
||||
<div class="hexes"> R12-13, S13-15, T13-15, U13-15, V13 </div> |
||||
{% elif CAVE_COMPLEX == "Kakazu East" %} |
||||
<span class="name"> {{CAVE_COMPLEX}} </span> (15) |
||||
<div class="hexes"> W13, X12-13, Y11-14, Z11-14, AA11-14, BB13 </div> |
||||
{% elif CAVE_COMPLEX == "Kakazu Village" %} |
||||
<span class="name"> {{CAVE_COMPLEX}} </span> (12) |
||||
<div class="hexes"> L15-17, M14-18, N14-18, O15-19, P15-19, Q16-20, R16-19, S17-19 </div> |
||||
{% else %} |
||||
<span class="name"> Cave Complex </span> |
||||
{%endif%} |
||||
</div> |
||||
</div> |
@ -0,0 +1,87 @@ |
||||
<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}}"> {%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 ≤ 3 (△) |
||||
<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 ≤ 3 (△) |
||||
<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,14 @@ |
||||
<html> <!-- vasl-templates:id {{SNIPPET_ID}} --> |
||||
|
||||
<!-- vasl-templates:name Turn Track shading --> |
||||
<!-- vasl-templates:description Generates a shaded square that you can place behind the Turn Track to indicate an LV Hindrance e.g. because of dusk/dawn. --> |
||||
|
||||
<table> <tr> |
||||
<td style=" |
||||
width: {{WIDTH:45px/4|Width}} ; |
||||
height: {{HEIGHT:45px/4|Height}} ; |
||||
background: {{COLOR:#f0f0f0/8|Color}} ; |
||||
"> |
||||
</table> |
||||
|
||||
</html> |
@ -0,0 +1,9 @@ |
||||
<html> <!-- vasl-templates:id {{SNIPPET_ID}} --> |
||||
|
||||
<!-- vasl-templates:name Victory Points --> |
||||
<!-- vasl-templates:description Add a label to keep track of your victory points, and press <i>Ctrl-L</i> when you need to update them. --> |
||||
<!-- 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: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 |
@ -0,0 +1,57 @@ |
||||
<html> <!-- vasl-templates:id {{SNIPPET_ID}} --> |
||||
|
||||
<head> |
||||
<meta charset="utf-8"> |
||||
<style> |
||||
{{CSS:common}} |
||||
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> |
||||
|
||||
<table> |
||||
|
||||
<tr> <td style=" |
||||
background: {{OB_COLOR}} ; |
||||
border-bottom: 1px solid {{OB_COLOR_2}} ; |
||||
font-size: 105% ; font-weight: bold ; |
||||
"> |
||||
<nobr>{{INCLUDE:player_flag_large}}{{PLAYER_NAME|nbsp}} Capabilities</nobr> |
||||
|
||||
<tr> <td> |
||||
|
||||
{%if NAT_CAPS%} |
||||
|
||||
<ul> |
||||
{%if NAT_CAPS.GRENADES%} <li class="grenades"> {{NAT_CAPS.GRENADES}} {%endif%} |
||||
{%if NAT_CAPS.HOB_DRM%} <li class="hob-drm"> Heat of Battle: {{NAT_CAPS.HOB_DRM}} {%endif%} |
||||
{%if NAT_CAPS.TH_COLOR%} <li class="th-color"> {{NAT_CAPS.TH_COLOR}} {%endif%} |
||||
{%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%} |
||||
{%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%} |
||||
|
||||
{%else%} |
||||
|
||||
Not available. |
||||
|
||||
{%endif%} |
||||
|
||||
</table> |
||||
|
||||
</html> |
@ -0,0 +1,467 @@ |
||||
{ |
||||
|
||||
"german": { |
||||
"th_color": "Black", |
||||
"oba": [ "8B", "3R" ], "oba_access": "≤ 2", |
||||
"hob_drm": "0 DRM", |
||||
"grenades": "Smoke", |
||||
"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 & RtPh Surrender NA <br> vs Russians", |
||||
"Massacre OK", |
||||
"{? 01/1944- | Squad Assault Fire | No Squad Assault Fire | Squad Assault Fire<sup>44+</sup> ?}" |
||||
] } |
||||
] |
||||
}, |
||||
|
||||
"russian": { |
||||
"th_color": "Red", |
||||
"oba": [ "5B", "2R" ], "oba_access": "≤ 1", |
||||
"hob_drm": "+2 DRM", |
||||
"grenades": null, |
||||
"notes": [ |
||||
"Massacre OK", |
||||
"Deploy NA", |
||||
"Entrench -1 DRM", |
||||
"{? -10/1942 | Commissars | Commissars NA | Commissars<sup>-10/42</sup> ?}", |
||||
"Human Wave", |
||||
"{? 01/1942- | Riders OK | Riders NA | Riders<sup>42+</sup> ?}" |
||||
] |
||||
}, |
||||
|
||||
"american": { |
||||
"th_color": "{? 01/1944- | Black | Red | Black<sup>44+</sup> ?}", |
||||
"oba": [ "10B", "3R", "Plentiful Ammo included" ], |
||||
"oba_access": "≤ 2", |
||||
"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" |
||||
] } |
||||
] |
||||
}, |
||||
"kfw-american": { |
||||
"th_color": "{! 06/1950-08/1950 = Red | 09/1950- = Black | ??? !}", |
||||
"oba": [ "{! 06/1950-08/1950 = 9B | 09/1950- = 10B | ??? !}", "3R", |
||||
"{! 09/1950- = Plentiful Ammo included !}" |
||||
], |
||||
"oba_access": "≤ 2", |
||||
"hob_drm": [ "0 DRM", "+3 for Katusa; NA for TACP" ], |
||||
"grenades": "SMOKE", |
||||
"notes": [ |
||||
"{! 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> !}", |
||||
"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) & 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" |
||||
] } |
||||
] |
||||
}, |
||||
|
||||
"british": { |
||||
"th_color": "Black", |
||||
"oba": [ "8B", "2R" ], "oba_access": "≤ 2", |
||||
"hob_drm": "-1 DRM", |
||||
"grenades": "{? 01/1944- | SMOKE | Smoke | SMOKE<sup>44+</sup> ?}", |
||||
"notes": [ |
||||
{ "caption": "Elite & 1st Line", "notes": [ |
||||
"Cowering NA" |
||||
] }, |
||||
{ "caption": "ANZAC", "notes": [ |
||||
"Stealthy (unless Green)" |
||||
] }, |
||||
{ "caption": "Gurkha", "notes": [ |
||||
"-1 CC DRM", |
||||
"Disrupt & RtPh Surrender NA", |
||||
"Commando (unless Green)", |
||||
"Stealthy" |
||||
] } |
||||
] |
||||
}, |
||||
|
||||
"french": { |
||||
"th_color": [ "Black", "AFV use Red TH#" ], |
||||
"oba": [ "6B", "2R" ], "oba_access": "≤ 1", |
||||
"hob_drm": "+1 DRM", |
||||
"grenades": "Smoke" |
||||
}, |
||||
"free-french": { |
||||
"th_color": "Black", |
||||
"oba": [ "8B", "2R" ], "oba_access": "≤ 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 & 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> ?}" |
||||
] } |
||||
] |
||||
}, |
||||
|
||||
|
||||
"italian": { |
||||
"th_color": "Red", |
||||
"oba": [ "7B", "3R" ], "oba_access": "≤ 1", |
||||
"hob_drm": "+3 DRM", |
||||
"grenades": "Smoke", |
||||
"notes": [ |
||||
"Escape NA", |
||||
{ "caption": "1st Line & Conscript", "notes": [ |
||||
"Surrender on HoB Final DR ≥ 10", |
||||
"Deploy NA", |
||||
"+1 CC Capture DRM NA", |
||||
"Always Lax", |
||||
"1 PAATC" |
||||
] } |
||||
] |
||||
}, |
||||
|
||||
"finnish": { |
||||
"th_color": "Red", |
||||
"oba": [ |
||||
"{! 01/1939-12/1940 = 6B | 01/1941-12/1942 = 7B | 01/1943-09/1944 = 8B | 10/1944- = 7B | ??? !}", |
||||
"3R", |
||||
"Plentiful Ammo included" |
||||
], |
||||
"oba_access": "≤ 1", |
||||
"hob_drm": "-1 DRM", |
||||
"grenades": null, |
||||
"notes": [ |
||||
"Deploy (1TC) & Recombine without Leader", |
||||
"Self-Rally OK [EXC: Conscript]", |
||||
"Cowering NA [EXC: Conscript]", |
||||
"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 & 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 → Fanatic" |
||||
] }, |
||||
{ "caption": "Allied Troops", "notes": [ |
||||
"Captured Use penalties NA" |
||||
] } |
||||
] |
||||
}, |
||||
|
||||
"axis-minor": { |
||||
"th_color": "Red", |
||||
"oba": [ "6B", "3R" ], "oba_access": "≤ 1", |
||||
"hob_drm": "+3 DRM", |
||||
"grenades": "Smoke", |
||||
"notes": [ |
||||
"Escape NA", |
||||
{ "caption": "1st Line & Conscript", "notes": [ |
||||
"1 PAATC", |
||||
"Surrender on HoB Final DR ≥ 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> ?}" |
||||
] } |
||||
] |
||||
}, |
||||
|
||||
"allied-minor": { |
||||
"th_color": "Red", |
||||
"oba": [ "6B", "3R" ], "oba_access": "≤ 1", |
||||
"hob_drm": "+2 DRM", |
||||
"grenades": "Smoke", |
||||
"notes": [ |
||||
"+1 Broken Morale vs Italians", |
||||
{ "caption": "1st Line & Green", "notes": [ |
||||
"1 PAATC" |
||||
] } |
||||
] |
||||
}, |
||||
|
||||
"japanese": { |
||||
"th_color": "Black", |
||||
"oba": [ "5B", "2R" ], "oba_access": "≤ 1", |
||||
"hob_drm": "+4 DRM", |
||||
"grenades": "SMOKE", |
||||
"notes": [ |
||||
"SMC PTC/Pin/Break NA", |
||||
"Tank-Hunter Heroes & ATMM", |
||||
"Banzai Charge (always Lax)", |
||||
"ATR/MMG/HMG Breakdown penalty", |
||||
"LLMC → LLTC if unbroken", |
||||
"Massacre OK", |
||||
"-1 Interrogation DRM", |
||||
"-2 Concealment drm", |
||||
"Enemy +2 search drm", |
||||
"Hand-to-Hand CC & Hara-Kiri", |
||||
{ "caption": "Leaders", "notes": [ |
||||
"Replacement NA", |
||||
"Casualty MC → elimination", |
||||
"Morale/Rally/Berserk as Commissar" |
||||
] }, |
||||
{ "caption": "Elite & 1st Line", "notes": [ |
||||
"Always Stealthy" |
||||
] }, |
||||
{ "caption": "Conscript", "notes": [ |
||||
"Always Lax" |
||||
] }, |
||||
{ "caption": "Always NA", "notes": [ |
||||
"PAATC", |
||||
"Escape", |
||||
"RtPh Surrender", |
||||
"Disruption", |
||||
"Encircled lower Morale", |
||||
"Leader Creation" |
||||
] } |
||||
] |
||||
}, |
||||
|
||||
"chinese~gmd": { |
||||
"th_color": "Red", |
||||
"oba": [ "5B", "2R", |
||||
"6B/2R if Majority Squad Type is 5-3-7", |
||||
"5B/3R if Majority Squad Type is 3-3-7 or 3-3-6" |
||||
], |
||||
"oba_access": "≤ 1", |
||||
"hob_drm": "0 DRM", |
||||
"grenades": "SMOKE", |
||||
"notes": [ |
||||
"Deploy NA", |
||||
"Lax at Night", |
||||
"+1 Leader Creation drm", |
||||
"Human Wave", |
||||
"Dare-Death Squads [EXC: 5-3-7]", |
||||
{ "caption": "1st Line & Conscript", "notes": [ |
||||
"1 PAATC" |
||||
] } |
||||
] |
||||
}, |
||||
|
||||
"chinese": { |
||||
"th_color": "Red", |
||||
"oba": null, |
||||
"hob_drm": "+1 DRM", |
||||
"grenades": null, |
||||
"notes": [ |
||||
"Cowering NA", |
||||
"Commissars", |
||||
"Human Wave", |
||||
"Dare-Death Squads" |
||||
] |
||||
}, |
||||
|
||||
"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 | ??? !}", |
||||
"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 !}" |
||||
], |
||||
"oba_access": "≤ 1 (ROK) ; 2 (KMC)", |
||||
"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 & 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) ?}" |
||||
] } |
||||
] |
||||
}, |
||||
|
||||
"kfw-bcfk": { |
||||
"th_color": "Black", |
||||
"oba": [ "8B", "2R" ], "oba_access": "≤ 2", |
||||
"hob_drm": "-1 DRM", |
||||
"grenades": "SMOKE", |
||||
"notes": [ |
||||
{ "caption": "2nd Line MMC", "notes": [ |
||||
"ELR Replacement → 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) & Self-Recombine OK" |
||||
] } |
||||
] |
||||
}, |
||||
|
||||
"kfw-ounc": { |
||||
"th_color": "Black", |
||||
"oba": [ "9B", "3R" ], "oba_access": "≤ 1", |
||||
"hob_drm": [ "0 DRM", "+3 for Turkish" ], |
||||
"grenades": "SMOKE", |
||||
"notes": [ |
||||
{ "caption": "2nd Line MMC", "notes": [ |
||||
"ELR Replacement → Disrupt [EXC: Turkish]" |
||||
] }, |
||||
{ "caption": "Ethiopian, French, Turkish", "notes": [ |
||||
"Bayonet Charge NTC NA for leaders" |
||||
] } |
||||
] |
||||
}, |
||||
|
||||
"kfw-kpa": { |
||||
"th_color": "Red", |
||||
"oba": [ "5B", "2R" ], "oba_access": "≤ 1", |
||||
"hob_drm": "+2 DRM", |
||||
"grenades": null, |
||||
"notes": [ |
||||
"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 & RtPh Surrender NA" |
||||
] } |
||||
] |
||||
}, |
||||
|
||||
"kfw-cpva": { |
||||
"th_color": "Red", |
||||
"oba": [ |
||||
"{? 04/1951- | 7B | | 7B<sup>4/51+</sup> ?}", |
||||
"{! 04/1951-09/1952 = 3R | 10/1952- = 2R | ??? !}" |
||||
], |
||||
"oba_access": "≤ 1", |
||||
"hob_drm": "+1 DRM", |
||||
"grenades": null, |
||||
"notes": [ |
||||
"Always Stealthy", |
||||
"Starshell restrictions", |
||||
"Armored Assault NA", |
||||
"Riders NA", |
||||
"{! 10/1950-03/1951 = Early KW CPVA rules !}", |
||||
"Leaders & Political Officers increase Morale <br> as if Commissar", |
||||
"SW B#/X#/ROF penalty", |
||||
"Restricted Fire", |
||||
"Infantry Platoon Movement", |
||||
"Hand-to-Hand CC (-1 DRM)", |
||||
"HS Infantry Overrun", |
||||
"Bugles", |
||||
"Entrench -1 DRM", |
||||
"PAATC NTC NA", |
||||
"Infantry Overrun NTC NA", |
||||
"Conceal if +2 Hindrance", |
||||
"Concealment -1 drm", |
||||
"Civilian Interrogation is always in effect", |
||||
{ "caption": "Assault Engineers", "notes": [ |
||||
"WP grenades" |
||||
] } |
||||
] |
||||
}, |
||||
|
||||
"burmese": { |
||||
"th_color": "Red", |
||||
"oba": null, |
||||
"hob_drm": "+2 DRM", |
||||
"grenades": null, |
||||
"notes": [ |
||||
"Dare-Death Squads (as if Chinese)", |
||||
"Deploy NA [EXC: A20.5 & A21.22]; Recombine OK", |
||||
{ "caption": "Elite and 1st Line MMC", "notes": [ |
||||
"Always Stealthy" |
||||
] }, |
||||
{ "caption": "Leaders", "notes": [ |
||||
"Morale/Berserk/Rally as Commissar" |
||||
] } |
||||
] |
||||
}, |
||||
|
||||
"indonesian": { |
||||
"th_color": "Red", |
||||
"oba": [ "5B", "3R" ], |
||||
"hob_drm": "+3 DRM", |
||||
"grenades": "Smoke", |
||||
"notes": [ |
||||
"Tank-Hunter/DC Heroes (as if 1945 Japanese)", |
||||
"Hand-to-Hand Combat", |
||||
"Massacre OK", |
||||
"HoB DR ≥ 12 → Berserk", |
||||
"Deploy NA [EXC: A20.5 & A21.22]; Recombine OK" |
||||
] |
||||
}, |
||||
|
||||
"thai": { |
||||
"th_color": "Black", |
||||
"oba": [ "7B", "3R" ], |
||||
"hob_drm": "0 DRM", |
||||
"grenades": "Smoke" |
||||
} |
||||
|
||||
} |
@ -0,0 +1,59 @@ |
||||
<html> <!-- vasl-templates:id {{SNIPPET_ID}} --> |
||||
|
||||
<head> |
||||
<meta charset="utf-8"> |
||||
<style> |
||||
{{CSS:common}} |
||||
.ma-note { margin: 2px 0 3px 0 ; text-align: justify ; } |
||||
.ma-note .key { font-weight: bold ; } |
||||
.ma-note p { margin-top: 2px ; } |
||||
.ma-note table { margin-left: 10px ; } |
||||
.ma-note li { margin-bottom: 2px ; } |
||||
.ma-note .example { font-size: 95% ; font-style: italic ; color: #000080 ; } |
||||
.ma-note p.errata { margin-top: 0 ; font-size: 95% ; font-style: italic ; color: #704040 ; } |
||||
.ma-note span.errata { font-style: italic ; color: #704040 ; } |
||||
.ma-note table { margin-left: 10px ; margin-top: -5px ; } |
||||
.ma-note table th { padding: 2px 10px 2px 5px ; text-align: left ; background: #f0f0f0 ; } |
||||
.ma-note table td { padding: 0 10px 0 5px ; } |
||||
.extra-notes-caption { border: 1px solid #e0e0e0 ; background: #fcfcfc ; font-weight: bold ; padding: 2px 5px ; } |
||||
.disabled { color: #808080 ; } |
||||
.disabled .exc { color: #808080 ; } |
||||
.slashed { text-decoration: line-through ; } |
||||
</style> |
||||
</head> |
||||
|
||||
<table style=" |
||||
{%if OB_MA_NOTES_WIDTH%} width: {{OB_MA_NOTES_WIDTH}} ; {%endif%} |
||||
"> |
||||
|
||||
<tr> |
||||
<td colspan="2" style=" |
||||
background: {{OB_COLOR}} ; |
||||
border-bottom: 1px solid {{OB_COLOR_2}} ; |
||||
padding: 2px 5px ; |
||||
font-size: 105% ; font-weight: bold ; |
||||
"> |
||||
{{INCLUDE:player_flag_large}}{{PLAYER_NAME}} {{VO_TYPE}} Notes |
||||
|
||||
{%if OB_MA_NOTES%} |
||||
<tr> <td style="padding:0 5px;"> |
||||
{%for ma_note in OB_MA_NOTES%} |
||||
{%if not ma_note[0]%} <div class="disabled"> {%endif%} |
||||
<div class="ma-note"> {{ma_note[1]}} </div> |
||||
{%if not ma_note[0]%} </div> {%endif%} |
||||
{%endfor%} |
||||
{%endif%} |
||||
|
||||
{%if OB_EXTRA_MA_NOTES%} |
||||
<tr> <td style="padding:0 5px;"> |
||||
{%if OB_EXTRA_MA_NOTES_CAPTION%} <div class="extra-notes-caption"> {{OB_EXTRA_MA_NOTES_CAPTION}} </div> {%endif%} |
||||
{%for ma_note in OB_EXTRA_MA_NOTES%} |
||||
{%if not ma_note[0]%} <div class="disabled"> {%endif%} |
||||
<div class="ma-note"> {{ma_note[1]}} </div> |
||||
{%if not ma_note[0]%} </div> {%endif%} |
||||
{%endfor%} |
||||
{%endif%} |
||||
|
||||
</table> |
||||
|
||||
</html> |
@ -1,40 +0,0 @@ |
||||
<html> |
||||
|
||||
<head> |
||||
<style> |
||||
td { margin: 0 ; padding: 0 ; } |
||||
.note { font-size: 90% ; font-style: italic ; color: #808080 ; } |
||||
</style> |
||||
</head> |
||||
|
||||
<table style=" |
||||
{%if OB_ORDNANCE_WIDTH%} width: {{OB_ORDNANCE_WIDTH}} ; {%endif%} |
||||
"> |
||||
|
||||
<tr> |
||||
<td colspan="2" style=" |
||||
background: {{OB_COLOR}} ; |
||||
border-bottom: 1px solid {{OB_COLOR_2}} ; |
||||
padding: 2px 5px ; |
||||
font-weight: bold ; |
||||
"> |
||||
{{PLAYER_NAME}} Ordnance |
||||
|
||||
{%for ord in OB_ORDNANCE%} |
||||
<tr style="border-bottom:1px dotted #e0e0e0;"> |
||||
<td valign="top" style="padding:2px 5px;"> |
||||
<b> {{ord.name}} </b> |
||||
<div class="note"> |
||||
{%if ord.notes%} |
||||
{{ord.note_number}}, {{ord.notes | join(", ")}} |
||||
{%else%} |
||||
{{ord.note_number}} |
||||
{%endif%} |
||||
</div> |
||||
<td valign="top" style="padding:2px 5px;"> |
||||
{%for cap in ord.capabilities%} <div> {{cap}} </div> {%endfor%} |
||||
{%endfor%} |
||||
|
||||
</table> |
||||
|
||||
</html> |
@ -1,43 +0,0 @@ |
||||
<html> |
||||
|
||||
<head> |
||||
<style> |
||||
td { margin: 0 ; padding: 0 ; } |
||||
.note { font-size: 90% ; font-style: italic ; color: #808080 ; } |
||||
</style> |
||||
</head> |
||||
|
||||
<table style=" |
||||
{%if OB_VEHICLES_WIDTH%} width: {{OB_VEHICLES_WIDTH}} ; {%endif%} |
||||
"> |
||||
|
||||
<tr> |
||||
<td colspan="2" style=" |
||||
background: {{OB_COLOR}} ; |
||||
border-bottom: 1px solid {{OB_COLOR_2}} ; |
||||
padding: 2px 5px ; |
||||
font-weight: bold ; |
||||
"> |
||||
{{PLAYER_NAME}} Vehicles |
||||
|
||||
{%for veh in OB_VEHICLES%} |
||||
<tr style="border-bottom:1px dotted #e0e0e0;"> |
||||
<td valign="top" style="padding:2px 5px;"> |
||||
<b> {{veh.name}} </b> |
||||
<div class="note"> |
||||
{%if veh.notes%} |
||||
{{veh.note_number}}, {{veh.notes | join(", ")}} |
||||
{%else%} |
||||
{{veh.note_number}} |
||||
{%endif%} |
||||
</div> |
||||
<td valign="top" style="padding:2px 5px;"> |
||||
{%if veh.no_radio%} <div> {{veh.no_radio}} </div> {%endif%} |
||||
{%if veh.no_if%} <div> no IF </div> {%endif%} |
||||
{%for cap in veh.capabilities%} <div> {{cap}} </div> {%endfor%} |
||||
{%if veh.crew_survival%} <div> {{veh.crew_survival}} </div> {%endif%} |
||||
{%endfor%} |
||||
|
||||
</table> |
||||
|
||||
</html> |
@ -0,0 +1,4 @@ |
||||
{# Some versions of Java require <img> tags to have the width and height specified!?! #} |
||||
{%if vo.image%} <img src="{{vo.image}}" |
||||
{%if vo.small_piece%} width="48" height="48" {%else%} width="60" height="60" {%endif%} |
||||
> {%endif%} |
@ -0,0 +1,85 @@ |
||||
<html> <!-- vasl-templates:id {{SNIPPET_ID}} --> |
||||
|
||||
<head> |
||||
<meta charset="utf-8"> |
||||
<style> |
||||
{{CSS:common}} |
||||
.note { font-size: 90% ; font-style: italic ; color: #808080 ; white-space: nowrap ; } |
||||
.capability { white-space: nowrap ; } |
||||
.capability .brewup { color: #a04010 ; } |
||||
.comment { font-size: 96% ; font-style: italic ; color: #404040 ; white-space: nowrap ; } |
||||
.comment .split-mg-red { color: #a04010 ; } |
||||
</style> |
||||
</head> |
||||
|
||||
{# NOTE: We set a narrow width to stop lots of notes making us very wide. #} |
||||
<table style=" |
||||
{%if OB_VO_WIDTH%} width: {{OB_VO_WIDTH}} ; {%else%} width: 1px ; {%endif%} |
||||
"> |
||||
|
||||
<tr> |
||||
<td colspan="2" style=" |
||||
background: {{OB_COLOR}} ; |
||||
border-bottom: 1px solid {{OB_COLOR_2}} ; |
||||
padding: 2px 5px ; |
||||
font-size: 105% ; font-weight: bold ; |
||||
white-space: nowrap ; |
||||
"> |
||||
{# CSS "white-space:nowrap" doesn't always work in VASSAL, we need to use <nobr> and here :-/ #} |
||||
<nobr>{{INCLUDE:player_flag_large}}{{PLAYER_NAME|nbsp}} {{VO_TYPES}}</nobr> |
||||
|
||||
{%for vo in OB_VO%} |
||||
|
||||
{% if vo.index == 0 %} |
||||
<tr> |
||||
{% set PADDING_TOP = 2 %} |
||||
{%else%} |
||||
<tr style="border-top:1px dotted #e0e0e0;"> |
||||
{% set PADDING_TOP = 5 %} |
||||
{%endif%} |
||||
|
||||
{% if vo.name_len <= MAX_VO_NAME_LEN %} |
||||
{# NOTE: If the vehicle/ordnance name is short, put the capabilities to the right of it. #} |
||||
<td valign="top" style="padding:{{PADDING_TOP}} 5px 2px 5px;"> |
||||
{{INCLUDE:ob_vo.name}} <br> |
||||
{{INCLUDE:ob_vo.image}} |
||||
{% set MAX_CAPABILITIES = 4 %} |
||||
{%else%} |
||||
{# NOTE: If the vehicle/ordnance name is long, put it on its own line, and the capabilities underneath. #} |
||||
<td colspan="2" valign="top" style="padding:{{PADDING_TOP}} 5px 0 5px;"> |
||||
{{INCLUDE:ob_vo.name}} |
||||
<tr> |
||||
<td valign="top" style="padding:0 5px 2px 5px;"> |
||||
{{INCLUDE:ob_vo.image}} |
||||
{% set MAX_CAPABILITIES = 3 %} |
||||
{%endif%} |
||||
|
||||
{% if vo.small_piece %} |
||||
{% set MAX_CAPABILITIES = MAX_CAPABILITIES - 1 %} |
||||
{%endif%} |
||||
|
||||
{% if vo.capabilities_len > MAX_CAPABILITIES or !vo.image %} |
||||
{# NOTE: If there are a lot of capabilities, tuck the note number & notes under the image. #} |
||||
{# But if there is no image, we always do this, and squeeze them in to the left of the capabilities. #} |
||||
<div class="note" style="margin-top:5px;"> |
||||
{{INCLUDE:ob_vo.notes}} |
||||
</div> |
||||
{%endif%} |
||||
|
||||
<td valign="top" style="padding:5px 5px 2px 5px;"> |
||||
{%for cap in vo.capabilities%} <div class="capability"> {{cap}} </div> {%endfor%} |
||||
{%for cmnt in vo.comments%} <div class="comment"> {{cmnt}} </div> {%endfor%} |
||||
|
||||
{% if vo.capabilities_len <= MAX_CAPABILITIES and vo.image %} |
||||
{# NOTE: If there are only a few capabilities, let the note number & notes spread full-width. #} |
||||
{# But if there is no image, we never do this (see above). #} |
||||
<tr> |
||||
<td class="note" valign="top" colspan="2" style="padding:2px 5px;"> |
||||
{{INCLUDE:ob_vo.notes}} |
||||
{%endif%} |
||||
|
||||
{%endfor%} |
||||
|
||||
</table> |
||||
|
||||
</html> |
@ -0,0 +1 @@ |
||||
<nobr><b>{{vo.name}}</b> {%if vo.elite%}Ⓔ{%endif%}</nobr> |
@ -0,0 +1,6 @@ |
||||
{%if vo.extn_id%} ❖ {%endif%} |
||||
{%if vo.notes%} |
||||
{{vo.note_number}}, {{vo.notes | join(", ")}} |
||||
{%else%} |
||||
{{vo.note_number}} |
||||
{%endif%} |
@ -0,0 +1,27 @@ |
||||
{# 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 ; } |
||||
|
||||
.header { margin-bottom: 0.25em ; } |
||||
.header .note-number { font-weight: bold ; } |
||||
.header .name { font-weight: bold ; font-style: italic ; } |
||||
|
||||
.content { text-align: justify ; } |
||||
.content li { margin-bottom: 2px ; } |
||||
.content .example { font-size: 95% ; font-style: italic ; color: #000080 ; } |
||||
.content .dagger-note { font-size:95% ; font-style: italic ; color: #303030 ; } |
||||
.content .rf { font-size: 95% ; font-style: italic ; color: #808080 ; } |
||||
.content p.errata { font-size: 95% ; font-style: italic ; color: #704040 ; } |
||||
.content span.errata { font-style: italic ; color: #704040 ; } |
||||
.content .lfloat { float: left ; margin-right: 0.5em ; } |
||||
.content .rfloat { float: right ; margin-left: 0.5em ; } |
||||
.content table { margin: 0 10px 0 10px ; margin-top: 0.5em ; font-size: 95% ; } |
||||
.content table th { padding: 2px 10px 2px 5px ; text-align: left ; background: #f0f0f0 ; } |
||||
.content table td { padding: 0 10px 0 5px ; } |
||||
|
||||
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 ; } |
@ -0,0 +1,27 @@ |
||||
<html> <!-- vasl-templates:id {{SNIPPET_ID}} --> |
||||
|
||||
<head> |
||||
<meta charset="utf-8"> |
||||
<style> |
||||
{{CSS:common}} |
||||
{{CSS:ob_vo_note}} |
||||
</style> |
||||
</head> |
||||
|
||||
<table> |
||||
|
||||
<tr> |
||||
<td colspan="2" style=" |
||||
background: {{OB_COLOR}} ; |
||||
border-bottom: 1px solid {{OB_COLOR_2}} ; |
||||
padding: 2px 5px ; |
||||
font-size: 105% ; font-weight: bold ; |
||||
"> |
||||
{{INCLUDE:player_flag_large}}{{VO_NAME}} |
||||
|
||||
<tr> |
||||
<td style="padding-top:2px;"> {{VO_NOTE_HTML}} |
||||
|
||||
</table> |
||||
|
||||
</html> |