Compare commits

...

642 Commits
v0.1 ... master

Author SHA1 Message Date
Pacman Ghost 24106fb7e5 Updated the documentation. 4 months ago
Pacman Ghost 2b4bc9045a Updated for VASL 6.6.7 and VASSAL 3.7.5. 4 months ago
Pacman Ghost 71b07accd8 Disable the context help button in the title bar (Windows). 5 months ago
Pacman Ghost acdb8351ef Added a link to the repo issues page in the About box. 5 months ago
Pacman Ghost 5606fe0d1a Show a better error message if Java is not configured. 5 months ago
Pacman Ghost cb0369b85a Show the "Program Info" menu item in the desktop program. 5 months ago
Pacman Ghost fdaf2f0cde Added logging for testing different VASSAL+VASL combinations. 5 months ago
Pacman Ghost bb075ef0da Tidied up some test fixtures. 5 months ago
Pacman Ghost a30cbbb616 Applied errata from ASLJ 13 for when the KMC get black OBA numbers. 5 months ago
Pacman Ghost 3e7daad138 Use blue counters for some French ordnance. 5 months ago
Pacman Ghost 2c6f0f2ba3 Updated the dependencies. 5 months ago
Pacman Ghost 127c5d46e0 Updated the dependencies. 6 months ago
Pacman Ghost 4df0f5cf77 Updated the version number to v1.12. 12 months ago
Pacman Ghost 8269917bee Updated the documentation 1 year ago
Pacman Ghost a274775bf7 Updated for VASSAL 3.6.14 and VASL 6.6.6. 1 year ago
Pacman Ghost 1c28ba4373 Show a better error message if the VASL data directory is not there. 1 year ago
Pacman Ghost bc87c398bb Added options to configure logging when running Docker. 1 year ago
Pacman Ghost 1e72a57d8d Added options to store data files outside a Docker container. 1 year ago
Pacman Ghost 8b8c3b2698 Fixed how external links in the help are opened. 1 year ago
Pacman Ghost 51a5bcee90 Booby Trap labels are now associated with a player nationality. 1 year ago
Pacman Ghost 6f070eb17a Correctly flag pieces from extensions as small. 1 year ago
Pacman Ghost 21d7aa2b68 Tweaked the display format for the Docker build timestamp. 1 year ago
Pacman Ghost 632e0e7e6d Tweaked a Trumbowyg icon. 1 year ago
Pacman Ghost 2c02163c46 Don't automatically create THH labels. 1 year ago
Pacman Ghost 20739d78de Updated the version strings. 1 year ago
Pacman Ghost 19734ac076 Updated for VASSAL 3.6.7 and VASL 6.6.5. 1 year ago
Pacman Ghost 85b625d54e Updated links to point to pacman-ghost.com. 1 year ago
Pacman Ghost 446a53b32f Fixed a possible timing error during startup. 1 year ago
Pacman Ghost c7a002b1c6 Fixed a test to work inside a Docker container with no internet access. 1 year ago
Pacman Ghost 8172fa7070 Allow the Canadians to generate a PIAT snippet. 1 year ago
Pacman Ghost 330d05e47d Don't show the loading spinner if Javascript is disabled. 1 year ago
Pacman Ghost b285a044ab Updated the documentation. 2 years ago
Pacman Ghost c555614ac6 Updated the examples. 2 years ago
Pacman Ghost 618fe3bd68 Tweaked a log message. 2 years ago
Pacman Ghost 0b720dc8bc Automatically set up basic logging for the desktop app. 2 years ago
Pacman Ghost 499e5645bc When loading a scenario, forget the previous .vsav file used. 2 years ago
Pacman Ghost fc3e1110f8 Worked around a layout problem when the Victory Conditions are long. 2 years ago
Pacman Ghost 8388cec67e Added large flags to the WYSIWYG editor's dropdown. 2 years ago
Pacman Ghost 569edbc48a Added shortcuts for Alt-Shift-< and >. 2 years ago
Pacman Ghost db7fecbc39 Optimized searching for scenarios. 2 years ago
Pacman Ghost 380dae5559 Merged the Extras templates for Kampfgruppe Scherer. 2 years ago
Pacman Ghost e5ad66658a Set focus to the correct default button if there are nested dialogs. 2 years ago
Pacman Ghost c5e1e53df4 Extended the files/directories monitored for Flask hot-reloading. 2 years ago
Pacman Ghost d00d809877 Don't reset the current directory for saving/loading files on "new scenario". 2 years ago
Pacman Ghost c4c2010170 Updated the National Capabilities label for the Free French. 2 years ago
Pacman Ghost c2e0b48343 Added Alt-Click handlers. 2 years ago
Pacman Ghost 0e0df4e3a6 Tightened up some tests. 2 years ago
Pacman Ghost d78ac34c1f Handle missing landing craft Chapter H notes. 2 years ago
Pacman Ghost 05933ad753 Reset the HTML editor controls each time they are used. 2 years ago
Pacman Ghost f6e5081605 Adjusted the layout of the SCENARIO panel. 2 years ago
Pacman Ghost b31f64ed7f Show an indicator when an HTML textbox goes multi-line. 2 years ago
Pacman Ghost 2d317c57bd Moved the "edit template" buttons into a drop-down menu. 2 years ago
Pacman Ghost 941439a2ae Allowed Extras templates to set the width of their dropdown's. 2 years ago
Pacman Ghost 071b6f587d Stopped incorrectly detecting "split MG" v/o comments as having been changed. 2 years ago
Pacman Ghost 437865b47d Improved time-based comments in the Axis Minor nat.caps. snippets. 2 years ago
Pacman Ghost 650a6f86d4 Grouped notes in Nationality Capabilities snippets. 2 years ago
Pacman Ghost 0682dc1dca Added an Extras template for Booby Traps. 2 years ago
Pacman Ghost 4ae19ca212 Tightened up how we detect if HTML content has been changed. 2 years ago
Pacman Ghost e7888e88e0 Cached V/O note images for extensions are now saved in their own sub-directory. 2 years ago
Pacman Ghost 1294d0e3d2 Store config and data files in the standard locations. 2 years ago
Pacman Ghost fbcf4e9184 Confirm closing the EDIT V/O dialog if changes have been made. 2 years ago
Pacman Ghost 6d261032be Improved the keyboard interface in the TURN TRACK dialog. 2 years ago
Pacman Ghost 4629b3421b Only show custom list bullets in the UI if they have been enabled. 2 years ago
Pacman Ghost 53e14f753f Allow single-line textbox's to be edited as HTML. 2 years ago
Pacman Ghost a09286bc50 Include the build git info in the SCENARIO template. 2 years ago
Pacman Ghost 4762a36eb6 Removed code supporting Internet Explorer. 2 years ago
Pacman Ghost 23256bbc63 Tightened up some tests. 2 years ago
Pacman Ghost 2628105ea9 Minor UI tweaks. 2 years ago
Pacman Ghost 77b1bc9548 Allow SSR snippets to be auto-generated. 2 years ago
Pacman Ghost ee4d8fbe02 Fixed a problem when auto-generating a simple note snippet with no content. 2 years ago
Pacman Ghost 2ee42f37b5 Tightened up the presentation of lists in the UI and snippets. 2 years ago
Pacman Ghost 15c632b522 Sanitize HTML content. 2 years ago
Pacman Ghost c420c168d1 Added a WYSIWYG editor for HTML content. 2 years ago
Pacman Ghost d5021f7960 Added the ASA and ROAR ID's to the SCENARIO template. 2 years ago
Pacman Ghost daef7c1c15 Gave VASSAL tests more time to run. 2 years ago
Pacman Ghost b10105f2a7 Improved the positioning and sizing of dialogs. 2 years ago
Pacman Ghost 6b909a37eb Changed how flags are resized. 2 years ago
Pacman Ghost 7e070c3b68 Update the UI after importing an ASA scenario. 2 years ago
Pacman Ghost 0312fa2fc7 Confirm closing the "add/edit simple note" dialog if changes have been made. 2 years ago
Pacman Ghost 95167f4888 Allow snippets to be generated from the "add/edit simple note" dialog. 2 years ago
Pacman Ghost 2a707863d2 Fixed a timing error during startup. 2 years ago
Pacman Ghost 13a1b511e6 Added splitters to make some UI elements resizable. 2 years ago
Pacman Ghost a556d22117 Got 1/2" counters showing at the smaller size again. 2 years ago
Pacman Ghost a52e7f8ba9 Added a splitter to the "edit vehicle/ordnance" dialog. 2 years ago
Pacman Ghost fa5a99e87c Changed how Java is detected (and reported) from the PATH. 2 years ago
Pacman Ghost 7f17a634eb Updated some icons, tightened up the UI. 2 years ago
Pacman Ghost fbc420d4b3 Tightened up the player flags. 2 years ago
Pacman Ghost feebe9f63c Disable snippet-width textbox's when disabling snippet buttons. 2 years ago
Pacman Ghost e479bf2a76 Removed the "Width:" labels from the UI. 2 years ago
Pacman Ghost 520ea43d9d Tidy-up strings coming from ROAR and ASA. 2 years ago
Pacman Ghost ff0f40b22b Tightened up how we map ASA theaters to our own. 2 years ago
Pacman Ghost 55c15e5157 Made the compass label part of the default template pack. 2 years ago
Pacman Ghost 757c78dbd8 Removed the text caption from some snippet buttons. 2 years ago
Pacman Ghost 1079ac7e60 Got the LFA tests going again in Firefox. 2 years ago
Pacman Ghost d165bfdefd Allow multiple levels of shading in the turn track. 2 years ago
Pacman Ghost 729c6e306d Reset the turn track when importing a scenario. 2 years ago
Pacman Ghost cd831446fd Allow turn track squares to be shaded. 2 years ago
Pacman Ghost 40c2b87cbe Added a new extras template for the compass. 2 years ago
Pacman Ghost bc724e426d Removed the option to create National Capabilities labels. 2 years ago
Pacman Ghost c850bbd281 Tweaked the presentation of the "VASSAL shim error" dialog. 2 years ago
Pacman Ghost 42cad6f87c Fixed a resizing problem in the "connect to ROAR" dialog. 2 years ago
Pacman Ghost c363acb73e Added more padding to dialogs. 2 years ago
Pacman Ghost c5cdaf27dd Return a spacer image as a flag for nationalities that don't have one. 2 years ago
Pacman Ghost 0966c08dd4 Added the turn track template. 2 years ago
Pacman Ghost 3a5e472b36 Updated dependencies. 2 years ago
Pacman Ghost f93888c51b Updated the version strings. 2 years ago
Pacman Ghost 786f35be9a Added the Swedish nationality. 2 years ago
Pacman Ghost cde6649918 Added support for VASL 6.6.4 and VASSAL 3.6.6. 2 years ago
Pacman Ghost 0cbce2182c Report the VASSAL and VASL versions when analyzing .vsav files. 2 years ago
Pacman Ghost 0ab71fb70e Added some more logging to the VASSAL shim. 2 years ago
Pacman Ghost 560f2e3e65 Default to showing images in snippets, and using custom list bullets. 2 years ago
Pacman Ghost 7a0c4044e4 Fixed an error caused by the VASL .vmod not loading properly. 2 years ago
Pacman Ghost 5d425f2ded Allow logging to a file during tests. 2 years ago
Pacman Ghost 22274a6183 Improved the help. 2 years ago
Pacman Ghost 0cbe6435cf Completely hide the UI controls if there are no vehicles/ordnance. 2 years ago
Pacman Ghost dad7dab770 Made a new VO_NOTE_IMAGE_URL_PATH parameter available to templates. 2 years ago
Pacman Ghost 114db959bd Minor tweaks to the templates. 2 years ago
Pacman Ghost b497aa6e0f Stop the DOS box from showing when we launch the webdriver. 2 years ago
Pacman Ghost 27bb528312 Updated the flags for the Allied/Axis Minor nationalities. 2 years ago
Pacman Ghost d8c9d20e2c Added some comments. 2 years ago
Pacman Ghost 6f1dbae5c8 Set the location of the geckodriver log file during tests. 2 years ago
Pacman Ghost ba8a2d425b Tightened up how the test suite skips certain versions of VASL. 2 years ago
Pacman Ghost 32efa5b1c7 Updated the Chapter H placeholders ZIP file. 2 years ago
Pacman Ghost 82ab463eab Use different backend webdriver's when running the test suite. 2 years ago
Pacman Ghost fe3d30d2e2 Added the Partisan nationality. 2 years ago
Pacman Ghost 38399c355f Allow VASL versions to be aliased. 2 years ago
Pacman Ghost 5c19af124a Got the embedded browser in the desktop app going again on Fedora 35. 2 years ago
Pacman Ghost 7c4e01eeec Allow URL's for downloaded artifacts for the Docker image to be overridden. 2 years ago
Pacman Ghost aeb3eaf5c8 Updated dependencies. 2 years ago
Pacman Ghost 4288125a7a Added some comments. 2 years ago
Pacman Ghost 44e4ff53dd Got accelerator underlines to show properly on Windows. 2 years ago
Pacman Ghost 1584988476 Show the Java version in the Program Info dialog. 2 years ago
Pacman Ghost dc8a05a78b Show the nationality colors as a palette in the LFA color-picker. 2 years ago
Pacman Ghost cbc86e6fe8 Tightened up the Docker build. 2 years ago
Pacman Ghost 2946b2d10f Use waitress to serve the webapp. 2 years ago
Pacman Ghost 2fc57b88ac Updated the version strings. 2 years ago
Pacman Ghost a1613942d4 Added support for VASSAL 3.5.8 and VASL 6.6.3. 2 years ago
Pacman Ghost 315549cd21 Documented how config files get installed into the Docker container. 3 years ago
Pacman Ghost f4553ca42f Always show the Installation tab in the help. 3 years ago
Pacman Ghost 35c9ee5d53 Documented the Selenium fix. 3 years ago
Pacman Ghost cc09ba179e Show a better error message if we can't get the VASSAL version number. 3 years ago
Pacman Ghost 71d4396678 Fixed some issues in the PF template. 3 years ago
Pacman Ghost 9d7e5d400c Update the UI correctly when reseting a vehicle/ordnance. 3 years ago
Pacman Ghost 8bf314248e Convert file downloads to UTF-8 when running in a browser. 3 years ago
Pacman Ghost 692c7fd33b Changed how the Docker container gets its container ID. 3 years ago
Pacman Ghost 427a3d65c5 Updated all dependencies. 3 years ago
Pacman Ghost 19dc063830 Changed the comment for the Pz VIE SA mines. 3 years ago
Pacman Ghost f23329a3c1 Update SMOKE Depletion Numbers correctly for Elite units. 3 years ago
Pacman Ghost 903f2f29ff Show a splash screen during startup. 3 years ago
Pacman Ghost 4fd57b5e75 Updated the version strings. 3 years ago
Pacman Ghost b5d570ce7b Fixed a test to work with Docker. 3 years ago
Pacman Ghost 84c4757b51 Cache the VASSAL version. 3 years ago
Pacman Ghost 089e43f31e Added a cache for vehicle/ordnance note images. 3 years ago
Pacman Ghost 85a0085a8a Prevented a console warning if asl-rulebook2 has not been configured. 3 years ago
Pacman Ghost 4d15050d72 Added a comment about setting the UID/GID inside the Docker container. 3 years ago
Pacman Ghost b8347f2b68 Changed how logging is configured in a Docker container. 3 years ago
Pacman Ghost 1d17a7ecdf Added some webdriver options. 3 years ago
Pacman Ghost b8c68b916e Added a vehicle entry for the LCV. 3 years ago
Pacman Ghost 94f3a2b8d5 Updated the vehicle/ordnance data with information from the back of counters. 3 years ago
Pacman Ghost 5cdbc3195a Don't change vehicular smoke dispenser depletion #'s for elite status. 3 years ago
Pacman Ghost 8ab4f3c730 Updated the regex for extracting turn track events from a vlog. 3 years ago
Pacman Ghost f3ae34878c Added asl-rulebook2 integration. 3 years ago
Pacman Ghost 1d3af963df Updated the version strings. 3 years ago
Pacman Ghost 687a65e76a Install the correct version of Python into the Docker container. 3 years ago
Pacman Ghost c56438d958 Fixed a possible timing error in the tests. 3 years ago
Pacman Ghost 3a8385023b Fixed Chapter H URL's for derived nationalities. 3 years ago
Pacman Ghost 82f383d6c4 Added some missing entries to the Chapter H placeholder zip. 3 years ago
Pacman Ghost 5936567a98 Added a check for invalid nationalities in a save file. 3 years ago
Pacman Ghost 0bd780e7b5 Added Note D to the British OML 2-in. Mortars. 3 years ago
Pacman Ghost f414a758a0 Added support for VASSAL 3.5.5 and VASL 6.6.2. 3 years ago
Pacman Ghost 4c07d279da Changed the ANZAC nationality to be based on the British. 3 years ago
Pacman Ghost 70edc69cdb Added the Australian nationality. 3 years ago
Pacman Ghost 6dd6ed11ef Updated the version strings. 3 years ago
Pacman Ghost 54780b1b66 Worked around a problem when switching between LFA graph types. 3 years ago
Pacman Ghost a8cdec3a33 Show custom flags properly if they are a local file. 3 years ago
Pacman Ghost ca750fb3a0 Pass a generic PLAYER_NAT parameter through to the templates. 3 years ago
Pacman Ghost b97ffa1cf9 Added an optional caption to the "Count remaining" extras template. 3 years ago
Pacman Ghost 1baaa2ee88 Updated the help. 3 years ago
Pacman Ghost 547eeff344 Changed how we auto-adjust the LFA moving average window size. 3 years ago
Pacman Ghost 4bc29250d5 Fixed the SS-only stuff in the German National Capabilities template. 3 years ago
Pacman Ghost 1c71b22c8e Added a note in the help about spaces in image URL's. 3 years ago
Pacman Ghost a7f7b22a36 Manually added Allied Minor Ordnance Note D to the Chapter H placeholders. 3 years ago
Pacman Ghost c703f3850d Changed how the Docker container gets its ID. 3 years ago
Pacman Ghost 692ee500c2 Added anchors to the help. 3 years ago
Pacman Ghost db2562e9ca Updated the help. 3 years ago
Pacman Ghost 36fcc4f9a5 Allow the User Files directory to be configured as a URL. 3 years ago
Pacman Ghost b5f72c2b57 Fixed a broken test when running against a remote server. 3 years ago
Pacman Ghost dbc366feda Added support for the "in" operator in Jinja templates. 3 years ago
Pacman Ghost a6e314f592 Show the current git info in the Program Info dialog when running in a container. 3 years ago
Pacman Ghost 95b7c52be4 Fixed an encoding problem when uploading scenarios to the ASL Scenario Archive. 3 years ago
Pacman Ghost 284a8e7042 Added an endpoint to return the favicon. 3 years ago
Pacman Ghost 54ea61ed84 Set the SameSite attibute when saving cookies. 3 years ago
Pacman Ghost fdef89ddb4 Fixed how single-year date-specific capabilities are calculated. 3 years ago
Pacman Ghost a8b6e53369 Default to line graphs for the LFA distribution graohs. 3 years ago
Pacman Ghost 4321d0d866 Increased the default LFA time-plot window size. 3 years ago
Pacman Ghost 2cc5b3cad4 Fixed some incorrect online counter image URL's. 3 years ago
Pacman Ghost 189293add5 Include the PyQt UI files in the setup package. 3 years ago
Pacman Ghost 8d7dd3af57 Added a clarification to the OB notes for the Finnish 37 PstK/37(g). 3 years ago
Pacman Ghost ba813a0e56 On Windows, try to use the Java bundled with VASSAL. 3 years ago
Pacman Ghost bdd2d169a2 Allow log file analysis distribution graphs to be shown as line graphs. 3 years ago
Pacman Ghost 42f3105556 Worked around a problem reading ZIP files. 3 years ago
Pacman Ghost 79babdf421 Fixed a problem with how we serve some counter images. 3 years ago
Pacman Ghost 1cfbd080cd Allow boxes in the "Grid" extras template to be labelled. 3 years ago
Pacman Ghost 52e2bf2d24 Worked around a timing problem when showing the Program Info dialog. 3 years ago
Pacman Ghost 951b57c6be Updated to Python 3.8.7. 3 years ago
Pacman Ghost 8f7077a884 Updated the version strings. 3 years ago
Pacman Ghost a68ef5f871 Decompress GZIP'ed responses when downloading files. 3 years ago
Pacman Ghost 5cb6376bd2 Updated the help. 3 years ago
Pacman Ghost bc5cd8db43 Tidied up the Docker launcher script. 3 years ago
Pacman Ghost 48896113ae Handle tabs in snippet content. 3 years ago
Pacman Ghost 77171a0d18 Only translate relative image URL's in Chapter H notes. 3 years ago
Pacman Ghost 7e05a04cd4 Apply the default settings when running the desktop app. 3 years ago
Pacman Ghost 864466bbdd Adjusted the layout of dialogs in the desktop application for HiDPI. 3 years ago
Pacman Ghost f3e487d03d Handle encoding when reading text files. 3 years ago
Pacman Ghost c078dcafa2 Improved how we handle and report errors during startup. 3 years ago
Pacman Ghost 0dbf1b0186 Disabled an intermittent pylint warning. 3 years ago
Pacman Ghost e0edff9b16 Added a missing env.var. for the Docker container. 3 years ago
Pacman Ghost 0efccd37e7 Tweaked the CSS for <sub> (for Pz IVF 1 and 2). 3 years ago
Pacman Ghost da5ced479f Show a larger flag in label headings that use a larger font. 3 years ago
Pacman Ghost 540f770b3a Worked around a problem building the desktop application. 3 years ago
Pacman Ghost b23199228e Show the desktop build info in the Program Info dialog. 3 years ago
Pacman Ghost 2a8f5f97aa Changed when we disable the Flask startup banner. 3 years ago
Pacman Ghost 7b0cd3be51 Tightened up some National Capabilities labels. 3 years ago
Pacman Ghost 13fcd0f51c Tightened up how we handle volume paths when launching the Docker container. 3 years ago
Pacman Ghost 92e17a3aaf Added support for the "Fight For Seoul" extension. 3 years ago
Pacman Ghost 39415d9560 Changed the supported versions of VASSAL. 3 years ago
Pacman Ghost 476688407c Updated the help. 3 years ago
Pacman Ghost 571175eef5 Do a multi-stage build for Docker. 3 years ago
Pacman Ghost 8401753cb6 Pre-load the menu item images. 3 years ago
Pacman Ghost 41c24bb354 Re-ordered the comments for the Japanese & Chinese knee mortar. 3 years ago
Pacman Ghost a4108c8256 Clear a player's description when their nationality is changed. 3 years ago
Pacman Ghost 1c7cabb73e Fixed the vertical alignment of flags in dialog titlebars. 3 years ago
Pacman Ghost d56a9a3f7a Added icons to the File menu. 3 years ago
Pacman Ghost e5872248a6 Fixed a test that was failing on Windows. 3 years ago
Pacman Ghost 6d59d6244c Added a dialog to show the program info. 3 years ago
Pacman Ghost 97cf6fd196 Fixed a possible test failure. 3 years ago
Pacman Ghost 02d1aeb408 Re-architected the test suite. 3 years ago
Pacman Ghost 92ccfdccf9 Fixed reading template packs from a ZIP file. 3 years ago
Pacman Ghost ca50cd00c0 Fixed the logo fade-in when searching for scenarios. 3 years ago
Pacman Ghost 6068b1e444 Force "first request" initialization to be done after starting the web server. 3 years ago
Pacman Ghost 1963558ba9 Download the scenario index files continuously. 3 years ago
Pacman Ghost 6fe7a693c3 Changed some test output to use a logger. 3 years ago
Pacman Ghost e3c5add8e7 Tightened up how the scenario date is unloaded. 3 years ago
Pacman Ghost d28cd9ffaa Fixed how select2's are loaded in the User Settings dialog. 3 years ago
Pacman Ghost f65ed8033d Fixed a possible timing error in the tests. 3 years ago
Pacman Ghost c92f24d9b0 Tightened up the Dockerfile. 3 years ago
Pacman Ghost b22c714ec5 Got VASSAL running inside the Docker container. 3 years ago
Pacman Ghost ab1d3251d9 Improved how we handle problems downloading the scenario index files. 3 years ago
Pacman Ghost 8ffb0bef49 Disabled warnings for some BFP multi-applicable notes. 3 years ago
Pacman Ghost 83e035e734 Show Chapter H notes as an image for landing craft correctly. 3 years ago
Pacman Ghost 2e3fe92d2b Improved how we handle problems downloading the scenario index files. 3 years ago
Pacman Ghost 8742f7fb0f Added a report showing the number of 2's and 12's rolled, and SA's. 3 years ago
Pacman Ghost 11ea8b47c2 Removed the help from the VASSAL shim. 3 years ago
Pacman Ghost 9adbae0a8e Changed how the VASSAL shim saves scenarios. 3 years ago
Pacman Ghost ac62b9b98a Changed how the "LFA upload" dialog is sized. 3 years ago
Pacman Ghost 4e3ee7d08c Renamed a test support function. 3 years ago
Pacman Ghost 605c56c302 Added a mapping for the ASL Scenario Archive theater "CBI". 3 years ago
Pacman Ghost 7f4cbc8286 Ignore the day-of-month when checking the date for the OBA info card. 3 years ago
Pacman Ghost c34310191c Fixed a timing problem during startup. 3 years ago
Pacman Ghost 26722019be Added support for VASSAL 3.4.2-.7 and VASL 6.6.0-.1. 3 years ago
Pacman Ghost 7e102744ca Updated the version strings. 3 years ago
Pacman Ghost b6afc5ade3 Fixed a problem saving the generated ASA screenshot for upload. 3 years ago
Pacman Ghost 00345369ad Don't adjust for time zones when unloading the scenario date. 3 years ago
Pacman Ghost c4d9d17292 Fixed permissions on the example files. 4 years ago
Pacman Ghost 2bd6eae828 Fixed external links in the help. 4 years ago
Pacman Ghost 1fa30377c8 Updated the help. 4 years ago
Pacman Ghost 32fa0100bf Set initial focus in dialogs. 4 years ago
Pacman Ghost 601f161f84 Updated the comments for the T-26 M31 and M32. 4 years ago
Pacman Ghost 65f52c5180 Fixed Plentiful Ammo in the KMC national capabilities. 4 years ago
Pacman Ghost 698d991363 Added an ATMM template for the Romanians. 4 years ago
Pacman Ghost 7fda1e6ef2 Check if the current scenario is dirty before importing a new scenario. 4 years ago
Pacman Ghost 3cc2ba8037 Tidied up the "missing image" image. 4 years ago
Pacman Ghost 9089203550 Tightened up how we show the "please wait" dialog. 4 years ago
Pacman Ghost d0f1ff7cc6 Tightened up the "ask" dialog. 4 years ago
Pacman Ghost 68faee1fab Changed how we handle the Escape key. 4 years ago
Pacman Ghost 6a690ead1a Tweaked the layout for the PLAYERS template. 4 years ago
Pacman Ghost e6a1b98801 Allow EXC blocks to be styled. 4 years ago
Pacman Ghost 720e1540b1 Tightened up the VASSAL tests. 4 years ago
Pacman Ghost 46724dff85 Added a popup menu item to generate a snippet as an image, 4 years ago
Pacman Ghost 28c4c27d2b Saved the generated ASA screenshot after shrinking it. 4 years ago
Pacman Ghost e48ed5431f Allow user settings to be confirmed before updating a scenario. 4 years ago
Pacman Ghost fbd07607de Added menu icons. 4 years ago
Pacman Ghost 999626c552 Disable logging of some warnings about VASL extensions at startup. 4 years ago
Pacman Ghost 5dffc5f056 Removed the "add" button from the LFA upload dialog. 4 years ago
Pacman Ghost 0f82a696f3 Close the downloads popup after the user has chosen to download a VSAV. 4 years ago
Pacman Ghost af8634cc83 Updated the example scenarios. 4 years ago
Pacman Ghost 190c3e97d6 Only save relevant settings when unloading the user settings dialog. 4 years ago
Pacman Ghost 3ce71bf681 Changed how we disable local ASA index updates. 4 years ago
Pacman Ghost ae2c817e2a Fixed permissions on some image files. 4 years ago
Pacman Ghost fdd9e9b3ae Tweaked the CSS for ASA search results. 4 years ago
Pacman Ghost de8f39fa95 Allow files to be uploaded to the ASL Scenario Archive. 4 years ago
Pacman Ghost 63ceda1044 Fixed a problem with saving test results. 4 years ago
Pacman Ghost 6fa454dd9a Fixed some timing issues in the scenario search tests. 4 years ago
Pacman Ghost b318507953 Position the LFA file selection popup if it's partially off-screen. 4 years ago
Pacman Ghost 2cbd84adcc Adjusted the dice hotness weights. 4 years ago
Pacman Ghost 93e5b7737a Fixed a typo in a test. 4 years ago
Pacman Ghost 66c6736d38 Updated the pylint configuration. 4 years ago
Pacman Ghost 720c491265 Allow custom labels to be added to a log file analysis. 4 years ago
Pacman Ghost 685888fddd Save scenarios as formatted JSON. 4 years ago
Pacman Ghost 8d6464b85c Handle syntax errors in the logging config file. 4 years ago
Pacman Ghost 3805e131bb Added integration with the ASL Scenario Archive. 4 years ago
Pacman Ghost 48c69927f6 Handle detached HEAD when reporting what the active git branch is. 4 years ago
Pacman Ghost c56b362113 Allow the VASSAL shim to access its properties file. 4 years ago
Pacman Ghost c043a892f0 Added log file analysis. 4 years ago
Pacman Ghost 0d0fe5869a Updated the version strings. 4 years ago
Pacman Ghost 588d3e6a0a Updated the help. 4 years ago
Pacman Ghost 55c9c711d1 Reworked some code to keep pylint happy. 4 years ago
Pacman Ghost 0680fb3863 Added support for VASL 6.5.1. 4 years ago
Pacman Ghost 40ef22c948 Allow the reports from the counters tests to be saved. 4 years ago
Pacman Ghost 2c0cee704b Allow a webdriver to be run in the Docker container. 4 years ago
Pacman Ghost 82d1577826 Recognize {{USER-FILES}} and {{CHAPTER-H}} in snippets. 4 years ago
Pacman Ghost ff4d7f5a1e Tidied up the User Settings dialog. 4 years ago
Pacman Ghost 1aad4da2a8 Changed the default ELR and SAN. 4 years ago
Pacman Ghost baeeecac97 Allow user files to be accessed from the container. 4 years ago
Pacman Ghost 8c6e3a6902 Allow generated URL's to be pointed at another webapp server. 4 years ago
Pacman Ghost 7afae4fd8d Changed how we detect if the program is already running. 4 years ago
Pacman Ghost a5bd1b8b7e Fixed how the PLAYERS snippet button is positioned. 4 years ago
Pacman Ghost b2a0cb94e5 Added templates for Finnish, Hungarian, Romanian PF's. 4 years ago
Pacman Ghost 2981843529 Use different bullet images for nested lists. 4 years ago
Pacman Ghost 61b04dfa16 Added snippets for National Capabilities. 4 years ago
Pacman Ghost f746b95408 Changed how the PopMenu hack works in the test suite. 4 years ago
Pacman Ghost ada2801020 Changed how the test suite selects vehicles/ordnance. 4 years ago
Pacman Ghost 98d69b669b Allow some test results to be saved based on an environment variable. 4 years ago
Pacman Ghost 29235584f6 Run the vehicle/ordnance report tests for each year for the landing craft. 4 years ago
Pacman Ghost 91204321f9 Tweaked the styling of some extras templates. 4 years ago
Pacman Ghost f62cfea323 Increased the size of the overflow area when positioning auto-created labels. 4 years ago
Pacman Ghost e6b3fe3488 Changed the threshold at which we retry webdriver screenshots. 4 years ago
Pacman Ghost a4b7333e6f Minor tweaks and corrections to smart comments. 4 years ago
Pacman Ghost 0ed334a22c Check both label fields when identifying player-owned snippets. 4 years ago
Pacman Ghost 9746391bfb Added smart comments for vehicle radios. 4 years ago
Pacman Ghost 2e799d60d6 Minor tweaks and corrections to smart comments. 4 years ago
Pacman Ghost e9079dd7aa Allow a caption to be set in the "grid" extras template. 4 years ago
Pacman Ghost eb66059f5c Changed how we detect labels in a VASL scenario save file. 4 years ago
Pacman Ghost e7ebe53718 Handle VASSAL crashing when updating scenarios. 4 years ago
Pacman Ghost 964d7d38d6 Ignore extras snippets when checking for player-owned snippets. 4 years ago
Pacman Ghost a1714be155 Handle spaces when detecting dagger blocks in Chapter H content. 4 years ago
Pacman Ghost e6369d4327 Added comments for the German Moebelwagen. 4 years ago
Pacman Ghost 01a9050adc Simplified some extras templates. 4 years ago
Pacman Ghost 5be29079f1 Softened the list item bullet image. 4 years ago
Pacman Ghost 14fc3479ae Tightened up how we handle whitespace and line wrapping in snippets. 4 years ago
Pacman Ghost fee5bbb2f7 Changed how "brewup" CS #'s are shown into snippets. 4 years ago
Pacman Ghost b89cd2bd23 Added smart vehicle/ordnance comments. 4 years ago
Pacman Ghost c00b3072e5 Don't show the "scenario modified" indicator if the "dirty scenario" check has been disabled. 4 years ago
Pacman Ghost 3a852e52e3 Tightened up how we locate the map in a VASL save file. 4 years ago
Pacman Ghost 638c52576b Updated snippet ID's to include their owning player nationality. 4 years ago
Pacman Ghost 23deac49e8 Disable the "add vehicle/ordnance" buttons if there are no vehicles/ordnance. 4 years ago
Pacman Ghost 54299e17b8 Allow Chapter H multi-applicable notes to be "disabled". 4 years ago
Pacman Ghost e23118e97f Fixed a temp file leak in the test suite. 4 years ago
Pacman Ghost 50813bd561 Finalized the styling for Chapter H content. 4 years ago
Pacman Ghost 3f58e844ae Added CSS styling for Chapter H errata. 4 years ago
Pacman Ghost ecadb0882b Fixed a problem with how sortable entries were being unloaded. 4 years ago
Pacman Ghost 0afed8ac2d Show the vehicle/ordnance type in the main sortable lists. 4 years ago
Pacman Ghost e8e720a229 Fixed a broken test. 4 years ago
Pacman Ghost fd73cbb3bf Changed the order of rows and columns in the "grid" extras template. 4 years ago
Pacman Ghost 4385f52efc Added styling for footnotes in Chapter H content. 4 years ago
Pacman Ghost 92e727f182 Tweaked the styling for tables in Chapter H content. 4 years ago
Pacman Ghost 9e9cba6b8f Fixed up some GPID's. 4 years ago
Pacman Ghost ef1028e477 Fixed up some GPID's. 4 years ago
Pacman Ghost 2536f220a2 Added support for K:FW. 4 years ago
Pacman Ghost e49785ff8f Added a rule for the default logger in the example config file. 4 years ago
Pacman Ghost 56dbac1232 Increased timeouts during tests. 4 years ago
Pacman Ghost 6d06fdc082 Show in the UI which VASL extension vehicles/ordnance came from. 4 years ago
Pacman Ghost 04f1755415 Update the UI to show if a scenario has been modified. 4 years ago
Pacman Ghost 54008b118e Updated some requirements. 4 years ago
Pacman Ghost e2638fe609 Tightened up how we check the VASSAL version. 4 years ago
Pacman Ghost 59a05e7a22 Include diagnostic info in the SCENARIO label when updating a .vsav file. 4 years ago
Pacman Ghost 575e217c68 Pass some scenario parameters through to extras templates for their forms. 4 years ago
Pacman Ghost 86c2ff00df Allow a snippet to be auto-generated after a simple note has been edited. 4 years ago
Pacman Ghost fbe01fb1f0 Fixed some tests that were broken when run against the Docker container. 4 years ago
Pacman Ghost 59e1517464 Updated the version strings to v1.2. 4 years ago
Pacman Ghost bc66eec58f Trim trailing whitespace from generated sippets. 4 years ago
Pacman Ghost f139404c37 Encode URL's that retrieve images from the internet. 4 years ago
Pacman Ghost a8e1c07d57 Tightened up the layout in some extras templates. 4 years ago
Pacman Ghost 403938e191 Tweaked the "grid" extras template to not generate so many blank lines. 4 years ago
Pacman Ghost 509e326b90 Allow the border style to be set in the "grid" extras template. 4 years ago
Pacman Ghost f1107e8e88 Added a helper script to dump VASL build files. 4 years ago
Pacman Ghost 6b0d492121 Added an extras template for Kakazu Ridge. 4 years ago
Pacman Ghost d24c854387 Updated for VASL 6.5.0. 4 years ago
Pacman Ghost 3099f73268 Tightened up how we build the Docker images. 4 years ago
Pacman Ghost 91d581eb2f Added a switch to specify which Docker network to use when building/running the container. 4 years ago
Pacman Ghost d561c5be00 Handle errors when building the Docker container correctly. 4 years ago
Pacman Ghost 689e6122ac Tightened up how the Docker containers are created. 4 years ago
Pacman Ghost d07118591f Added a script to manage the Docker container. 4 years ago
Pacman Ghost 7e657e581f Minor code tidy-up. 4 years ago
Pacman Ghost 1fdb5853d5 Updated the version numbers. 4 years ago
Pacman Ghost 2f01c44e43 Updated PyInstaller (security fix). 4 years ago
Pacman Ghost ffcdf4e380 Documented the targeted version of Python. 4 years ago
Pacman Ghost 6f94f635aa Added a Quick-Start Guide. 4 years ago
Pacman Ghost 6878421bd3 Added separators to menus. 4 years ago
Pacman Ghost 06983b9cb3 Removed the common CSS from extras templates that don't need it. 4 years ago
Pacman Ghost 9b0db53799 Added extra information to the COUNT-REMAINING extras template. 4 years ago
Pacman Ghost e737b43464 Connected the example scenarios to ROAR. 4 years ago
Pacman Ghost e9a25d2042 Updated the version strings. 4 years ago
Pacman Ghost c353fbb325 Improved how we analyze a VASL scenario. 5 years ago
Pacman Ghost cbdeca5546 Ignore our own labels when analyzing a VASL scenario. 5 years ago
Pacman Ghost e3db9822b9 Updated logging for the VASSAL shim. 5 years ago
Pacman Ghost 029bcd197b Added a workaround to detect more pieces when analyzing a VASL scenario. 5 years ago
Pacman Ghost 0345f11526 Updated the example scenarios. 5 years ago
Pacman Ghost 36d0dcf20f Replaced rawgit.com links with vasl-templates.org. 5 years ago
Pacman Ghost 4681315b6f Added a workaround for a CSS problem in Chromium. 5 years ago
Pacman Ghost 052d4e9628 Fixed a test option. 5 years ago
Pacman Ghost 6b1962707d Default to overwriting the source .vsav file after updating it. 5 years ago
Pacman Ghost 317ec8536c Get scenario images from the internet by default. 5 years ago
Pacman Ghost 95d2875c1c Updated the help. 5 years ago
Pacman Ghost 5f234fc9a5 Tightened up how capability theater/nationality superscripts are parsed. 5 years ago
Pacman Ghost 635c6d5093 Added support for American Ordnance Note C. 5 years ago
Pacman Ghost ebbfa3305c Exclude parameters from extras templates for all control types. 5 years ago
Pacman Ghost 749d543d6a Detect sortable2 entries correctly. 5 years ago
Pacman Ghost 76b7d9836a Allow vehicles/ordnance to be imported from a .vsav file. 5 years ago
Pacman Ghost cfc7ab6079 Tightened up configuration options for the test suite. 5 years ago
Pacman Ghost 9ecf1f87d4 Use a constant correctly. 5 years ago
Pacman Ghost 4662eba76e Fixed a minor formatting issue in a template. 5 years ago
Pacman Ghost 87c8de42bf Updated the version string to v0.11. 5 years ago
Pacman Ghost fbea94910a Updated gitignore for VS Code. 5 years ago
Pacman Ghost 401ad8128c Work around a resizing problem when showing/hiding the ROAR info panel. 5 years ago
Pacman Ghost cd445915ea Read the nationalities file correctly from a custom template pack. 5 years ago
Pacman Ghost b1639ebf66 Changed the app icon, also included it in the About box. 5 years ago
Pacman Ghost 2845ce5aeb Increased the size of headers in snippets. 5 years ago
Pacman Ghost e63430fc1f Allow the font to be configured for HTML snippets. 5 years ago
Pacman Ghost 5f3a89345a Changed when we download a fresh copy of the ROAR scenario index. 5 years ago
Pacman Ghost 5d34c52b66 Tightened up validation and error handling of server configuration in the desktop app. 5 years ago
Pacman Ghost ed0ca6f331 Tightened up handling of configuration errors. 5 years ago
Pacman Ghost 6ec5354532 Made the background colour configurable in the Turn Track shading extras template. 5 years ago
Pacman Ghost 5667eaa0a5 Added an extras template to generate a grid. 5 years ago
Pacman Ghost 187faa112b Added an extras template to generate snippets for Victory Points. 5 years ago
Pacman Ghost e8c710f00b Fixed the focus rectangle around the player droplists. 5 years ago
Pacman Ghost b07f89eab6 Added a comment. 5 years ago
Pacman Ghost 8532ab6413 Allow custom flags to be specified as a URL. 5 years ago
Pacman Ghost 2397eb5c5f Tidied up the layout of the flags in player droplists. 5 years ago
Pacman Ghost 7a397bd550 Tidied up keyboard/mouse handling for the select-vo dialog. 5 years ago
Pacman Ghost 13f6418fb9 Changed how where scenario images are loaded from is configured. 5 years ago
Pacman Ghost 5d2e01ec2e Include width/height attributes for player flag and vehicle/ordnance counter images in snippets. 5 years ago
Pacman Ghost ed3ec863a2 Handle opening external links in a new browser window properly. 5 years ago
Pacman Ghost 82677e5d32 Don't ask the user to confirm leaving the page if we're running inside the desktop app. 5 years ago
Pacman Ghost 13e6709420 Allow images in scenarios to be loaded from the internet. 5 years ago
Pacman Ghost 0a91f820f3 Added ROAR integration. 5 years ago
Pacman Ghost bc58b8c9c4 Updated the version string to v0.10. 5 years ago
Pacman Ghost 4641fcc650 Tweaked the vehicle/ordnance note template. 5 years ago
Pacman Ghost 9beacada3a Updated the help. 5 years ago
Pacman Ghost 8d9e5d05f8 Improved the adaptive layout for vehicle/ordnance snippets. 5 years ago
Pacman Ghost 8650c65f9c Ignore an incorrectly configured Chapter H directory. 5 years ago
Pacman Ghost 9fc48ac8bd Fixed a possible crash at startup. 5 years ago
Pacman Ghost 02c27ab38b Allow custom flags to be defined. 5 years ago
Pacman Ghost 6b4cb60e61 Added the Canadian and New Zealand nationalities. 5 years ago
Pacman Ghost 10d8b890d1 Fixed some incorrect CSS. 5 years ago
Pacman Ghost 44855a729b Merged the PF and THH extras template. 5 years ago
Pacman Ghost 0f2ea315cc Added 6.4.4 to the list of supported versions of VASL. 5 years ago
Pacman Ghost e8099ea9b0 Tweaked the date format in the SCENARIO template. 5 years ago
Pacman Ghost 5e0019c753 Allow custom list bullets to be used everywhere (optionally). 5 years ago
Pacman Ghost 55e45af6f3 Apply the CSS correctly when generating vehicle/ordnance notes as an image. 5 years ago
Pacman Ghost f8eb902e7e Fixed a timing issue during tests. 5 years ago
Pacman Ghost 620bf88244 Added templates for Japanese THH. 5 years ago
Pacman Ghost 418dc4732b Made the vehicle/ordnance snippets adapt their layout to the content. 5 years ago
Pacman Ghost ff5752fc74 Show the correct vehicle note for Landing Craft. 5 years ago
Pacman Ghost 43a1c33ed8 Fixed an incorrect URL. 5 years ago
Pacman Ghost 0a253ea135 Fixed a problem with generating URL's on Windows. 5 years ago
Pacman Ghost b3b3e77ec2 Use a shared template for vehicle/ordnance snippets. 5 years ago
Pacman Ghost db6144e774 Don't create multiple snippets for duplicate vehicles/ordnance. 5 years ago
Pacman Ghost 8ab604f0f1 Removed some incorrect notes from the B+J American vehicles. 5 years ago
Pacman Ghost 8cb11db721 Tightened up the CSS for vehicle/ordnance multi-applicable notes. 5 years ago
Pacman Ghost e01c78e9af Fixed how image URL's are generated for common vehicles/ordnance. 5 years ago
Pacman Ghost ec3c9ca2f1 Added support for HTML vehicle/ordnance notes. 5 years ago
Pacman Ghost 3690cc06cb Added the Free French nationality. 5 years ago
Pacman Ghost 62f30bb40e Updated the Russian Lend-Lease vehicles. 5 years ago
Pacman Ghost 914e65ed3b Added an option to hide unavailable multi-applicable notes. 5 years ago
Pacman Ghost 5c0136030e Show the scenario ID in the window title. 5 years ago
Pacman Ghost c102413d9c Generate a default save name for scenarios. 5 years ago
Pacman Ghost 3a39a4e57c Changed how startup initialization is done. 5 years ago
Pacman Ghost 31d2dcece1 Removed the file_server sub-directory. 5 years ago
Pacman Ghost 08728b20c8 Updated the version string to v0.9. 5 years ago
Pacman Ghost bab142243b Merge branch 'v0.8' 5 years ago
Pacman Ghost 1a86f638a2 Preserve the GPID remappings correctly during tests. 5 years ago
Pacman Ghost 300dcd0a6f Tightened up the BFP extension ID's. 5 years ago
Pacman Ghost 9178cdf3ce Updated the Chapter H placeholder files. 5 years ago
Pacman Ghost 37b02ccb0a Added a test for generating the Chapter H placeholder files. 5 years ago
Pacman Ghost 3344cdf083 Allow vehicles/ordnance to be marked as Elite. 5 years ago
Pacman Ghost 6969c5c228 Fixed some tests to skip if the Chapter H data is not provided. 5 years ago
Pacman Ghost 056f644d70 Stopped pytest from complaining about import order when run on Windows. 5 years ago
Pacman Ghost 62131dbefc Improved how we detect when the PopMenu menu has gone during tests. 5 years ago
Pacman Ghost a211c45364 Show HTML content when confirming deleting sortable entries. 5 years ago
Pacman Ghost fb34924ffe Only reset the scenario once during startup. 5 years ago
Pacman Ghost f2e3803874 Log a warning if the multi-applicable notes are using &half; (instead of &frac12;). 5 years ago
Pacman Ghost dd5c00bc93 Fixed a typo. 5 years ago
Pacman Ghost 60f4f98693 Don't show uninteresting capabilities in snippets. 5 years ago
Pacman Ghost 0382c5756b Updated the tests to recognize extension and Allied/Axis Minor common mutli-applicable notes. 5 years ago
Pacman Ghost 5c8b077142 Allied/Axis Minor common multi-applicable notes now apply to all Allied/Axis Minor pieces. 5 years ago
Pacman Ghost 3c7057ce84 Improved how missing template files are handled. 5 years ago
Pacman Ghost 1151ba606e Remove duplicate multi-applicable notes. 5 years ago
Pacman Ghost 12593b55a5 Allow vehicles/ordnance to be added to an OB multiple times. 5 years ago
Pacman Ghost ef45ac0c05 Show player scenario notes without colors. 5 years ago
Pacman Ghost 08ad8b8c68 Adjusted the minimum size of the desktop app main window. 5 years ago
Pacman Ghost 2385e51da0 Locate the license file correctly in the compiled version. 5 years ago
Pacman Ghost f81c148220 Preload the flag images after the page has finished loading. 5 years ago
Pacman Ghost 1075a555d7 Increased the initial size of some dialogs. 5 years ago
Pacman Ghost 2154faeb97 Allow Ctrl-Enter to dismiss the USER SETTINGS dialog. 5 years ago
Pacman Ghost c128209e7f Added support for BFP's "Blood & Jungle" and "Poland In Flames". 5 years ago
Pacman Ghost 7bd6e9504d Specify the encoding when generate snippet images. 5 years ago
Pacman Ghost f5e9999354 Updated the version string to v0.8. 5 years ago
Pacman Ghost de27daed25 Tweaked the link to the Chapter H placeholders ZIP file. 5 years ago
Pacman Ghost ed9d5dc664 Tightened up the CSS for sortable's. 5 years ago
Pacman Ghost 666cc3daf2 Tweaked how we handle errors during startup. 5 years ago
Pacman Ghost fcf750b199 Fixed a test that was failing when run against a container. 5 years ago
Pacman Ghost eacbfd5b26 Handle things better if VASSAL is configured, but not Java :-/ 5 years ago
Pacman Ghost 1d61b5a10b Changed when we validate certain key settings. 5 years ago
Pacman Ghost 956c276c23 Tell the user to restart the program if they change key settings. 5 years ago
Pacman Ghost 05667f813c Renamed the freeze script. 5 years ago
Pacman Ghost 3aa98c61b0 Updated the help. 5 years ago
Pacman Ghost 65b6eca087 Added placeholder files for the Chapter H notes. 5 years ago
Pacman Ghost ed14c063c1 Updated the Server Settings dialog for the new features. 5 years ago
Pacman Ghost 835c376603 Updated the example screenshots. 5 years ago
Pacman Ghost 36b6ea2da5 Show the git branch and last commit ID in the desktop app About box. 5 years ago
Pacman Ghost 15f693fc05 Tightened up how we serve images. 5 years ago
Pacman Ghost b10adaac99 Disabled a test failing under Firefox. 5 years ago
Pacman Ghost c69a09d95f Tweaked error reporting and logging when loading VASL extensions. 5 years ago
Pacman Ghost 617e5deda4 Cache and re-use a shared WebDriver. 5 years ago
Pacman Ghost ffed68d236 Allow images of snippets to be created. 5 years ago
Pacman Ghost 0f85f9dfca Added support for VASL extensions. 5 years ago
Pacman Ghost 3dc9a61251 Tightened up the code wrapping the Selenium webdriver. 5 years ago
Pacman Ghost 0a4d40abfd Install the VASL module correctly when running tests. 5 years ago
Pacman Ghost 360ca1941c Allow user files to be included in snippets. 5 years ago
Pacman Ghost fbdcd70710 Added support for Chapter H vehicle/ordnance notes and multi-applicable notes. 5 years ago
Pacman Ghost ed962b0456 Updated the version string to v0.7. 5 years ago
Pacman Ghost e6c37c49f9 Updated the tests so that they can be used with a remote server. 5 years ago
Pacman Ghost 7d9ceac239 Docker'ized the application. 5 years ago
Pacman Ghost 4c054ba98e Update the version string to v0.6. 5 years ago
Pacman Ghost 4725d5355f Removed execute permission from files that don't need it. 5 years ago
Pacman Ghost dc2de2a273 Updated the examples. 5 years ago
Pacman Ghost 5db3c26ae2 Locate the application icon correctly when we're frozen. 5 years ago
Pacman Ghost 279b9a12c0 Tweaked how we capture output from the VASSAL shim. 5 years ago
Pacman Ghost 6d075744f7 Added an About box. 5 years ago
Pacman Ghost c76dea3ab3 Handle hotkeys if a dialog is up, or a dropdown is showing. 5 years ago
Pacman Ghost cdc0ce68d1 Adjust the scenario date for timezone when saving scenarios. 5 years ago
Pacman Ghost 50aff86a93 Store admin metadata when saving scenarios. 5 years ago
Pacman Ghost dffdc88fd5 Made the location of Chrome/Chromium configurable. 5 years ago
Pacman Ghost eadb681d84 Show a warning if an unsupported version of VASSAL or VASL is being used. 5 years ago
Pacman Ghost 103423f193 Fixed a possibily incorrect warning about the VASL module having been changed. 5 years ago
Pacman Ghost 97bd169d70 Show the VASSAL shim report before saving the updated VSAV file. 5 years ago
Pacman Ghost 6cf8f01e50 Report errors in Python callbacks to the user. 5 years ago
Pacman Ghost 5b149f6a7c Tweaked the example logging config. 5 years ago
Pacman Ghost 3b40e9750c Don't include Python config files in frozen releases. 5 years ago
Pacman Ghost 744403d8e7 Tightened up the PF template. 5 years ago
Pacman Ghost b4db7ea076 Use the new-style flags in the KGS extras templates. 5 years ago
Pacman Ghost 88a1ed933d Include flags in the SCENARIO template. 5 years ago
Pacman Ghost a933aa4541 Automatically insert/update labels in a VASSAL save file. 5 years ago
Pacman Ghost 035aa4922a Fixed a misteak in a template. 5 years ago
Pacman Ghost ba83febf6a Fixed Selenium webdriver leak. 5 years ago
Pacman Ghost a770067bbe Report errors if the desktop app fails to startup. 5 years ago
Pacman Ghost cacbaff598 Tweaked the appearance of the main window if the embedded browser is disabled. 5 years ago
Pacman Ghost 80a0a5046b Fixed some font size issues. 5 years ago
Pacman Ghost f28b2cf363 Resize flag images in the backend. 5 years ago
Pacman Ghost 42f6f7a143 Fixed the VftT link in the desktop app. 5 years ago
Pacman Ghost e3bd90b488 Updated the version string to v0.5. 5 years ago
Pacman Ghost c5a9a32315 Updated the help. 5 years ago
Pacman Ghost b0b850c11d Added flags to the LATW snippets. 5 years ago
Pacman Ghost c4c7e725eb Tightened up the CSS for dialogs. 5 years ago
Pacman Ghost 6523b718a1 Use the correct vo_entry when resetting vehicle/ordnance capabilities. 5 years ago
Pacman Ghost fc31d6345b Minor UI changes. 5 years ago
Pacman Ghost f41766741c Renamed an example config file. 5 years ago
Pacman Ghost f07f09154e Added workarounds for VASL 6.4.3. 5 years ago
Pacman Ghost 8a3acb19c6 Use pyinstaller to freeze the application. 5 years ago
Pacman Ghost b524af57c2 Added support for extras templates. 5 years ago
Pacman Ghost 675629dc14 Changed when the default scenario is loaded during startup. 5 years ago
Pacman Ghost b044cb6815 Redirect to the "missing image" URL if a VASL module has not been installed. 5 years ago
Pacman Ghost b9408d6842 Allow the date format to be changed. 5 years ago
Pacman Ghost 1c4e333325 Account for the timezone when unloading the scenario date. 6 years ago
Pacman Ghost d1252e1f60 Added UTF-8 declarations to the default snippet templates. 6 years ago
Pacman Ghost a0fe6fbdcc Tweaked how the tests check the clipboard. 6 years ago
Pacman Ghost 7a1b7735ce Allow flags to be included in snippets. 6 years ago
Pacman Ghost 4192bf9654 Show flags in dialog titlebars. 6 years ago
Pacman Ghost ca30238615 Show flags in the player droplist's. 6 years ago
Pacman Ghost cedd5b4eac Only show warnings relevant to the V/O snippet being generated. 6 years ago
Pacman Ghost 91b26d963c Added a button to reset a vehicle/ordnance's capabilities back to default. 6 years ago
Pacman Ghost 9ea39a0ec0 Don't show an unnecessary warning when editing a vehicle/ordnance. 6 years ago
Pacman Ghost e1f2ccd3a2 Show vehicle/ordnance capabilities in the UI. 6 years ago
Pacman Ghost c316489ab0 Allow vehicle/ordnance capabilities and image to be changed. 6 years ago
Pacman Ghost 55b6970a52 Updated the version string to v0.4. 6 years ago
Pacman Ghost 0352be5c9f Added tests to make sure the common vehicles/ordnance and landing craft are loaded correctly. 6 years ago
Pacman Ghost 36a2ec3519 Updated the help. 6 years ago
Pacman Ghost 3c9b357e1b Minor tweak to the CSS for the SCENARIO template. 6 years ago
Pacman Ghost 3c8e0ceb93 Fixed some tests if no VASL modules are provided. 6 years ago
Pacman Ghost 9f6e4af13c Use a normalized path when extracting files from a VASL module file. 6 years ago
Pacman Ghost 9c9771a1b4 Added a "server settings" dialog to the desktop app. 6 years ago
Pacman Ghost a90b2a6d4a Split the main CSS file into smaller parts. 6 years ago
Pacman Ghost ec7e9f00b3 Allow VASL counter images to be included in V/O snippets. 6 years ago
Pacman Ghost 6dee1f6e36 Split the main template file into smaller parts. 6 years ago
Pacman Ghost 7871cec1e5 Select the correct vehicle/ordnance when the OK button is clicked. 6 years ago
Pacman Ghost c957f03739 Allow the user to choose between multiple VASL counter images. 6 years ago
Pacman Ghost c1567fcbaa Save vehicles/ordnance using V/O ID's. 6 years ago
Pacman Ghost 794315720d Removed underscores from JSON key names. 6 years ago
Pacman Ghost 7aa523310a Use V/O ID's to figure out which vehicles/ordnance have already been added to a player's OB. 6 years ago
Pacman Ghost 2609ce1357 Show the vehicle/ordnance type in the "Select V/O" dialog. 6 years ago
Pacman Ghost 9549cd0976 Assigned unique vehicle/ordnance ID's. 6 years ago
Pacman Ghost ded0a1aa18 Use a fixed height for vehicle/ordnance sortable2 entries. 6 years ago
Pacman Ghost 7b76cd52a1 Show 1/2" and 5/8" VASL counters in different sizes. 6 years ago
Pacman Ghost 7780574717 Show VASL counter images in the UI. 6 years ago
Pacman Ghost 12fb0b274f Restored the "T" (Tunisia only) and "F" (rare) superscripts for vehicle/ordnance note N's. 6 years ago
Pacman Ghost 996587806d Tidied up the UI for the player droplists. 6 years ago
Pacman Ghost 9ac4ffeb14 Fixed up some abbreviated vehicle/ordnance names. 6 years ago
Pacman Ghost 113b67ad3e Added Landing Craft. 6 years ago
Pacman Ghost 5597781069 Added the Finnish vehicles/ordnance. 6 years ago
Pacman Ghost 4de10016ac Handle Romanian/Slovakian/Croatian-only capabilities. 6 years ago
Pacman Ghost bdecdfb838 Added the Axis Minor common vehicles/ordnance. 6 years ago
Pacman Ghost 482a0c624f Added the Bulgarian nationality and vehicles/ordnance. 6 years ago
Pacman Ghost 614c78d06c Added the Croatian nationality and vehicles/ordnance. 6 years ago
Pacman Ghost 75cef87a46 Added the Slovakian nationality and vehicles/ordnance. 6 years ago
Pacman Ghost ce450d00ee Added the Hungarian nationality and vehicles/ordnance. 6 years ago
Pacman Ghost 193521d4e1 Added the Romanian nationality and vehicles/ordnance. 6 years ago
Pacman Ghost 4e5b20e1ca Re-organized the Allied Minor vehicle/ordnance data files. 6 years ago
Pacman Ghost d52b100c81 Added the Allied Minor common vehicles/ordnance. 6 years ago
Pacman Ghost cb57546288 Added the Greek nationality and vehicles/ordnance. 6 years ago
Pacman Ghost 0905c4f81c Added the Dutch nationality and vehicles/ordnance. 6 years ago
Pacman Ghost f5effc4894 Added the Danish nationality and vehicles/ordnance. 6 years ago
Pacman Ghost 5dc923deb9 Added the Yugoslavian nationality and vehicles/ordnance. 6 years ago
Pacman Ghost 779f326bf7 Added the Belgian nationality and vehicles/ordnance. 6 years ago
Pacman Ghost 8f6086f01b Added the Polish nationality and vehicles/ordnance. 6 years ago
Pacman Ghost dc20d47854 Updated the version string to v0.3. 6 years ago
Pacman Ghost ed2d9b532c Made OB vehicle/ordnance superscripts smaller. 6 years ago
Pacman Ghost ea4a5426f6 Added tests for Burma-specific capabilities. 6 years ago
Pacman Ghost f631d11cd5 Updated color management for nationalities with two-tone counters. 6 years ago
Pacman Ghost 287bccc498 Added the French vehicles. 6 years ago
Pacman Ghost 66ae337454 Added the French ordnance. 6 years ago
Pacman Ghost 86efac5812 Added the Chinese nationality. 6 years ago
Pacman Ghost c719b82829 Added the Chinese vehicles. 6 years ago
Pacman Ghost eec5ecfbbf Added the Chinese ordnance. 6 years ago
Pacman Ghost f96bb2af6d Added the Japanese vehicles. 6 years ago
Pacman Ghost 694d7141ed Added the Japanese ordnance. 6 years ago
Pacman Ghost 7183d4cf6a Added the Italian vehicles. 6 years ago
Pacman Ghost 899d3f93d7 Added the Italian ordnance. 6 years ago
Pacman Ghost 13ba18936f Added the British vehicles. 6 years ago
Pacman Ghost 641a724439 Added the British ordnance. 6 years ago
Pacman Ghost b522762168 Added more tests for American vehicle capabilitiies. 6 years ago
Pacman Ghost fbb8a48adc Made APCR ETO-only for the M10 GMC and M18 GMC. 6 years ago
Pacman Ghost c8a666b511 Added the American vehicles. 6 years ago
Pacman Ghost bb963e42a5 Use Chrome as the default test WebDriver. 6 years ago
Pacman Ghost f7e5a30aba Minor changes to the help text. 6 years ago
Pacman Ghost 13a305b57c Fixed some layout issues in Firefox on Linux. 6 years ago
Pacman Ghost fe44eb878a Added tests for date-based capabilities that change in the middle of a year. 6 years ago
Pacman Ghost c228db45b8 Updated the tests to use wait_for_clipboard(). 6 years ago
Pacman Ghost 4b13100c45 Handle ETO/PTO-only capabilities. 6 years ago
Pacman Ghost 4693a7531f Added the SCENARIO_THEATER field. 6 years ago
Pacman Ghost 673877bc5b Added US Ordnance. 6 years ago
Pacman Ghost 8b1c0a41f7 Updated the version string to v0.2. 6 years ago
  1. 8
      .dockerignore
  2. 2
      .gitignore
  3. 112
      .pylintrc
  4. 86
      Dockerfile
  5. 2
      MANIFEST.in
  6. 19
      README.md
  7. 118
      _freeze.py
  8. 5
      chapter-h/README.md
  9. BIN
      chapter-h/chapter-h-placeholders.zip
  10. 219
      conftest.py
  11. 1
      docker/config/.gitignore
  12. 5
      docker/config/debug.cfg
  13. 5
      docker/config/site.cfg
  14. 11
      docker/run.sh
  15. BIN
      examples/Hill 621 (Scenario E) (online).vsav
  16. 190
      examples/Hill 621 (Scenario E).json
  17. BIN
      examples/Hill 621 (Scenario E).png
  18. BIN
      examples/Hill 621 (Scenario E).small.jpg
  19. BIN
      examples/Hill 621 (Scenario E).vsav
  20. BIN
      examples/Hube's Pocket (Scenario G) (online).vsav
  21. 127
      examples/Hube's Pocket (Scenario G).json
  22. BIN
      examples/Hube's Pocket (Scenario G).png
  23. BIN
      examples/Hube's Pocket (Scenario G).small.jpg
  24. BIN
      examples/Hube's Pocket (Scenario G).vsav
  25. 4
      examples/README.md
  26. BIN
      examples/The Streets Of Stalingrad (Scenario C) (online).vsav
  27. 174
      examples/The Streets Of Stalingrad (Scenario C).json
  28. BIN
      examples/The Streets Of Stalingrad (Scenario C).png
  29. BIN
      examples/The Streets Of Stalingrad (Scenario C).small.jpg
  30. BIN
      examples/The Streets Of Stalingrad (Scenario C).vsav
  31. 221
      freeze.py
  32. BIN
      loader/assets/loading.gif
  33. 107
      loader/freeze.py
  34. 207
      loader/main.py
  35. 1
      pytest.ini
  36. 7
      requirements-dev.txt
  37. 10
      requirements.txt
  38. 396
      run-container.sh
  39. 52
      setup.py
  40. 189
      tools/build_file.py
  41. 73
      vasl_templates/about.py
  42. 96
      vasl_templates/file_dialog.py
  43. 192
      vasl_templates/main.py
  44. 254
      vasl_templates/main_window.py
  45. BIN
      vasl_templates/resources/file_browser.png
  46. 261
      vasl_templates/server_settings.py
  47. 64
      vasl_templates/tools/check_connect_roar.py
  48. 268
      vasl_templates/tools/dump_log_file_analysis.py
  49. 83
      vasl_templates/tools/get_piece_info.py
  50. 301
      vasl_templates/tools/make_chapter_h_placeholders.py
  51. 1493
      vasl_templates/tools/tests/fixtures/chapter-h-placeholders.txt
  52. 27
      vasl_templates/tools/tests/test_make_chapter_h_placeholders.py
  53. 258
      vasl_templates/tools/webdriver_stress_test.py
  54. 188
      vasl_templates/ui/about.ui
  55. 575
      vasl_templates/ui/server_settings.ui
  56. 78
      vasl_templates/utils.py
  57. 149
      vasl_templates/web_channel.py
  58. 256
      vasl_templates/webapp/__init__.py
  59. 1
      vasl_templates/webapp/config/.gitignore
  60. 12
      vasl_templates/webapp/config/constants.py
  61. 10
      vasl_templates/webapp/config/debug.cfg.example
  62. 69
      vasl_templates/webapp/config/logging.yaml.example
  63. 30
      vasl_templates/webapp/config/logging.yaml.template
  64. 16
      vasl_templates/webapp/config/site.cfg.example
  65. 48
      vasl_templates/webapp/data/asl-scenario-archive.json
  66. 11
      vasl_templates/webapp/data/default-scenario.json
  67. 40
      vasl_templates/webapp/data/default-template-pack/atmm-romanian.j2
  68. 15
      vasl_templates/webapp/data/default-template-pack/atmm.j2
  69. 55
      vasl_templates/webapp/data/default-template-pack/baz-cpva16.j2
  70. 57
      vasl_templates/webapp/data/default-template-pack/baz-cpva17.j2
  71. 36
      vasl_templates/webapp/data/default-template-pack/baz.j2
  72. 60
      vasl_templates/webapp/data/default-template-pack/baz45.j2
  73. 62
      vasl_templates/webapp/data/default-template-pack/baz50.j2
  74. 25
      vasl_templates/webapp/data/default-template-pack/common.css
  75. 5
      vasl_templates/webapp/data/default-template-pack/compass.j2
  76. 11
      vasl_templates/webapp/data/default-template-pack/extras/blank-space.j2
  77. 41
      vasl_templates/webapp/data/default-template-pack/extras/booby-traps.j2
  78. 47
      vasl_templates/webapp/data/default-template-pack/extras/count-remaining.j2
  79. 49
      vasl_templates/webapp/data/default-template-pack/extras/grid.j2
  80. 62
      vasl_templates/webapp/data/default-template-pack/extras/hip-guns.j2
  81. 49
      vasl_templates/webapp/data/default-template-pack/extras/kakazu-ridge-cave-complexes.j2
  82. 87
      vasl_templates/webapp/data/default-template-pack/extras/kgs.j2
  83. 14
      vasl_templates/webapp/data/default-template-pack/extras/turn-track-shading.j2
  84. 9
      vasl_templates/webapp/data/default-template-pack/extras/victory-points.j2
  85. 27
      vasl_templates/webapp/data/default-template-pack/mol-p.j2
  86. 23
      vasl_templates/webapp/data/default-template-pack/mol.j2
  87. 57
      vasl_templates/webapp/data/default-template-pack/nat_caps.j2
  88. 467
      vasl_templates/webapp/data/default-template-pack/national-capabilities.json
  89. 145
      vasl_templates/webapp/data/default-template-pack/nationalities.json
  90. 59
      vasl_templates/webapp/data/default-template-pack/ob_ma_notes.j2
  91. 9
      vasl_templates/webapp/data/default-template-pack/ob_note.j2
  92. 40
      vasl_templates/webapp/data/default-template-pack/ob_ordnance.j2
  93. 11
      vasl_templates/webapp/data/default-template-pack/ob_setup.j2
  94. 43
      vasl_templates/webapp/data/default-template-pack/ob_vehicles.j2
  95. 4
      vasl_templates/webapp/data/default-template-pack/ob_vo.image.include
  96. 85
      vasl_templates/webapp/data/default-template-pack/ob_vo.j2
  97. 1
      vasl_templates/webapp/data/default-template-pack/ob_vo.name.include
  98. 6
      vasl_templates/webapp/data/default-template-pack/ob_vo.notes.include
  99. 27
      vasl_templates/webapp/data/default-template-pack/ob_vo_note.css
  100. 27
      vasl_templates/webapp/data/default-template-pack/ob_vo_note.j2
  101. Some files were not shown because too many files have changed in this diff Show More

@ -0,0 +1,8 @@
*
! setup.py
! requirements*.txt
! vasl_templates/
! vassal-shim/release/
! docker/
! LICENSE.txt

2
.gitignore vendored

@ -1,5 +1,7 @@
_work_/
_releases_/
.vscode
.venv*
*.pyc
.pytest_cache/

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

@ -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,3 +1,5 @@
recursive-include vasl_templates/ui *.*
recursive-include vasl_templates/webapp/config *.*
recursive-include vasl_templates/webapp/data *.*
recursive-include vasl_templates/webapp/static *.*

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

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

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 971 KiB

After

Width:  |  Height:  |  Size: 3.8 MiB

Binary file not shown.

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 &ge; 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"
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

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

@ -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 &ge; 2 more buildings initially occupied by the Germans than they lose of their own initially-held stone buildings to German Control, and/or have a favorable 3:1 ratio of unbroken squad-equivalents.\n<p> <b>Scenario B:</b> At Game End, the player with undisputed control of at least 6 hexes of building X3 wins. A hex containing a Melee is controlled by neither player. If only one player has an unbroken unit in the building at Game End, that player is the winner. Any other result is a draw.\n</ul>","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"
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 573 KiB

After

Width:  |  Height:  |  Size: 902 KiB

Binary file not shown.

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:] )

Binary file not shown.

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

@ -1,38 +1,48 @@
""" Setup the package.
Install this module in development mode to get the tests to work:
pip install --editable .[dev]
pip install --editable .[dev]
"""
import os
from setuptools import setup, find_packages
# ---------------------------------------------------------------------
# NOTE: We break the requirements out into separate files so that we can load them early
# into a Docker image, where they can be cached, instead of having to re-install them every time.
def parse_requirements( fname ):
"""Parse a requirements file."""
lines = []
fname = os.path.join( os.path.split(__file__)[0], fname )
with open( fname, "r", encoding="utf-8" ) as fp:
for line in fp:
line = line.strip()
if line == "" or line.startswith("#"):
continue
lines.append( line )
return lines
# ---------------------------------------------------------------------
setup(
name = "vasl_templates",
version = "0.1",
version = "1.12", # nb: also update constants.py
description = "Create HTML snippets for use in VASL.",
license = "AGPLv3",
url = "https://github.com/pacman-ghost/vasl-templates",
url = "https://code.pacman-ghost.com/public/vasl-templates",
packages = find_packages(),
install_requires = [
# Python 3.6.5
"flask==1.0.2",
# NOTE: PyQt5 requirements: https://doc.qt.io/qt-5/linux.html
# Linux: mesa-libGL-devel ; @"C Development Tools and Libraries"
# nb: WebEngine seems to be broken in 5.10.1 :-/
"PyQT5==5.10.0",
"pyyaml==3.13",
"click==6.7",
],
install_requires = parse_requirements( "requirements.txt" ),
extras_require = {
"dev": [
"pytest==3.6.0",
"tabulate==0.8.2",
"selenium==3.12.0",
"lxml==4.2.4",
"pylint==1.9.2",
"pytest-pylint==0.9.0",
"cx-Freeze==5.1.1",
"gui": [
# NOTE: PyQt5 requirements: https://doc.qt.io/qt-5/linux.html
# Linux: mesa-libGL-devel ; @"C Development Tools and Libraries"
# NOTE: You may need to disable VMware 3D acceleration, if QWebEngineView is crashing.
"PyQT5==5.15.4",
"PyQtWebEngine==5.15.4",
],
"dev": parse_requirements( "requirements-dev.txt" ),
},
include_package_data = True,
data_files = [

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

@ -5,19 +5,50 @@ import sys
import os
import os.path
import threading
import time
import traceback
import logging
import urllib.request
from urllib.error import URLError
from PyQt5.QtWidgets import QApplication
# FUDGE! This works around a problem running the compiled desktop app on Fedora 30.
# https://github.com/pyinstaller/pyinstaller/issues/1113#issuecomment-244855512
# https://github.com/pyinstaller/pyinstaller/issues/1113#issuecomment-551934945
import encodings.idna #pylint: disable=unused-import
import PyQt5.QtWebEngineWidgets
from PyQt5.QtWidgets import QApplication, QMessageBox
from PyQt5.QtCore import Qt, QSettings, QDir
import PyQt5.QtCore
import click
from vasl_templates.main_window import MainWindow
from vasl_templates.webapp import app as webapp
from vasl_templates.webapp import load_debug_config
from vasl_templates.webapp import main as webapp_main, snippets as webapp_snippets
# notify everyone that we're being run as the desktop application
os.environ[ "IS_DESKTOP_APP" ] = "1"
os.environ[ "QDIR_HOME_PATH" ] = QDir.homePath()
from vasl_templates.webapp.utils import SimpleError, is_windows
# NOTE: We're supposed to do the following to support HiDPI, but it causes the main window
# to become extremely large when the Windows zoom level is high (and it doesn't really fix
# the dialog layout problems anyway :-/).# Since we're a webapp running in a browser,
# desktop DPI isn't really an issue for us, we just need to make sure that the Qt dialogs
# look OK. I adjusted the layout for the About box so it's correct for HiDPI; it doesn't
# look great for normal DPI (too much whitespace), but it's useable.
# # nb: this must be done before the QApplication object is created
# QApplication.setAttribute( PyQt5.QtCore.Qt.AA_EnableHighDpiScaling, True )
# QApplication.setAttribute( PyQt5.QtCore.Qt.AA_UseHighDpiPixmaps, True )
# FUDGE! We need this to get the embedded browser working on Fedora 35 (things were
# still OK on Windows, but setting this doesn't seem to hurt), and it needs to be done
# before creating the QApplication.
os.environ[ "QTWEBENGINE_CHROMIUM_FLAGS" ] = "--no-sandbox"
# FUDGE! This needs to be created before showing any UI elements e.g. an error message box.
qt_app = QApplication( sys.argv )
app_settings = None
_webapp_error = None # nb: this needs to be global :shrug:
# ---------------------------------------------------------------------
@ -44,8 +75,43 @@ def qtMessageHandler( msg_type, context, msg ):# pylint: disable=unused-argument
@click.option( "--default-scenario", help="Default scenario settings." )
@click.option( "--remote-debugging", help="Chrome DevTools port number." )
@click.option( "--debug", help="Debug config file." )
def main( template_pack, default_scenario, remote_debugging, debug ): #pylint: disable=too-many-locals
"""Main entry point for the application."""
def main( template_pack, default_scenario, remote_debugging, debug ):
"""Manage HTML labels in a VASL scenario."""
try:
return _do_main( template_pack, default_scenario, remote_debugging, debug )
except Exception as ex: #pylint: disable=broad-except
# log the error
# NOTE: If we get here, there was probably an error during startup, so we can't
# assume too much about how much of our expected environment has been set up.
try:
fname = os.path.join( QDir.homePath(), "vasl-templates.log" )
with open( fname, "w", encoding="utf-8" ) as fp:
traceback.print_exc( file=fp )
except: #pylint: disable=bare-except
pass
QMessageBox.warning( None, "Unexpected error", str(ex) )
return -1
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
def _do_main( template_pack, default_scenario, remote_debugging, debug ): #pylint: disable=too-many-locals,too-many-branches
"""Do main processing."""
# NOTE: We do these imports here (instead of at the top of the file) so that we can catch errors.
from vasl_templates.webapp import app as webapp
from vasl_templates.webapp import globvars, load_debug_config
from vasl_templates.webapp import main as webapp_main, snippets as webapp_snippets
# initialize logging
# NOTE: We set up basic logging for people using the desktop app (if they are running from source,
# or using Docker, there is an expectation they can do this themselves). If logging has already
# been set up, this config is in *addition* to what's already been configured.
handler = logging.FileHandler( globvars.user_profile.default_log_fname, mode="w" )
handler.setLevel( logging.WARNING )
handler.setFormatter(
logging.Formatter( "%(asctime)s | %(message)s" )
)
logging.getLogger().addHandler( handler )
# configure the default template pack
if template_pack:
@ -61,7 +127,7 @@ def main( template_pack, default_scenario, remote_debugging, debug ): #pylint: d
# configure the default scenario
if default_scenario:
if not os.path.isfile( default_scenario ):
raise RuntimeError( "Can't find the default scenario file." )
raise SimpleError( "Can't find the default scenario file." )
webapp_main.default_scenario = default_scenario
# configure remote debugging
@ -70,10 +136,12 @@ def main( template_pack, default_scenario, remote_debugging, debug ): #pylint: d
os.environ["QTWEBENGINE_REMOTE_DEBUGGING"] = remote_debugging
# load the application settings
fname = "vasl-templates.ini" if sys.platform == "win32" else ".vasl-templates.conf"
if not os.path.isfile( fname ) :
fname = os.path.join( QDir.homePath(), fname )
settings = QSettings( fname, QSettings.IniFormat )
# FUDGE! Declaring app_settings as global here doesn't work on Windows (?!), we have to do this weird import :-/
import vasl_templates.main #pylint: disable=import-self
vasl_templates.main.app_settings = QSettings(
globvars.user_profile.desktop_settings_fname,
QSettings.IniFormat
)
# install the debug config file
if debug:
@ -82,32 +150,76 @@ def main( template_pack, default_scenario, remote_debugging, debug ): #pylint: d
# connect PyQt's logging to Python logging
PyQt5.QtCore.qInstallMessageHandler( qtMessageHandler )
# disable the Flask "do not use in a production environment" warning
import flask.cli
flask.cli.show_server_banner = lambda *args: None
# see if we can connect to the webapp server
port = webapp.config["FLASK_PORT_NO"]
url = "http://localhost:{}/ping".format( port )
# install the server settings
try:
resp = urllib.request.urlopen( url ).read()
except: #pylint: disable=bare-except
resp = None
if resp:
raise RuntimeError( "The application is already running." )
from vasl_templates.server_settings import install_server_settings #pylint: disable=cyclic-import
install_server_settings( True )
except Exception as ex: #pylint: disable=broad-except
# NOTE: We used to advise the user to check the app config file for errors, but exceptions can be thrown
# for reasons other than errors in that file (e.g. bad JSON in the vehicle/ordnance data files).
logging.critical( traceback.format_exc() )
from vasl_templates.main_window import MainWindow #pylint: disable=cyclic-import
MainWindow.showErrorMsg( "Couldn't install the server settings:\n\n{}".format( ex ) )
return 2
# start the webapp server
flask_port = webapp.config[ "FLASK_PORT_NO" ]
def webapp_thread():
"""Run the webapp server."""
try:
webapp.run( host="localhost", port=port, use_reloader=False )
except Exception as ex:
import waitress
# FUDGE! Browsers tend to send a max. of 6-8 concurrent requests per server, so we increase
# the number of worker threads to avoid task queue warnings :-/
nthreads = webapp.config.get( "WAITRESS_THREADS", 8 )
waitress.serve( webapp,
host="localhost", port=flask_port,
threads=nthreads
)
except Exception as ex: #pylint: disable=broad-except
logging.critical( "WEBAPP SERVER EXCEPTION: %s", ex )
logging.critical( traceback.format_exc() )
raise
# NOTE: We pass the exception to the GUI thread, where it can be shown to the user.
global _webapp_error
_webapp_error = ex
thread = threading.Thread( target=webapp_thread )
# FUDGE! If we detect another instance, we hang on Windows after reporting the error. Running the webapp
# in a daemon thread makes the problem go away - you would think the thread would terminate, since it wouldn't
# be able to listen on the same server port - but I guess not :-/
thread.daemon = True
thread.start()
# NOTE: We want to detect if another instance of the program is already running, but we can't simply
# try to connect to the webapp, since we can't tell the difference between connecting to the webapp
# we just started above, and an already-running instance. We handle this by assigning each instance
# a unique ID, which lets us figure out if we've connected to ourself, or another instance.
from vasl_templates.webapp.main import INSTANCE_ID
# wait for the webapp server to start
while True:
if _webapp_error:
break
try:
url = "http://localhost:{}/ping".format( flask_port )
with urllib.request.urlopen( url ) as resp:
resp_data = resp.read().decode( "utf-8" )
# we got a response - figure out if we connected to ourself or another instance
if resp_data[:6] != "pong: ":
raise SimpleError( "Unexpected server check response: {}".format( resp_data ) )
if resp_data[6:] == INSTANCE_ID:
break
from vasl_templates.webapp.config.constants import APP_NAME
QMessageBox.warning( None, APP_NAME, "The program is already running." )
return -1
except URLError:
# no response - the webapp server is probably still starting up
time.sleep( 0.25 )
continue
except Exception as ex: #pylint: disable=broad-except
raise ex
if _webapp_error:
# the webapp server didn't start up - re-raise the error in this thread
raise _webapp_error #pylint: disable=raising-bad-type
# check if we should disable OpenGL
# Using the QWebEngineView crashes on Windows 7 in a VM. It uses OpenGL, which is
# apparently not well supported on Windows, and is dependent on the graphics card driver:
@ -121,20 +233,28 @@ def main( template_pack, default_scenario, remote_debugging, debug ): #pylint: d
opengl_type = getattr( Qt, opengl_type )
QApplication.setAttribute( opengl_type )
#pylint: disable=line-too-long
# FUDGE! This works around a weird problem on Windows, if it has been configured to *not* show
# accelerator underlines by default. Pressing ALT is supposed to show them, but doesn't :-/
# The odd thing is, the default theme is "windowsvista", but we need to set it anyway (probably
# a timing issue during startup). It might also have something to do with virtualenv's:
# https://stackoverflow.com/questions/69032767/show-hide-menu-underline-accelerators-with-pyqt-according-to-platform-integratio#comment122036986_69032767
#pylint: enable=line-too-long
if is_windows():
QApplication.setStyle( "windowsvista" )
# disable the context help button in the title bar (Windows only)
QApplication.setAttribute( PyQt5.QtCore.Qt.AA_DisableWindowContextHelpButton )
# check if we should disable the embedded browser
disable_browser = webapp.config.get( "DISABLE_WEBENGINEVIEW" )
# run the application
app = QApplication( sys.argv )
url = "http://localhost:{}".format( port )
main_window = MainWindow( settings, url, disable_browser )
url = "http://localhost:{}".format( flask_port )
from vasl_templates.main_window import MainWindow #pylint: disable=cyclic-import
main_window = MainWindow( url, disable_browser )
main_window.show()
ret_code = app.exec_()
# shutdown the webapp server
url = "http://localhost:{}/shutdown".format( port )
urllib.request.urlopen( url ).read()
thread.join()
ret_code = qt_app.exec_()
return ret_code

@ -1,18 +1,23 @@
""" Main application window. """
import sys
import os
import re
import json
import base64
import logging
from PyQt5.QtWidgets import QWidget, QVBoxLayout, QLabel, QMessageBox
from PyQt5.QtWidgets import QWidget, QVBoxLayout, QMenuBar, QAction, QLabel, QMessageBox
from PyQt5.QtWebEngineWidgets import QWebEngineView, QWebEngineProfile, QWebEnginePage
from PyQt5.QtWebChannel import QWebChannel
from PyQt5.QtGui import QDesktopServices, QIcon
from PyQt5.QtCore import Qt, QUrl, pyqtSlot
from PyQt5.QtCore import Qt, QUrl, QMargins, pyqtSlot, QVariant
from vasl_templates.webapp.config.constants import APP_NAME
from vasl_templates.webapp.config.constants import APP_NAME, APP_VERSION, IS_FROZEN
from vasl_templates.webapp import globvars
from vasl_templates.main import app_settings
from vasl_templates.web_channel import WebChannelHandler
from vasl_templates.utils import log_exceptions
from vasl_templates.utils import catch_exceptions, launch_file
_CONSOLE_SOURCE_REGEX = re.compile( r"^http://.+?/static/(.*)$" )
@ -21,58 +26,89 @@ _CONSOLE_SOURCE_REGEX = re.compile( r"^http://.+?/static/(.*)$" )
class AppWebPage( QWebEnginePage ):
"""Application web page."""
def acceptNavigationRequest( self, url, nav_type, is_mainframe ): #pylint: disable=no-self-use,unused-argument
def acceptNavigationRequest( self, url, nav_type, is_mainframe ): #pylint: disable=unused-argument
"""Called when a link is clicked."""
if url.host() in ("localhost","127.0.0.1"):
return True
if "/asl-rulebook2/" not in url.url(): # nb: asl-rulebook2 links are routed through our webapp
return True
if not is_mainframe:
# NOTE: We get here if we're in a child frame (e.g. Google Maps). However, we can't just ignore
# these requests, because the help is also in a frame, and we want links to open in an external browser.
# Sigh...
if "google.com/maps" in url.url():
return True
QDesktopServices.openUrl( url )
return False
def javaScriptConsoleMessage( self, level, msg, line_no, source_id ): #pylint: disable=unused-argument,no-self-use
def javaScriptConsoleMessage( self, level, msg, line_no, source_id ): #pylint: disable=unused-argument
"""Log a Javascript console message."""
mo = _CONSOLE_SOURCE_REGEX.search( source_id )
source = mo.group(1) if mo else source_id
logger = logging.getLogger( "javascript" )
logger.info( "%s:%d - %s", source, line_no, msg )
logger.warning( "%s:%d - %s", source, line_no, msg )
# ---------------------------------------------------------------------
class MainWindow( QWidget ):
"""Main application window."""
def __init__( self, settings, url, disable_browser ):
instance = None
def __init__( self, url, disable_browser ):
# initialize
super().__init__()
self.settings = settings
self._view = None
self._is_closing = False
# initialize the main window
self.setWindowTitle( APP_NAME )
if IS_FROZEN:
base_dir = os.path.join( sys._MEIPASS, "vasl_templates/webapp" ) #pylint: disable=no-member,protected-access
else:
base_dir = os.path.join( os.path.split(__file__)[0], "webapp" )
self.setWindowIcon( QIcon(
os.path.join( os.path.split(__file__)[0], "webapp/static/images/app.ico" )
os.path.join( base_dir, "static/images/app.ico" )
) )
# create the menu
menu_bar = QMenuBar( self )
file_menu = menu_bar.addMenu( "&File" )
def add_action( caption, icon, handler ):
"""Add a menu action."""
icon = QIcon( os.path.join( base_dir, "static/images/menu", icon ) if icon else None )
action = QAction( icon, caption, self )
action.triggered.connect( handler )
file_menu.addAction( action )
add_action( "&Settings", "settings.png", self.on_settings )
add_action( "&Log file", "log.png", self.on_log_file )
add_action( "&About", "info.png", self.on_about )
file_menu.addSeparator()
add_action( "E&xit", "exit.png", self.on_exit )
# set the window geometry
if disable_browser:
self.setFixedSize( 300, 100 )
self.setFixedSize( 300, 108 )
else:
# restore it from the previous session
val = self.settings.value( "MainWindow/geometry" )
val = app_settings.value( "MainWindow/geometry" )
if val :
self.restoreGeometry( val )
else :
self.resize( 1000, 600 )
self.setMinimumSize( 800, 500 )
self.resize( 1000, 650 )
# NOTE: This should be wide enough for the sortable hints to not wrap (so that
# we don't see a scrollbar when their panels are reduced to their minimum height).
# We also want the Trumbowyg button pane for the VC to wrap somewhere sensible.
self.setMinimumSize( 1030, 650 )
# initialize the layout
layout = QVBoxLayout( self )
layout.setMenuBar( menu_bar )
# FUDGE! We offer the option to disable the QWebEngineView since getting it to run
# under Windows (especially older versions) is unreliable (since it uses OpenGL).
# By disabling it, the program will at least start (in particular, the webapp server),
# and non-technical users can then open an external browser and connect to the webapp
# that way. Sigh...
layout = QVBoxLayout( self )
if not disable_browser:
# initialize the web view
@ -82,6 +118,10 @@ class MainWindow( QWidget ):
# initialize the web page
# nb: we create an off-the-record profile to stop the view from using cached JS files :-/
profile = QWebEngineProfile( None, self._view )
version = APP_NAME.lower().replace( " ", "-" ) + "/" + APP_VERSION[1:]
profile.setHttpUserAgent(
re.sub( r"QtWebEngine/\S+", version, profile.httpUserAgent() )
)
page = AppWebPage( profile, self._view )
self._view.setPage( page )
@ -110,8 +150,14 @@ class MainWindow( QWidget ):
"<p> Close this window when you're done.".format(
APP_NAME, url
) )
label.setStyleSheet( "QLabel { background-color: white ; padding: 0.5em ; }" )
label.setOpenExternalLinks( True )
layout.addWidget( label )
layout.setContentsMargins( QMargins(0,0,0,0) )
# register the instance
assert MainWindow.instance is None
MainWindow.instance = self
def closeEvent( self, evt ) :
"""Handle requests to close the window (i.e. exit the application)."""
@ -123,8 +169,11 @@ class MainWindow( QWidget ):
def close_window():
"""Close the main window."""
if self._view:
self.settings.setValue( "MainWindow/geometry" , self.saveGeometry() )
app_settings.setValue( "MainWindow/geometry", self.saveGeometry() )
self.close()
# FUDGE! We need to do this to stop PyQt 5.15.2 from complaining that the profile
# is being deleted while the page is still alive.
self._view.page().deleteLater()
# check if the scenario is dirty
def callback( is_dirty ):
@ -135,7 +184,7 @@ class MainWindow( QWidget ):
close_window()
return
# yup - ask the user to confirm the close
rc = self.ask(
rc = MainWindow.ask(
"This scenario has been changed\n\nDo you want to close the program, and lose your changes?",
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No
@ -147,38 +196,167 @@ class MainWindow( QWidget ):
self._view.page().runJavaScript( "is_scenario_dirty()", callback )
evt.ignore() # nb: we wait until the Javascript finishes to process the event
def showInfoMsg( self, msg ):
@staticmethod
def showInfoMsg( msg ):
"""Show an informational message."""
QMessageBox.information( self , APP_NAME , msg )
QMessageBox.information( MainWindow.instance, APP_NAME, msg )
def showErrorMsg( self, msg ):
@staticmethod
def showErrorMsg( msg ):
"""Show an error message."""
QMessageBox.warning( self , APP_NAME , msg )
QMessageBox.warning( MainWindow.instance, APP_NAME, msg )
def ask( self, msg , buttons , default ) :
@staticmethod
def ask( msg, buttons, default ) :
"""Ask the user a question."""
return QMessageBox.question( self , APP_NAME , msg , buttons , default )
return QMessageBox.question( MainWindow.instance, APP_NAME, msg, buttons, default )
def on_exit( self ):
"""Menu action handler."""
self.close()
def on_settings( self ):
"""Menu action handler."""
from vasl_templates.server_settings import ServerSettingsDialog #pylint: disable=cyclic-import
dlg = ServerSettingsDialog( self )
dlg.exec_()
def on_log_file( self ):
"""Menu action handler."""
launch_file( globvars.user_profile.default_log_fname )
def on_about( self ):
"""Menu action handler."""
from vasl_templates.about import AboutDialog #pylint: disable=cyclic-import
dlg = AboutDialog( self )
dlg.exec_()
@pyqtSlot()
@log_exceptions( caption="SLOT EXCEPTION" )
def on_new_scenario( self):
@catch_exceptions( caption="SLOT EXCEPTION" )
def on_app_loaded( self ):
"""Called when the application has finished loading.
NOTE: This handler might be called multiple times.
"""
def decode_val( val ):
"""Decode a settings value."""
# NOTE: Comma-separated values are deserialized as lists automatically.
if val == "true":
return True
if val == "false":
return False
if str(val).isdigit():
return int(val)
return val
# load and install the user settings
user_settings = {}
for key in app_settings.allKeys():
if key.startswith( "UserSettings/" ):
val = app_settings.value( key )
key = key[13:] # remove the leading "UserSettings/"
sections = key.split( "." )
target = user_settings
while len(sections) > 1:
if sections[0] not in target:
target[ sections[0] ] = {}
target = target[ sections.pop(0) ]
target[ sections[0] ] = decode_val( val )
self._view.page().runJavaScript(
"install_user_settings('{}')".format( json.dumps( user_settings ) )
)
@pyqtSlot()
@catch_exceptions( caption="SLOT EXCEPTION" )
def on_new_scenario( self ):
"""Called when the user wants to load a scenario."""
self._web_channel_handler.on_new_scenario()
@pyqtSlot( result=str )
@log_exceptions( caption="SLOT EXCEPTION" )
@pyqtSlot( result=QVariant )
@catch_exceptions( caption="SLOT EXCEPTION" )
def load_scenario( self ):
"""Called when the user wants to load a scenario."""
return self._web_channel_handler.load_scenario()
@pyqtSlot( str, result=bool )
@log_exceptions( caption="SLOT EXCEPTION" )
def save_scenario( self, data ):
fname, data = self._web_channel_handler.load_scenario()
if data is None:
return None
return QVariant( {
"filename": fname,
"data": data
} )
@pyqtSlot( str, str, result=str )
@catch_exceptions( caption="SLOT EXCEPTION", retval=False )
def save_scenario( self, fname, data ):
"""Called when the user wants to save a scenario."""
return self._web_channel_handler.save_scenario( data )
fname = self._web_channel_handler.save_scenario( fname, data )
return fname
@pyqtSlot( result=QVariant )
@catch_exceptions( caption="SLOT EXCEPTION" )
def load_vsav( self ):
"""Called when the user wants to update a VASL scenario."""
fname, data = self._web_channel_handler.load_vsav()
if data is None:
return None
return QVariant( {
"filename": fname,
"data": base64.b64encode( data ).decode( "utf-8" )
} )
@pyqtSlot( str, str, result=bool )
@catch_exceptions( caption="SLOT EXCEPTION", retval=False )
def save_updated_vsav( self, fname, data ):
"""Called when a VASL scenario has been updated and is ready to be saved."""
data = base64.b64decode( data )
return self._web_channel_handler.save_updated_vsav( fname, data )
@pyqtSlot( str )
@catch_exceptions( caption="SLOT EXCEPTION", retval=False )
def save_log_file_analysis( self, data ):
"""Called when the user wants to save a log file analysis."""
self._web_channel_handler.save_log_file_analysis( data )
@pyqtSlot( str )
@catch_exceptions( caption="SLOT EXCEPTION" )
def on_user_settings_change( self, user_settings ):
"""Called when the user changes the user settings."""
# delete all existing keys
for key in app_settings.allKeys():
if key.startswith( "UserSettings/" ):
app_settings.remove( key )
# save the new user settings
def save_section( vals, key_prefix ):
"""Save a section of the User Settings."""
for key,val in vals.items():
if isinstance( val, dict ):
# FUDGE! The PyQt doco claims that it supports nested sections, but key names that have
# a slash in them get saved as a top-level key, with the slash converted to a back-slash,
# even on Linux :-/ We use dotted key names to represent nested levels.
save_section( val, key_prefix+key+"." )
continue
# NOTE: PyQt handles lists automatically, converting them to a comma-separated list,
# and de-serializing them as lists (string values with a comma in them get quoted).
app_settings.setValue(
"UserSettings/{}".format( key_prefix + key ),
val
)
save_section( json.loads( user_settings ), "" )
@pyqtSlot( str, bool )
@catch_exceptions( caption="SLOT EXCEPTION" )
def on_update_scenario_status( self, caption, is_dirty ):
"""Update the UI to show the scenario's status."""
self._web_channel_handler.on_update_scenario_status( caption, is_dirty )
@pyqtSlot( str )
@log_exceptions( caption="SLOT EXCEPTION" )
def on_scenario_name_change( self, val ):
"""Update the main window title to show the scenario name."""
self._web_channel_handler.on_scenario_name_change( val )
@catch_exceptions( caption="SLOT EXCEPTION" )
def on_snippet_image( self, img_data ):
"""Called when a snippet image has been generated."""
self._web_channel_handler.on_snippet_image( img_data )
@pyqtSlot( str, str, result=bool )
@catch_exceptions( caption="SLOT EXCEPTION", retval=False )
def save_downloaded_vsav( self, fname, data ):
"""Called when a VASL scenario has been downloaded."""
data = base64.b64decode( data )
# NOTE: We handle this the same as saving an updated VSAV.
return self._web_channel_handler.save_updated_vsav( fname, data )

Binary file not shown.

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

File diff suppressed because it is too large Load Diff

@ -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>&amp;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>&amp;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&amp;SSAL installation:</string>
</property>
<property name="buddy">
<cstring>vassal_dir</cstring>
</property>
</widget>
</item>
<item row="0" column="1">
<layout class="QHBoxLayout" name="horizontalLayout_3">
<property name="spacing">
<number>2</number>
</property>
<item>
<widget class="QLineEdit" name="vassal_dir"/>
</item>
<item>
<widget class="QPushButton" name="select_vassal_dir_button">
<property name="minimumSize">
<size>
<width>22</width>
<height>22</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>22</width>
<height>22</height>
</size>
</property>
<property name="text">
<string/>
</property>
</widget>
</item>
</layout>
</item>
<item row="1" column="0">
<widget class="QLabel" name="label">
<property name="text">
<string>&amp;VASL module:</string>
</property>
<property name="buddy">
<cstring>vasl_mod</cstring>
</property>
</widget>
</item>
<item row="1" column="1">
<layout class="QHBoxLayout" name="horizontalLayout_7">
<property name="spacing">
<number>2</number>
</property>
<item>
<widget class="QLineEdit" name="vasl_mod"/>
</item>
<item>
<widget class="QPushButton" name="select_vasl_mod_button">
<property name="sizePolicy">
<sizepolicy hsizetype="Fixed" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="minimumSize">
<size>
<width>22</width>
<height>22</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>22</width>
<height>22</height>
</size>
</property>
<property name="text">
<string/>
</property>
<property name="autoDefault">
<bool>true</bool>
</property>
</widget>
</item>
</layout>
</item>
<item row="2" column="0">
<widget class="QLabel" name="label_6">
<property name="text">
<string>VASL e&amp;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 &amp;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 &amp;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 &amp;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&amp;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,13 +1,58 @@
""" Miscellaneous utilities. """
import os
import platform
import subprocess
import functools
import logging
import traceback
import json
from vasl_templates.webapp import app
from vasl_templates.webapp.config.constants import BASE_DIR, IS_FROZEN
# ---------------------------------------------------------------------
def get_build_info():
"""Get the program build info."""
# locate and load the build info file
fname = os.path.join( BASE_DIR, "config", "build-info.json" )
if not os.path.isfile( fname ):
return None
with open( fname, "r", encoding="utf-8" ) as fp:
build_info = json.load( fp )
# get the build timestamp
result = { "timestamp": build_info["timestamp"] }
# get the git info
if "branch_name" in build_info:
git_info = build_info[ "branch_name" ]
if "last_commit_id" in build_info:
git_info += ":{}".format( build_info["last_commit_id"][:8] )
result["git_info"] = git_info
return result
def get_build_git_info():
"""Get the git details for the current build."""
if IS_FROZEN:
build_info = get_build_info()
if build_info:
return build_info[ "git_info" ]
elif app.config.get( "IS_CONTAINER" ):
return os.environ.get( "BUILD_GIT_INFO" )
return None
# ---------------------------------------------------------------------
def log_exceptions( caption="EXCEPTION" ):
"""Decorator that logs exceptions thrown by the wrapped function."""
def catch_exceptions( caption="EXCEPTION", retval=None ):
"""Decorator that handles exceptions thrown by the wrapped function.
We have to wrap every callback fuction that the front-end invokes with this,
otherwise an exception will cause the program to crash and die :-/
"""
def decorator( func ):
"""The real decorator function."""
@functools.wraps( func )
@ -15,9 +60,34 @@ def log_exceptions( caption="EXCEPTION" ):
"""Wrapper around the function being decorated."""
try:
return func( *args, **kwargs )
except Exception as ex:
except Exception as ex: #pylint: disable=broad-except
logging.critical( "%s: %s", caption, ex )
logging.critical( traceback.format_exc() )
raise
from vasl_templates.main_window import MainWindow #pylint: disable=cyclic-import
MainWindow.showErrorMsg( "Unexpected callback error:\n\n{}".format( str(ex) ) )
return retval
return wrapper
return decorator
# ---------------------------------------------------------------------
def show_msg_store( msg_store ):
"""Show messages in a MsgStore."""
# NOTE: It would be nice to show a single dialog with all the messages, each one tagged with
# a pretty little icon, but for now, we just show a message box for each message :-/
from vasl_templates.main_window import MainWindow
for msg_type in ("error","warning"):
for msg in msg_store.get_msgs( msg_type ):
MainWindow.showErrorMsg( msg )
# ---------------------------------------------------------------------
def launch_file( fname ):
"""Launch the specified file."""
if platform.system() == "Windows":
os.startfile( fname ) #pylint: disable=no-member
elif platform.system() == "Darwin":
subprocess.call( ("open", fname) )
else:
subprocess.call( ("xdg-open", fname) )

@ -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,263 @@
""" 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_sigint( signum, stack ): #pylint: disable=unused-argument
"""Clean up after a SIGINT."""
# 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" )
# 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_sigint )
@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.12" # 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 &le; {%if SCENARIO_YEAR < 1944%} 2 {%else%} 3 {%endif%} (&#9651;) <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>

@ -1,9 +1,8 @@
<html>
<html> <!-- vasl-templates:id {{SNIPPET_ID}} -->
<head>
<style>
td { margin: 0 ; padding: 0 ; }
</style>
<meta charset="utf-8">
<style> {{CSS:common}} </style>
</head>
<table>
@ -13,16 +12,16 @@ td { margin: 0 ; padding: 0 ; }
background: {{OB_COLOR}} ;
border-bottom: 1px solid {{OB_COLOR_2}} ;
padding: 2px 5px ;
font-weight: bold ;
font-size: 105% ; font-weight: bold ;
">
Anti-Tank Magnetic Mines
{{INCLUDE:player_flag_large}}Anti-Tank Magnetic Mines
<tr>
<td style="padding:2px 5px;">
ATMM check: dr &le; 3 (&#9651;) <br>
<b>ATMM check</b>: dr &le; 3 (&#9651;) <br>
<table style="margin-left:10px;">
<tr>
<td style="width:20px;"> +1 <td> HS/crew
<td style="width:25px;"> +1 <td> HS/crew
<tr>
<td> +2 <td> SMC
<tr>

@ -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> &nbsp;
<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> &nbsp;
<tr>
<td> <b>X#:</b>
<td class="r"> 10
</table>
</table>
</html>

@ -1,10 +1,9 @@
<html>
<html> <!-- vasl-templates:id {{SNIPPET_ID}} -->
<head>
<meta charset="utf-8">
<style>
td { margin: 0 ; padding: 0 ; }
td.c { text-align: center ; }
td.r { text-align: right ; }
{{CSS:common}}
</style>
</head>
@ -15,16 +14,16 @@ td.r { text-align: right ; }
background: {{OB_COLOR}} ;
border-bottom: 1px solid {{OB_COLOR_2}} ;
padding: 2px 5px ;
font-weight: bold ;
font-size: 105% ; font-weight: bold ;
">
Bazooka {%if BAZ_TYPE%} ('{{BAZ_TYPE}}) {%endif%}
{{INCLUDE:player_flag_large}}Bazooka {%if BAZ_TYPE%} ('{{BAZ_TYPE}}) {%endif%}
<tr>
<td style="padding:0 3px;">
<td style="padding:0 5px;">
<table>
<tr>
<td> <b>Range</b> <td> <b>TH#</b>
<td class="c"> Range <td class="c" width="35"> <b>TH#</b>
{%if BAZ_TYPE == 45%}
<tr>
<td class="c"> 0 <td class="c"> 11
@ -52,28 +51,25 @@ td.r { text-align: right ; }
{%endif%}
</table>
<td valign="top" style="padding:0 3px;">
<td valign="top" style="padding:0 5px;">
<table>
{%if BAZ_BREAKDOWN%}
<tr>
<td> <b>TK#:</b>
<td class="r"> {{BAZ_TK}}
<tr>
<td colspan="2" class="r"> 8-{{BAZ_RANGE}}
<tr>
<td> &nbsp;
<tr>
<td> <b>X#:</b>
<td class="r"> {{BAZ_BREAKDOWN}}
{%endif%}
{%if BAZ_WP%}
<tr>
<td> <b>WP#:</b>
<td class="r"> {{BAZ_WP}}
{%endif%}
{%if BAZ_TOKILL%}
<tr>
<td> <b>TK#:</b>
<td class="r"> {{BAZ_TOKILL}}
{%endif%}
{%if BAZ_RANGE%}
<tr>
<td colspan="2" class="r"> 8-{{BAZ_RANGE}}
{%endif%}
</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> &nbsp;
<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> &nbsp;
<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;"> &nbsp;
</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}}">&nbsp;Booby Traps <span class="level">(Level {{LEVEL:A::B::C/3|Level}})</span>
<tr>
<td style="padding:2px 5px;">
<b> {%if BOARDS%} Boards: {{BOARDS}} {%else%} Entire map {%endif%} </b>
<ul>
<li> Original TC
{% if LEVEL == "A" %} &ge; 11
{% elif LEVEL == "B" %} 11
{% elif LEVEL == "C" %} 12
{%else%} ??? {%endif%}
<li> Search Casualties
</ul>
</table>

@ -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&half; &times; #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 &times; #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&half; &times; # 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&half; &times; #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> &nbsp;
<td class="header2" colspan=2> Hidden
<td class="header2" colspan=2> Possible
<tr>
<td> &nbsp;
<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> &nbsp; <td> &nbsp;
<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> &nbsp; <td> &nbsp;
<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> &nbsp; <td> &nbsp; <td> 2-5 <td> 6-10
<tr>
<td class="status"> P P
<td> &nbsp; <td> &nbsp; <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}}">&nbsp;{%endif%}{{TYPE}}
{% if TYPE == "Grenade Bundles" %}
<tr>
<td style="padding:3px 5px 0 5px;">
CC Attack -2 DRM
<tr>
<td style="padding:3px 5px 0 5px;">
<b>ATMM check</b>: dr &le; 3 (&#9651;)
<table style="margin-left:10px;">
<tr>
<td style="width:20px;"> +1
<td> HS/crew
<tr>
<td> +2
<td> SMC
<tr>
<td> +1
<td> CX
<tr>
<td> +1
<td> vs. non-armored vehicle
</table>
original 6 = pinned (CCV reduced by 1)
{% elif TYPE == "Molotov Cocktails" %}
<tr>
<td style="padding:3px 5px 0 5px;">
Against AFV only.
<tr>
<td style="padding:3px 5px 0 5px;">
<b>MOL check</b>: dr &le; 3 (&#9651;)
<table style="margin-left:10px;">
<tr>
<td style="width:20px;"> +1
<td> HS/crew
<tr>
<td> +2
<td> SMC
<tr>
<td> +1
<td> CX
</table>
<tr>
<td style="padding:3px 5px 0 5px;">
<b>IFT DR original colored dr</b>:
<ul>
<li> 1 = Flame in target Location
<li> 6 = thrower breaks, Flame in their Location
</ul>
<tr>
<td style="padding:3px 5px 0 5px;">
<b>Kindling Attempt</b>: +2 DRM
{%endif%}
</table>
</html>

@ -0,0 +1,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

@ -1,11 +1,9 @@
<html>
<html> <!-- vasl-templates:id {{SNIPPET_ID}} -->
<head>
<meta charset="utf-8">
<style>
td { margin: 0 ; padding: 0 ; }
td.c { text-align: center ; }
td.r { text-align: right ; }
ul { margin: 0 0 0 10px ; padding: 0 ; }
{{CSS:common}}
</style>
</head>
@ -16,16 +14,16 @@ ul { margin: 0 0 0 10px ; padding: 0 ; }
background: {{OB_COLOR}} ;
border-bottom: 1px solid {{OB_COLOR_2}} ;
padding: 2px 5px ;
font-weight: bold ;
font-size: 105% ; font-weight: bold ;
">
MOL Projector
{{INCLUDE:player_flag_large}}MOL Projector
<tr>
<td style="padding:0 3px;width:1%;">
<td style="padding:0 5px;">
<table>
<tr>
<td> <b>Range</b> <td> <b>TH#</b>
<td class="c"> Range <td class="c" width="35"> <b>TH#</b>
<tr>
<td class="c"> 0 <td class="c"> 10
<tr>
@ -38,25 +36,28 @@ ul { margin: 0 0 0 10px ; padding: 0 ; }
<td class="c"> 4 <td class="c"> 4
</table>
<td valign="top" style="padding:0 3px;">
<td valign="top" style="padding:0 5px;" width="170">
<table>
<tr>
<td colspan="2" class="r"> 4-4
<tr>
<td> &nbsp;
<tr>
<td> <b>X#:</b>
<td class="r"> 12
<tr>
<td> <b>B#:</b>
<td class="r"> 11
<tr>
<td colspan="2" class="r"> 4-4
</table>
<tr>
<td colspan="2">
IFT DR original colored dr:
<b>IFT DR original colored dr</b>:
<ul>
<li> 1 = Flame in target Location
<li> 6 = thrower breaks, Flame in their Location
</ul>
</table>
</html>

@ -1,9 +1,9 @@
<html>
<html> <!-- vasl-templates:id {{SNIPPET_ID}} -->
<head>
<meta charset="utf-8">
<style>
td { margin: 0 ; padding: 0 ; }
ul { margin: 0 0 0 10px ; padding: 0 ; }
{{CSS:common}}
</style>
</head>
@ -14,16 +14,16 @@ ul { margin: 0 0 0 10px ; padding: 0 ; }
background: {{OB_COLOR}} ;
border-bottom: 1px solid {{OB_COLOR_2}} ;
padding: 2px 5px ;
font-weight: bold ;
font-size: 105% ; font-weight: bold ;
">
Molotov Cocktail
{{INCLUDE:player_flag_large}}Molotov Cocktail
<tr>
<td style="padding:0 5px;">
MOL check: dr &le; 3 (&#9651;) <br>
<table style="margin-left:10px;">
<b>MOL check</b>: dr &le; 3 (&#9651;) <br>
<table style="margin:0 0 5px 10px;">
<tr>
<td style="width:20px;"> +1 <td> HS/crew
<td style="width:25px;"> +1 <td> HS/crew
<tr>
<td> +2 <td> SMC
<tr>
@ -31,12 +31,13 @@ ul { margin: 0 0 0 10px ; padding: 0 ; }
<tr>
<td> +1 <td> non-AFV target
</table>
IFT DR original colored dr:
<ul>
<b>IFT DR original colored dr</b>:
<ul style="margin-bottom:5px;">
<li> 1 = Flame in target Location
<li> 6 = thrower breaks, Flame in their Location
</ul>
Kindling Attempt: +2 DRM
<b>Kindling Attempt</b>: +2 DRM
</table>
</html>

@ -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}}&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": "&le; 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 &amp; 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": "&le; 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": "&le; 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": "&le; 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) &amp; Self-Recombine OK",
"Cowering NA",
"Commandos",
"No Non-Qualified Use penalty for RCL",
"No Captured Use penalty for Communist SW"
] },
{ "caption": "Airborne (6-6-7)", "allow_empty": true },
{ "caption": "Katusa", "notes": [
"As U.S. Army MMC",
"HoB +3 DRM",
"Leader Creation +1 drm",
"{! 09/1950-10/1951 = ELR 2 !}",
"{! 09/1950-10/1951 = Allied Troop penalties with U.S. leaders !}"
] },
{ "caption": "Tactical Air Control Party", "notes": [
"Inherent Radio (Contact = 9)",
"May set up HIP"
] }
]
},
"british": {
"th_color": "Black",
"oba": [ "8B", "2R" ], "oba_access": "&le; 2",
"hob_drm": "-1 DRM",
"grenades": "{? 01/1944- | SMOKE | Smoke | SMOKE<sup>44+</sup> ?}",
"notes": [
{ "caption": "Elite &amp; 1st Line", "notes": [
"Cowering NA"
] },
{ "caption": "ANZAC", "notes": [
"Stealthy (unless Green)"
] },
{ "caption": "Gurkha", "notes": [
"-1 CC DRM",
"Disrupt &amp; RtPh Surrender NA",
"Commando (unless Green)",
"Stealthy"
] }
]
},
"french": {
"th_color": [ "Black", "AFV use Red TH#" ],
"oba": [ "6B", "2R" ], "oba_access": "&le; 1",
"hob_drm": "+1 DRM",
"grenades": "Smoke"
},
"free-french": {
"th_color": "Black",
"oba": [ "8B", "2R" ], "oba_access": "&le; 2",
"hob_drm": "-1 DRM",
"grenades": "{? 01/1944- | SMOKE | Smoke | SMOKE<sup>44+</sup> ?}",
"notes": [
"{? 12/1943- | Assault Fire | No Assault Fire | Assault Fire<sup>12/43+</sup> ?}",
"{? 12/1943-05/1945 | Inherent Crews as British for Morale | | Inherent Crews as British for Morale<sup>12/43-5/45</sup> ?}",
{ "caption": "Elite &amp; 1st Line", "notes": [
"Cowering NA"
] },
{ "caption": "No Captured Use penalty", "notes": [
"U.S. MTR/BAZ",
"Vichy French SW",
"{? -11/1943 | British (f) vehicles/Guns/SW | | British (f) vehicles/Guns/SW <sup>-11/43</sup> ?}",
"{? 12/1943-05/1945 | British/French (a)/(f) SW | | British/French (a)/(f) SW<sup>12/43-5/45</sup> ?}"
] }
]
},
"italian": {
"th_color": "Red",
"oba": [ "7B", "3R" ], "oba_access": "&le; 1",
"hob_drm": "+3 DRM",
"grenades": "Smoke",
"notes": [
"Escape NA",
{ "caption": "1st Line &amp; Conscript", "notes": [
"Surrender on HoB Final DR &ge; 10",
"Deploy NA",
"+1 CC Capture DRM NA",
"Always Lax",
"1 PAATC"
] }
]
},
"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": "&le; 1",
"hob_drm": "-1 DRM",
"grenades": null,
"notes": [
"Deploy (1TC) &amp; 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 &amp; 1st Line", "notes": [
"Always Stealthy",
"Use FT/DC as Elite",
"{? 07/1944- | Inherent PF | No Inherent PF | Inherent PF<sup>7/44+</sup> ?}"
] }
]
},
"swedish": {
"th_color": [ "Red", "[EXC: MG]" ],
"oba": [ "6B", "3R"],
"hob_drm": "0 DRM",
"notes": [
"Extreme Winter effects NA",
{ "caption": "1<sup>st</sup> Line", "notes": [
"Battle Hardening &rarr; Fanatic"
] },
{ "caption": "Allied Troops", "notes": [
"Captured Use penalties NA"
] }
]
},
"axis-minor": {
"th_color": "Red",
"oba": [ "6B", "3R" ], "oba_access": "&le; 1",
"hob_drm": "+3 DRM",
"grenades": "Smoke",
"notes": [
"Escape NA",
{ "caption": "1st Line &amp; Conscript", "notes": [
"1 PAATC",
"Surrender on HoB Final DR &ge; 10"
] },
{ "caption": "Romanian, Hungarian", "notes": [
"{! -02/1944 = No Inherent PF | 03/1944-05/1944 = | 06/1944- = Inherent PF in non-Crew MMC | Inherent PF <span class='comment'>(Romanian<sup>3/44+</sup>, Hungarian<sup>6/44+</sup>)</span> !}"
] },
{ "caption": "Romanian", "notes": [
"{! 03/1944-05/1944 = Inherent PF in non-Crew MMC !}",
"{? 07/1943- | Inherent ATMM in non-Crew Elite <br> and 1st Line MMC (-2 CC DRM) | No Inherent ATMM | Inherent ATMM<sup>(7/43+)</sup> ?}"
] }
]
},
"allied-minor": {
"th_color": "Red",
"oba": [ "6B", "3R" ], "oba_access": "&le; 1",
"hob_drm": "+2 DRM",
"grenades": "Smoke",
"notes": [
"+1 Broken Morale vs Italians",
{ "caption": "1st Line &amp; Green", "notes": [
"1 PAATC"
] }
]
},
"japanese": {
"th_color": "Black",
"oba": [ "5B", "2R" ], "oba_access": "&le; 1",
"hob_drm": "+4 DRM",
"grenades": "SMOKE",
"notes": [
"SMC PTC/Pin/Break NA",
"Tank-Hunter Heroes &amp; ATMM",
"Banzai Charge (always Lax)",
"ATR/MMG/HMG Breakdown penalty",
"LLMC &rarr; LLTC if unbroken",
"Massacre OK",
"-1 Interrogation DRM",
"-2 Concealment drm",
"Enemy +2 search drm",
"Hand-to-Hand CC &amp; Hara-Kiri",
{ "caption": "Leaders", "notes": [
"Replacement NA",
"Casualty MC &rarr; elimination",
"Morale/Rally/Berserk as Commissar"
] },
{ "caption": "Elite &amp; 1st Line", "notes": [
"Always Stealthy"
] },
{ "caption": "Conscript", "notes": [
"Always Lax"
] },
{ "caption": "Always NA", "notes": [
"PAATC",
"Escape",
"RtPh Surrender",
"Disruption",
"Encircled lower Morale",
"Leader Creation"
] }
]
},
"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": "&le; 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 &amp; 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": "&le; 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 &amp; Conscript MMC: <ul> <li> Always Lax <li> Deploy NA",
"{? -10/1950 | Human Bullets | | Human Bullets (pre-11/50) ?}"
] },
{ "caption": "Korean Marine Corps (KMC)", "notes": [
"{! 04/1949-07/1950 = Japanese-Armed KMC | 08/1950- = U.S.-Armed KMC !}",
"{? -01/1951 | SW B#/X#/ROF penalty | | SW B#/X#/ROF penalty (pre-2/51) ?}"
] }
]
},
"kfw-bcfk": {
"th_color": "Black",
"oba": [ "8B", "2R" ], "oba_access": "&le; 2",
"hob_drm": "-1 DRM",
"grenades": "SMOKE",
"notes": [
{ "caption": "2nd Line MMC", "notes": [
"ELR Replacement &rarr; Disrupt"
] },
{ "caption": "Canadian", "notes": [
"{? 01/1952- | Squads have Assault Fire | | Squads have Assault Fire<sup>1/52+</sup> ?}"
] },
{ "caption": "Royal Marines", "notes": [
"Commandos",
"No Non-Qualified Use penalty for RCL",
"No Captured Use penalty for Communist SW",
"Self-Deploy (1TC) &amp; Self-Recombine OK"
] }
]
},
"kfw-ounc": {
"th_color": "Black",
"oba": [ "9B", "3R" ], "oba_access": "&le; 1",
"hob_drm": [ "0 DRM", "+3 for Turkish" ],
"grenades": "SMOKE",
"notes": [
{ "caption": "2nd Line MMC", "notes": [
"ELR Replacement &rarr; Disrupt [EXC: Turkish]"
] },
{ "caption": "Ethiopian, French, Turkish", "notes": [
"Bayonet Charge NTC NA for leaders"
] }
]
},
"kfw-kpa": {
"th_color": "Red",
"oba": [ "5B", "2R" ], "oba_access": "&le; 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 &amp; 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": "&le; 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 &amp; 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 &amp; 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 &ge; 12 &rarr; Berserk",
"Deploy NA [EXC: A20.5 &amp; A21.22]; Recombine OK"
]
},
"thai": {
"th_color": "Black",
"oba": [ "7B", "3R" ],
"hob_drm": "0 DRM",
"grenades": "Smoke"
}
}

@ -2,42 +2,171 @@
"german": {
"display_name": "German",
"ob_colors": [ "#d3edfc", "#91cdf5" ]
"ob_colors": [ "#d3edfc","#91cdf5", "#91cdf5" ]
},
"russian": {
"display_name": "Russian",
"ob_colors": [ "#eabe51", "#d68d1a" ]
"ob_colors": [ "#eabe51","#d68d1a", "#d68d1a" ]
},
"american": {
"display_name": "American",
"ob_colors": [ "#e5f700", "#cdf000" ]
"ob_colors": [ "#e5f700","#cdf000", "#cdf000" ]
},
"british": {
"display_name": "British",
"ob_colors": [ "#f6edda", "#e5cea0" ]
"ob_colors": [ "#f6edda","#e5cea0", "#e5cea0" ]
},
"british~canadian": {
"display_name": "Canadian",
"ob_colors": [ "#f6edda","#e5cea0", "#e5cea0" ]
},
"british~newzealand": {
"display_name": "New Zealand",
"ob_colors": [ "#f6edda","#e5cea0", "#e5cea0" ]
},
"british~australian": {
"display_name": "Australian",
"ob_colors": [ "#f6edda","#e5cea0", "#e5cea0" ]
},
"british~anzac": {
"display_name": "ANZAC",
"ob_colors": [ "#f6edda","#e5cea0", "#e5cea0" ]
},
"french": {
"display_name": "French",
"ob_colors": [ "#a2ddff", "#41a5ff" ]
"ob_colors": [ "#a2ddff","#41a5ff", "#41a5ff" ]
},
"free-french": {
"display_name": "Free French",
"ob_colors": [ "#a2ddff","#41a5ff", "#41a5ff" ]
},
"italian": {
"display_name": "Italian",
"ob_colors": [ "#dde0e2", "#a6adb2" ]
"ob_colors": [ "#dde0e2","#a6adb2", "#a6adb2" ]
},
"finnish": {
"display_name": "Finnish",
"ob_colors": [ "#edefef", "#ced3d3" ]
"ob_colors": [ "#edefef","#ced3d3", "#ced3d3" ]
},
"swedish": {
"display_name": "Swedish",
"ob_colors": [ "#89bfe9","#699fc9", "#699fc9" ]
},
"japanese": {
"display_name": "Japanese",
"ob_colors": [ "#fff200", "#ffdb00" ]
"ob_colors": [ "#fff200","#ffdb00", "#ffdb00" ]
},
"chinese": {
"display_name": "Chinese",
"ob_colors": [ "#d3edfc","#91cdf5", "#e0a22b" ]
},
"chinese~gmd": {
"display_name": "Chinese GMD",
"ob_colors": [ "#d3edfc","#91cdf5", "#e0a22b" ]
},
"partisan": {
"display_name": "Partisan",
"ob_colors": [ "#eabe51","#d68d1a", "#d68d1a" ]
},
"polish": {
"display_name": "Polish",
"ob_colors": [ "#ecd8b0","#e8cfa4", "#84e8c2" ],
"type": "allied-minor"
},
"belgian": {
"display_name": "Belgian",
"ob_colors": [ "#a3ecd1","#82e3bd", "#61d8a6" ],
"type": "allied-minor"
},
"yugoslavian": {
"display_name": "Yugoslavian",
"ob_colors": [ "#a3ecd1","#82e3bd", "#61d8a6" ],
"type": "allied-minor"
},
"danish": {
"display_name": "Danish",
"ob_colors": [ "#a3ecd1","#82e3bd", "#61d8a6" ],
"type": "allied-minor"
},
"dutch": {
"display_name": "Dutch",
"ob_colors": [ "#a3ecd1","#82e3bd", "#61d8a6" ],
"type": "allied-minor"
},
"greek": {
"display_name": "Greek",
"ob_colors": [ "#a3ecd1","#82e3bd", "#61d8a6" ],
"type": "allied-minor"
},
"romanian": {
"display_name": "Romanian",
"ob_colors": [ "#3ceb7c","#1de256", "#0ed93c" ],
"type": "axis-minor"
},
"hungarian": {
"display_name": "Hungarian",
"ob_colors": [ "#3ceb7c","#1de256", "#0ed93c" ],
"type": "axis-minor"
},
"slovakian": {
"display_name": "Slovakian",
"ob_colors": [ "#3ceb7c","#1de256", "#0ed93c" ],
"type": "axis-minor"
},
"croatian": {
"display_name": "Croatian",
"ob_colors": [ "#3ceb7c","#1de256", "#0ed93c" ],
"type": "axis-minor"
},
"bulgarian": {
"display_name": "Bulgarian",
"ob_colors": [ "#3ceb7c","#1de256", "#0ed93c" ],
"type": "axis-minor"
},
"thai": {
"display_name": "Thai",
"ob_colors": [ "#3ceb7c","#1de256", "#0ed93c" ]
},
"indonesian": {
"display_name": "Indonesian",
"ob_colors": [ "#3ceb7c","#1de256", "#0ed93c" ]
},
"burmese": {
"display_name": "Burmese",
"ob_colors": [ "#3ceb7c","#1de256", "#0ed93c" ]
},
"filipino": {
"display_name": "Filipino",
"ob_colors": [ "#3ceb7c","#1de256", "#0ed93c" ]
},
"kfw-rok": {
"display_name": "South Korean",
"ob_colors": [ "#e5cea0","#d2ac5b", "#cdf000" ]
},
"kfw-ounc": {
"display_name": "OUNC",
"ob_colors": [ "#55aeff","#118eff", "#b8e527" ]
},
"kfw-kpa": {
"display_name": "North Korean",
"ob_colors": [ "#eabe51","#d68d1a", "#d68d1a" ]
},
"kfw-cpva": {
"display_name": "Communist Chinese",
"ob_colors": [ "#e5cea0","#d2ac5b", "#d3870e" ]
}
}

@ -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,4 +1,11 @@
<html>
<html> <!-- vasl-templates:id {{SNIPPET_ID}} -->
<head>
<meta charset="utf-8">
<style>
{{CSS:common}}
</style>
</head>
<table>

@ -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,4 +1,11 @@
<html>
<html> <!-- vasl-templates:id {{SNIPPET_ID}} -->
<head>
<meta charset="utf-8">
<style>
{{CSS:common}}
</style>
</head>
<table>
@ -10,7 +17,7 @@
font-weight: bold ;
{%if OB_SETUP_WIDTH%} width: {{OB_SETUP_WIDTH}} ; {%endif%}
">
{{OB_SETUP}}
{{INCLUDE:player_flag}}{{OB_SETUP}}
</table>

@ -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 &nbsp; here :-/ #}
<nobr>{{INCLUDE:player_flag_large}}{{PLAYER_NAME|nbsp}}&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%}&#x24ba;{%endif%}</nobr>

@ -0,0 +1,6 @@
{%if vo.extn_id%} &#x2756; {%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>

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

Loading…
Cancel
Save