Allow vehicles/ordnance to be imported from a .vsav file.

master
Pacman Ghost 5 years ago
parent cfc7ab6079
commit 76b7d9836a
  1. 2
      vasl_templates/webapp/config/logging.yaml.example
  2. 6
      vasl_templates/webapp/static/css/vassal-shim.css
  3. 15
      vasl_templates/webapp/static/help/index.html
  4. 2
      vasl_templates/webapp/static/help/main.css
  5. 5
      vasl_templates/webapp/static/main.js
  6. 300
      vasl_templates/webapp/static/vassal.js
  7. 22
      vasl_templates/webapp/static/vo.js
  8. 3
      vasl_templates/webapp/templates/index.html
  9. 4
      vasl_templates/webapp/templates/vassal.html
  10. BIN
      vasl_templates/webapp/tests/fixtures/analyze-vsav/basic.vsav
  11. BIN
      vasl_templates/webapp/tests/fixtures/analyze-vsav/common-vo.vsav
  12. BIN
      vasl_templates/webapp/tests/fixtures/analyze-vsav/extensions-bfp.vsav
  13. BIN
      vasl_templates/webapp/tests/fixtures/analyze-vsav/landing-craft.vsav
  14. 133
      vasl_templates/webapp/tests/test_vassal.py
  15. 170
      vasl_templates/webapp/vassal.py
  16. BIN
      vassal-shim/release/vassal-shim.jar
  17. 20
      vassal-shim/src/vassal_shim/AnalyzeNode.java
  18. 6
      vassal-shim/src/vassal_shim/Main.java
  19. 25
      vassal-shim/src/vassal_shim/Utils.java
  20. 85
      vassal-shim/src/vassal_shim/VassalShim.java

@ -27,6 +27,6 @@ loggers:
vasl_mod:
level: "WARNING"
handlers: [ "file" ]
update_vsav:
vassal_shim:
level: "WARNING"
handlers: [ "file" ]

@ -1,6 +1,6 @@
.ui-dialog.update-vsav .ui-dialog-titlebar { display: none ; }
#update-vsav { display: flex ; align-items: center ; }
#update-vsav img { margin-right: 1em ; }
.ui-dialog.vassal-shim-progress .ui-dialog-titlebar { display: none ; }
#vassal-shim-progress { display: flex ; align-items: center ; }
#vassal-shim-progress img { margin-right: 1em ; }
#vassal-shim-error textarea { width: 100% ; height: 15em ; min-height: 5em ; resize: none ; padding: 2px ; font-family: monospace ; font-size: 80% ; }
.ui-dialog.vassal-shim-error .ui-dialog-titlebar { background: #f5af41 ; }

@ -175,6 +175,11 @@ Adding each vehicle and ordnance for each player is just a matter of selecting t
<p> Double-click on an entry to make changes to it e.g. because an SSR changes its capabilities, or you'd like to add a note.
<div class="hint"> It's also possible to include Chapter H notes in your scenarios, although you will need to <a href="#" onclick="select_tab('chapterh');">set some things up</a> first. </div>
<h4 style="clear:none;"> Analyzing a VASL scenario </h4>
<p> If you have already set up the VASL scenario, you can also choose <em>"Analyze VASL scenario"</em> from the menu, and the program will identify vehicles and ordnance, and automatically create entries for each one. Only counters from the two configured nationalities will be imported, so make sure you set these first.
<br clear="all">
<p> <img src="images/ob_setup.png" class="preview" style="width:15em;float:left;">
@ -211,6 +216,16 @@ or a <tt>width</tt> and/or <tt>height</tt> parameter to explicitly set the image
<br clear="all">
<h2> Suggested workflow </h2>
<p>
<ul>
<li> Set up the VASL scenario, with the boards and counters.
<li> In <em>VASL Templates</em>, set the two player nationalities, then analyze the VASL scenario to automatically create entries for vehicles and ordnance.
<li> Enter the other scenario details e.g. the scenario name and date, Victory Conditions and SSR's, setup instructions.
<li> Update the VASL scenario file, to automatically create labels for all the scenario details and vehicles/ordnance.
</ul>
<h2> Configuring the program </h2>
<h4> Showing VASL counter images in the UI </h4>

@ -6,7 +6,7 @@ h2 { margin-top: 1em ; clear: both ; color: #666 ; }
h4 { margin-top: 0.5em ; clear: both ; color: #666 ; }
p { margin-top: 0.5em ; }
li { margin-left: 1em ; }
li { margin: 0.2em 0 0 1em ; }
.code { white-space: pre ; font-family: monospace ; margin: 0 1em 1em 2em ; }
/* -------------------------------------------------------------------- */

@ -45,10 +45,11 @@ $(document).ready( function () {
new_scenario: { label: "New scenario", action: function() { on_new_scenario() ; } },
load_scenario: { label: "Load scenario", action: on_load_scenario },
save_scenario: { label: "Save scenario", action: on_save_scenario },
update_vsav: { label: "Update VASL scenario", action: on_update_vsav },
separator: { type: "separator" },
template_pack: { label: "Load template pack", action: on_template_pack },
analyze_vsav: { label: "Analyze VASL scenario", action: on_analyze_vsav },
update_vsav: { label: "Update VASL scenario", action: on_update_vsav },
separator2: { type: "separator" },
template_pack: { label: "Load template pack", action: on_template_pack },
user_settings: { label: "Settings", action: user_settings },
show_help: { label: "Help", action: show_help },
} ) ;

@ -1,63 +1,17 @@
gLoadVsavHandler = null ;
// --------------------------------------------------------------------
function on_update_vsav()
{
// FOR TESTING PORPOISES! We can't control a file upload from Selenium (since
// the browser will use native controls), so we get the data from a <textarea>).
if ( getUrlParam( "vsav_persistence" ) ) {
var $elem = $( "#_vsav-persistence_" ) ;
var vsav_data = $elem.val() ;
$elem.val( "" ) ; // nb: let the test suite know we've received the data
do_update_vsav( vsav_data, "test.vsav" ) ;
return ;
}
// if we are running inside the PyQt wrapper, let it handle everything
if ( gWebChannelHandler ) {
gWebChannelHandler.load_vsav( function( data ) {
if ( ! data )
return ;
do_update_vsav( data.data, data.filename ) ;
} ) ;
return ;
}
function on_update_vsav() { _load_and_process_vsav( _do_update_vsav ) ; }
// ask the user to upload the VSAV file
$("#load-vsav").trigger( "click" ) ; // nb: this will call on_load_vsav_file_selected() when a file has been selected
}
function on_load_vsav_file_selected()
{
// read the selected file
var fileReader = new FileReader() ;
var file = $("#load-vsav").prop( "files" )[0] ;
fileReader.onload = function() {
vsav_data = fileReader.result ;
if ( vsav_data.substring(0,5) === "data:" )
vsav_data = vsav_data.split( "," )[1] ;
do_update_vsav( vsav_data, file.name ) ;
} ;
fileReader.readAsDataURL( file ) ;
}
function do_update_vsav( vsav_data, fname )
function _do_update_vsav( vsav_data, fname )
{
// show the progress dialog
var $dlg = $( "#update-vsav" ).dialog( {
dialogClass: "update-vsav",
modal: true,
width: 300,
height: 60,
resizable: false,
closeOnEscape: false,
} ) ;
// generate all the snippets
var $dlg = _show_vassal_shim_progress_dlg( "Updating your VASL scenario..." ) ;
var snippets = _generate_snippets() ;
// send a request to update the VSAV
var data = { "filename": fname, vsav_data: vsav_data, snippets: snippets } ;
var data = { filename: fname, vsav_data: vsav_data, snippets: snippets } ;
$.ajax( {
url: gUpdateVsavUrl,
type: "POST",
@ -65,40 +19,9 @@ function do_update_vsav( vsav_data, fname )
contentType: "application/json",
} ).done( function( data ) {
$dlg.dialog( "close" ) ;
data = JSON.parse( data ) ;
// check if there was an error
if ( data.error ) {
if ( getUrlParam( "vsav_persistence" ) ) {
$("#_vsav-persistence_").val(
"ERROR: " + data.error + "\n\n=== STDOUT ===\n" + data.stdout + "\n=== STDERR ===\n" + data.stderr
) ;
return ;
}
$("#vassal-shim-error").dialog( {
dialogClass: "vassal-shim-error",
title: "Can't update the scenario",
modal: true,
width: 600, height: "auto",
open: function() {
$( "#vassal-shim-error .message" ).html( data.error ) ;
var log = "" ;
if ( data.stdout && data.stderr )
log = "=== STDOUT ===\n" + data.stdout + "\n=== STDERR ===\n" + data.stderr ;
else if ( data.stdout )
log = data.stdout ;
else if ( data.stderr )
log = data.stderr ;
if ( log )
$( "#vassal-shim-error .log" ).val( log ).show() ;
else
$( "#vassal-shim-error .log" ).hide() ;
},
buttons: {
Close: function() { $(this).dialog( "close" ) ; },
},
} ) ;
data = _check_vassal_shim_response( data, "Can't update the VASL scenario." ) ;
if ( ! data )
return ;
}
// check if anything was changed
if ( ! data.report.was_modified ) {
showInfoMsg( "No changes were made to the VASL scenario." ) ;
@ -348,3 +271,212 @@ function _get_raw_content( snippet_id, $btn, params )
return null ;
}
// --------------------------------------------------------------------
function on_analyze_vsav()
{
// check if there are any vehicles/ordnance already defined
var voDefined1 = $( "#ob_vehicles-sortable_1 .vo-entry" ).length > 0 || $( "#ob_ordnance-sortable_1 .vo-entry" ).length > 0 ;
var voDefined2 = $( "#ob_vehicles-sortable_2 .vo-entry" ).length > 0 || $( "#ob_ordnance-sortable_2 .vo-entry" ).length > 0 ;
if ( voDefined1 || voDefined2 ) {
// yup - confirm the operation
ask( "Analyze VASL scenario",
"<p>There are some vehicles/ordnance already configured. <p>They will be <i>replaced</i> with those found in the analyzed VASL scenario.", {
ok: function() { _load_and_process_vsav( _do_analyze_vsav ) ; },
} ) ;
return ;
}
// ask the user to select a VASL scenario, then analyze it
_load_and_process_vsav( _do_analyze_vsav ) ;
}
function _do_analyze_vsav( vsav_data, fname )
{
// show the progress dialog
var $dlg = _show_vassal_shim_progress_dlg( "Analyzing the VASL scenario..." ) ;
// send a request to analyze the VSAV
var data = { filename: fname, vsav_data: vsav_data } ;
$.ajax( {
url: gAnalyzeVsavUrl,
type: "POST",
data: JSON.stringify( data ),
contentType: "application/json",
} ).done( function( data ) {
$dlg.dialog( "close" ) ;
data = _check_vassal_shim_response( data, "Can't analyze the VASL scenario." ) ;
if ( ! data )
return ;
_create_vo_entries_from_analysis( data ) ;
} ).fail( function( xhr, status, errorMsg ) {
$dlg.dialog( "close" ) ;
showErrorMsg( "Can't analyze the VASL scenario:<div class='pre'>" + escapeHTML(errorMsg) + "</div>" ) ;
} ) ;
}
function _create_vo_entries_from_analysis( report )
{
function create_vo_entries( player_no, vo_type ) {
// clear the existing vehicles/ordnance
$( "#ob_" + vo_type + "-sortable_" + player_no ).sortable2( "delete-all" ) ;
// build an index of GPID's that belong to the specified player and V/O type
var entries_index = {} ;
var entries = gVehicleOrdnanceListings[ vo_type ][ get_player_nat(player_no) ] ;
var gpids, i ;
for ( i=0 ; i < entries.length ; ++i ) {
gpids = $.isArray( entries[i].gpid ) ? entries[i].gpid : [entries[i].gpid] ;
for ( var j=0 ; j < gpids.length ; ++j )
entries_index[ gpids[j] ] = entries[i] ;
}
// add a vehicle/ordnance for each relevant GPID
var nCreated = 0 ;
gpids = Object.keys( report ) ;
for ( i=0 ; i < gpids.length ; ++i ) {
var gpid = gpids[ i ] ;
var entry = entries_index[ gpid ] ;
if ( ! entry )
continue ;
var image_id = $.isArray( entry.gpid ) ? [gpid,0] : null ;
do_add_vo( vo_type, player_no, entry, image_id, false, null, null, null ) ;
++ nCreated ;
}
return nCreated ;
}
// import any vehicles/ordnance found
var imported = [
[ create_vo_entries( 1, "vehicles" ), create_vo_entries( 1, "ordnance" ) ],
[ create_vo_entries( 2, "vehicles" ), create_vo_entries( 2, "ordnance" ) ]
] ;
// report what happened
var report_strings = [] ;
function make_report_string( nat, nVehicles, nOrdnance ) {
var buf = [] ;
if ( nVehicles > 0 )
buf.push( nVehicles + "{{NAT}} " + pluralString(nVehicles,"vehicle","vehicles") ) ;
if ( nOrdnance > 0 )
buf.push( nOrdnance + "{{NAT}} ordnance" ) ;
if ( buf.length == 1 ) {
report_strings.push(
"Imported " + buf[0].replace("{{NAT}}"," "+nat) + "."
) ;
} else if ( buf.length == 2 ) {
report_strings.push(
"Imported " + buf[0].replace( "{{NAT}}", " "+nat ) + " and " + buf[1].replace( "{{NAT}}", "" ) + "."
) ;
}
}
make_report_string( get_nationality_display_name(get_player_nat(1)), imported[0][0], imported[0][1] ) ;
make_report_string( get_nationality_display_name(get_player_nat(2)), imported[1][0], imported[1][1] ) ;
if ( report_strings.length === 0 )
showWarningMsg( "<p>No vehicles/ordnance were imported. <p style='margin-top:0.25em;'>Have you set the player nationalities?" ) ;
else
showInfoMsg( report_strings.join( "<p style='margin-top:0.25em;'>" ) ) ;
}
// --------------------------------------------------------------------
function _load_and_process_vsav( handler )
{
// FOR TESTING PORPOISES! We can't control a file upload from Selenium (since
// the browser will use native controls), so we get the data from a <textarea>).
if ( getUrlParam( "vsav_persistence" ) ) {
var $elem = $( "#_vsav-persistence_" ) ;
var vsav_data = $elem.val() ;
$elem.val( "" ) ; // nb: let the test suite know we've received the data
handler( vsav_data, "test.vsav" ) ;
return ;
}
// if we are running inside the PyQt wrapper, let it handle everything
if ( gWebChannelHandler ) {
gWebChannelHandler.load_vsav( function( data ) {
if ( ! data )
return ;
handler( data.data, data.filename ) ;
} ) ;
return ;
}
// ask the user to upload the VSAV file
gLoadVsavHandler = handler ;
$("#load-vsav").trigger( "click" ) ; // nb: this will call on_load_vsav_file_selected() when a file has been selected
}
function on_load_vsav_file_selected()
{
// read the selected file
var fileReader = new FileReader() ;
var file = $("#load-vsav").prop( "files" )[0] ;
fileReader.onload = function() {
vsav_data = fileReader.result ;
if ( vsav_data.substring(0,5) === "data:" )
vsav_data = vsav_data.split( "," )[1] ;
gLoadVsavHandler( vsav_data, file.name ) ;
gLoadVsavHandler = null ;
} ;
fileReader.readAsDataURL( file ) ;
}
// --------------------------------------------------------------------
function _check_vassal_shim_response( data, caption )
{
// check if there was an error
if ( ! data.error )
return data ;
// yup - report the error
if ( getUrlParam( "vsav_persistence" ) ) {
$( "#_vsav-persistence_" ).val(
"ERROR: " + data.error + "\n\n=== STDOUT ===\n" + data.stdout + "\n=== STDERR ===\n" + data.stderr
) ;
return null ;
}
$( "#vassal-shim-error" ).dialog( {
dialogClass: "vassal-shim-error",
title: caption,
modal: true,
width: 600, height: "auto",
open: function() {
$( "#vassal-shim-error .message" ).html( data.error ) ;
var log = "" ;
if ( data.stdout && data.stderr )
log = "=== STDOUT ===\n" + data.stdout + "\n=== STDERR ===\n" + data.stderr ;
else if ( data.stdout )
log = data.stdout ;
else if ( data.stderr )
log = data.stderr ;
if ( log )
$( "#vassal-shim-error .log" ).val( log ).show() ;
else
$( "#vassal-shim-error .log" ).hide() ;
},
buttons: {
Close: function() { $(this).dialog( "close" ) ; },
},
} ) ;
return null ;
}
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
function _show_vassal_shim_progress_dlg( caption )
{
// show the progress dialog
return $( "#vassal-shim-progress" ).dialog( {
dialogClass: "vassal-shim-progress",
modal: true,
width: 300,
height: 60,
resizable: false,
closeOnEscape: false,
open: function() {
$(this).find( ".message" ).text( caption ) ;
},
} ) ;
}

@ -5,7 +5,7 @@ function add_vo( vo_type, player_no )
{
// load the available vehicles/ordnance
var nat = $( "select[name='PLAYER_" + player_no + "']" ).val() ;
var entries = gVehicleOrdnanceListings[vo_type][nat] ;
var entries = gVehicleOrdnanceListings[ vo_type ][ nat ] ;
if ( entries === undefined ) {
showWarningMsg( "There are no " + get_nationality_display_name(nat) + " " + vo_type + " listings." ) ;
return ;
@ -111,17 +111,15 @@ function add_vo( vo_type, player_no )
return ;
var sel_index = $elem.children( ".vo-entry" ).data( "index" ) ;
var sel_entry = entries[ sel_index ] ;
var usedVoIds=[], usedSeqIds={} ;
var usedVoIds = [] ;
$sortable2.find( "li" ).each( function() {
usedVoIds.push( $(this).data( "sortable2-data" ).vo_entry.id ) ;
usedSeqIds[ $(this).data( "sortable2-data" ).id ] = true ;
} ) ;
// check for duplicates
function add_sel_entry() {
var $img = $elem.find( "img[class='vasl-image']" ) ;
var vo_image_id = $img.data( "vo-image-id" ) ;
var seq_id = auto_assign_id( usedSeqIds, "seq_id" ) ;
do_add_vo( vo_type, player_no, sel_entry, vo_image_id, false, null, null, seq_id ) ;
do_add_vo( vo_type, player_no, sel_entry, vo_image_id, false, null, null, null ) ;
$dlg.dialog( "close" ) ;
}
if ( usedVoIds.indexOf( sel_entry.id ) !== -1 ) {
@ -143,11 +141,21 @@ function add_vo( vo_type, player_no )
function do_add_vo( vo_type, player_no, vo_entry, vo_image_id, elite, custom_capabilities, custom_comments, seq_id )
{
// initialize
var nat = get_player_nat( player_no ) ;
var $sortable2 = $( "#ob_" + vo_type + "-sortable_" + player_no ) ;
if ( seq_id === null ) {
// auto-assign a sequence ID
var usedSeqIds = {} ;
$sortable2.find( "li" ).each( function() {
usedSeqIds[ $(this).data( "sortable2-data" ).id ] = true ;
} ) ;
seq_id = auto_assign_id( usedSeqIds, "seq_id" ) ;
}
// add the specified vehicle/ordnance
// NOTE: We set a fixed height for the sortable2 entries (based on the CSS settings in tabs-ob.css),
// so that the vehicle/ordnance images won't get truncated if there are a lot of them.
var nat = get_player_nat( player_no ) ;
var $sortable2 = $( "#ob_" + vo_type + "-sortable_" + player_no ) ;
var div_tag = "<div class='vo-entry" ;
var fixed_height = "3.25em" ;
if ( is_small_vasl_piece( vo_entry ) ) {

@ -23,7 +23,7 @@
<link rel="stylesheet" type="text/css" href="{{url_for('static',filename='css/select-vo-dialog.css')}}" />
<link rel="stylesheet" type="text/css" href="{{url_for('static',filename='css/edit-vo-dialog.css')}}" />
<link rel="stylesheet" type="text/css" href="{{url_for('static',filename='css/select-roar-scenario-dialog.css')}}" />
<link rel="stylesheet" type="text/css" href="{{url_for('static',filename='css/vassal.css')}}" />
<link rel="stylesheet" type="text/css" href="{{url_for('static',filename='css/vassal-shim.css')}}" />
<link rel="stylesheet" type="text/css" href="{{url_for('static',filename='css/snippets.css')}}" />
<link rel="stylesheet" type="text/css" href="{{url_for('static',filename='css/user-settings-dialog.css')}}" />
@ -102,6 +102,7 @@ gVehicleNotesUrl = "{{url_for('get_vehicle_notes')}}" ;
gOrdnanceNotesUrl = "{{url_for('get_ordnance_notes')}}" ;
gGetVaslPieceInfoUrl = "{{url_for('get_vasl_piece_info')}}" ;
gGetRoarScenarioIndexUrl = "{{url_for('get_roar_scenario_index')}}" ;
gAnalyzeVsavUrl = "{{url_for('analyze_vsav')}}" ;
gUpdateVsavUrl = "{{url_for('update_vsav')}}" ;
gMakeSnippetImageUrl = "{{url_for('make_snippet_image')}}" ;
gHelpUrl = "{{url_for('show_help')}}" ;

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

@ -13,8 +13,9 @@ from vasl_templates.webapp.vassal import VassalShim
from vasl_templates.webapp.utils import TempFile, change_extn
from vasl_templates.webapp import globvars
from vasl_templates.webapp.tests.utils import \
init_webapp, refresh_webapp, select_menu_option, get_stored_msg, set_stored_msg, set_stored_msg_marker, wait_for
from vasl_templates.webapp.tests.test_scenario_persistence import load_scenario, load_scenario_params, \
init_webapp, refresh_webapp, select_menu_option, get_stored_msg, set_stored_msg, set_stored_msg_marker, wait_for, \
new_scenario, set_player
from vasl_templates.webapp.tests.test_scenario_persistence import load_scenario, load_scenario_params, save_scenario, \
assert_scenario_params_complete
# ---------------------------------------------------------------------
@ -335,7 +336,7 @@ def test_dump_vsav( webapp, webdriver ):
@pytest.mark.skipif( not pytest.config.option.vasl_mods, reason="--vasl-mods not specified" ) #pylint: disable=no-member
@pytest.mark.skipif( not pytest.config.option.vassal, reason="--vassal not specified" ) #pylint: disable=no-member
@pytest.mark.skipif( pytest.config.option.short_tests, reason="--short-tests specified" ) #pylint: disable=no-member
def test_legacy_labels( webapp, webdriver ):
def test_update_legacy_labels( webapp, webdriver ):
"""Test detection and updating of legacy labels."""
# initialize
@ -420,7 +421,7 @@ def test_legacy_labels( webapp, webdriver ):
@pytest.mark.skipif( not pytest.config.option.vasl_mods, reason="--vasl-mods not specified" ) #pylint: disable=no-member
@pytest.mark.skipif( not pytest.config.option.vassal, reason="--vassal not specified" ) #pylint: disable=no-member
@pytest.mark.skipif( pytest.config.option.short_tests, reason="--short-tests specified" ) #pylint: disable=no-member
def test_legacy_latw_labels( webapp, webdriver ):
def test_update_legacy_latw_labels( webapp, webdriver ):
"""Test detection and updating of legacy LATW labels."""
# initialize
@ -494,6 +495,89 @@ def test_legacy_latw_labels( webapp, webdriver ):
# ---------------------------------------------------------------------
@pytest.mark.skipif( not pytest.config.option.vasl_mods, reason="--vasl-mods not specified" ) #pylint: disable=no-member
@pytest.mark.skipif( not pytest.config.option.vassal, reason="--vassal not specified" ) #pylint: disable=no-member
def test_analyze_vsav( webapp, webdriver ):
"""Test analyzing a scenario."""
# initialize
control_tests = init_webapp( webapp, webdriver, vsav_persistence=1, scenario_persistence=1,
reset = lambda ct:
ct.set_data_dir( dtype="real" ) \
.set_vasl_mod( vmod="random", extns_dtype="real" )
)
def do_test(): #pylint: disable=missing-docstring
# analyze a basic scenario
new_scenario()
_analyze_vsav( "basic.vsav",
[ [ "ge/v:033", "ge/v:066" ], [ "ge/o:029" ] ],
[ [ "ru/v:064" ], [ "ru/o:002", "ru/o:006" ] ],
[ "Imported 2 German vehicles and 1 ordnance.", "Imported 1 Russian vehicle and 2 ordnance." ]
)
# try again with different nationalities
new_scenario()
set_player( 1, "french" )
set_player( 2, "british" )
_analyze_vsav( "basic.vsav",
[ [], [] ],
[ [], [] ],
[ "No vehicles/ordnance were imported." ]
)
# analyze a scenario with landing craft
new_scenario()
set_player( 1, "american" )
set_player( 2, "japanese" )
_analyze_vsav( "landing-craft.vsav",
[ [ ("sh/v:000","397/0"), ("sh/v:000","399/0"), ("sh/v:006","413/0"), ("sh/v:006","415/0") ], [] ],
[ [ "sh/v:007", "sh/v:008" ], [] ],
[ "Imported 4 American vehicles.", "Imported 2 Japanese vehicles." ]
)
# analyze a scenario with common vehicles/ordnance
new_scenario()
set_player( 1, "belgian" )
set_player( 2, "romanian" )
_analyze_vsav( "common-vo.vsav",
[ [ "be/v:000", "alc/v:011" ], [ "be/o:001", "alc/o:012" ] ],
[ [ "ro/v:000", "axc/v:027" ], [ "ro/o:003", "axc/o:002" ] ],
[ "Imported 2 Belgian vehicles and 2 ordnance.", "Imported 2 Romanian vehicles and 2 ordnance." ]
)
# try again with the Yugoslavians/Croatians
new_scenario()
set_player( 1, "yugoslavian" )
set_player( 2, "croatian" )
_analyze_vsav( "common-vo.vsav",
[ [ "alc/v:011" ], [ "alc/o:012" ] ],
[ [ "axc/v:027" ], [ "axc/o:002" ] ],
[ "Imported 1 Yugoslavian vehicle and 1 ordnance.", "Imported 1 Croatian vehicle and 1 ordnance." ]
)
# try again with the Germans/Russians
new_scenario()
_analyze_vsav( "common-vo.vsav",
[ [], [] ],
[ [], [] ],
[ "No vehicles/ordnance were imported." ]
)
# analyze a scenario using counters from an extension
new_scenario()
set_player( 1, "american" )
set_player( 2, "japanese" )
_analyze_vsav( "extensions-bfp.vsav",
[ [ "am/v:906" ], [ "am/o:900" ] ],
[ [ "ja/v:902" ], [ "ja/o:902" ] ],
[ "Imported 1 American vehicle and 1 ordnance.", "Imported 1 Japanese vehicle and 1 ordnance." ]
)
# run the test against all versions of VASSAL+VASL
_run_tests( control_tests, do_test, not pytest.config.option.short_tests ) #pylint: disable=no-member
# ---------------------------------------------------------------------
def _run_tests( control_tests, func, test_all ):
"""Run the test function for each combination of VASSAL + VASL.
@ -623,3 +707,44 @@ def _get_vsav_labels( vsav_dump ):
regex = re.compile( r"<html>.*?</html>" )
matches = [ regex.search(label) for label in labels ]
return [ mo.group() if mo else "<???>" for mo in matches ]
# ---------------------------------------------------------------------
def _analyze_vsav( fname, expected_ob1, expected_ob2, expected_report ):
"""Analyze a VASL scenario."""
# read the VSAV data
fname = os.path.join( os.path.split(__file__)[0], "fixtures/analyze-vsav/"+fname )
vsav_data = open( fname, "rb" ).read()
# send the VSAV data to the front-end to be analyzed
set_stored_msg( "_vsav-persistence_", base64.b64encode( vsav_data ).decode( "utf-8" ) )
prev_info_msg = set_stored_msg_marker( "_last-info_" )
prev_warning_msg = set_stored_msg_marker( "_last-warning_" )
select_menu_option( "analyze_vsav" )
wait_for( 60,
lambda: get_stored_msg("_last-info_") != prev_info_msg or get_stored_msg("_last-warning_") != prev_warning_msg
) # nb: wait for the analysis to finish
# check the results
saved_scenario = save_scenario()
def get_ids( key ): #pylint: disable=missing-docstring
return set(
( v["id"], v.get("image_id") )
for v in saved_scenario.get( key, [] )
)
def adjust_expected( vals ): #pylint: disable=missing-docstring
return set(
v if isinstance(v,tuple) else (v,None)
for v in vals
)
assert get_ids( "OB_VEHICLES_1" ) == adjust_expected( expected_ob1[0] )
assert get_ids( "OB_ORDNANCE_1" ) == adjust_expected( expected_ob1[1] )
assert get_ids( "OB_VEHICLES_2" ) == adjust_expected( expected_ob2[0] )
assert get_ids( "OB_ORDNANCE_2" ) == adjust_expected( expected_ob2[1] )
# check the report
msg = get_stored_msg( "_last-info_" )
if msg == prev_info_msg:
msg = get_stored_msg( "_last-warning_" )
assert all( e in msg for e in expected_report )

@ -5,21 +5,20 @@ import sys
import os
import subprocess
import traceback
import json
import re
import logging
import base64
import time
import xml.etree.cElementTree as ET
from flask import request
from flask import request, jsonify
from vasl_templates.webapp import app, globvars
from vasl_templates.webapp.config.constants import BASE_DIR, IS_FROZEN
from vasl_templates.webapp.utils import TempFile, SimpleError
from vasl_templates.webapp.webdriver import WebDriver
_logger = logging.getLogger( "update_vsav" )
_logger = logging.getLogger( "vassal_shim" )
SUPPORTED_VASSAL_VERSIONS = [ "3.2.15" ,"3.2.16", "3.2.17" ]
SUPPORTED_VASSAL_VERSIONS_DISPLAY = "3.2.15-.17"
@ -45,6 +44,7 @@ def update_vsav(): #pylint: disable=too-many-statements
_logger.info( "Updating VSAV (#bytes=%d): %s", len(vsav_data), vsav_filename )
with TempFile() as input_file:
# save the VSAV data in a temp file
input_file.write( vsav_data )
input_file.close()
@ -53,6 +53,7 @@ def update_vsav(): #pylint: disable=too-many-statements
_logger.debug( "Saving a copy of the VSAV data: %s", fname )
with open( fname, "wb" ) as fp:
fp.write( vsav_data )
with TempFile() as snippets_file:
# save the snippets in a temp file
xml = _save_snippets( snippets, snippets_file )
@ -62,6 +63,7 @@ def update_vsav(): #pylint: disable=too-many-statements
_logger.debug( "Saving a copy of the snippets: %s", fname )
with open( fname, "wb" ) as fp:
ET.ElementTree( xml ).write( fp )
# run the VASSAL shim to update the VSAV file
with TempFile() as output_file, TempFile() as report_file:
output_file.close()
@ -79,49 +81,26 @@ def update_vsav(): #pylint: disable=too-many-statements
with open( app.config.get("UPDATE_VSAV_RESULT"), "wb" ) as fp:
fp.write( vsav_data )
# read the report
label_report = _parse_label_report( report_file.name )
except VassalShimError as ex:
_logger.error( "VASSAL shim error: rc=%d", ex.retcode )
if ex.retcode != 0:
return json.dumps( {
"error": "Unexpected return code from the VASSAL shim: {}".format( ex.retcode ),
"stdout": ex.stdout,
"stderr": ex.stderr,
} )
return json.dumps( {
"error": "Unexpected error output from the VASSAL shim.",
"stdout": ex.stdout,
"stderr": ex.stderr,
} )
except subprocess.TimeoutExpired:
return json.dumps( {
"error": "<p>The updater took too long to run, please try again." \
"<p>If this problem persists, try configuring a longer timeout."
} )
except SimpleError as ex:
_logger.error( "VSAV update error: %s", ex )
return json.dumps( { "error": str(ex) } )
report = _parse_label_report( report_file.name )
except Exception as ex: #pylint: disable=broad-except
_logger.error( "Unexpected VSAV update error: %s", ex )
return json.dumps( {
"error": str(ex),
"stdout": traceback.format_exc(),
} )
return VassalShim.translate_vassal_shim_exception( ex )
# return the results
_logger.debug( "Updated the VSAV file OK: elapsed=%.3fs", time.time()-start_time )
# NOTE: We adjust the recommended save filename to encourage users to not overwrite the original file :-/
vsav_filename = os.path.split( vsav_filename )[1]
fname, extn = os.path.splitext( vsav_filename )
return json.dumps( {
return jsonify( {
"vsav_data": base64.b64encode(vsav_data).decode( "utf-8" ),
"filename": fname+" (updated)" + extn,
"report": {
"was_modified": label_report["was_modified"],
"labels_created": len(label_report["created"]),
"labels_updated": len(label_report["updated"]),
"labels_deleted": len(label_report["deleted"]),
"labels_unchanged": len(label_report["unchanged"]),
"was_modified": report["was_modified"],
"labels_created": len(report["created"]),
"labels_updated": len(report["updated"]),
"labels_deleted": len(report["deleted"]),
"labels_unchanged": len(report["unchanged"]),
},
} )
@ -200,14 +179,63 @@ def _parse_label_report( fname ):
# ---------------------------------------------------------------------
@app.route( "/analyze-vsav", methods=["POST"] )
def analyze_vsav():
"""Analyze a VASL scenario file."""
# parse the request
start_time = time.time()
vsav_data = request.json[ "vsav_data" ]
vsav_filename = request.json[ "filename" ]
try:
# get the VSAV data (we do this inside the try block so that the user gets shown
# a proper error dialog if there's a problem decoding the base64 data)
vsav_data = base64.b64decode( vsav_data )
_logger.info( "Analyzing VSAV (#bytes=%d): %s", len(vsav_data), vsav_filename )
with TempFile() as input_file:
# save the VSAV data in a temp file
input_file.write( vsav_data )
input_file.close()
fname = app.config.get( "ANALYZE_VSAV_INPUT" ) # nb: for diagnosing problems
if fname:
_logger.debug( "Saving a copy of the VSAV data: %s", fname )
with open( fname, "wb" ) as fp:
fp.write( vsav_data )
# run the VASSAL shim to analyze the VSAV file
with TempFile() as report_file:
report_file.close()
vassal_shim = VassalShim()
vassal_shim.analyze_scenario( input_file.name, report_file.name )
report = _parse_analyze_report( report_file.name )
except Exception as ex: #pylint: disable=broad-except
return VassalShim.translate_vassal_shim_exception( ex )
# return the results
_logger.debug( "Analyzed the VSAV file OK: elapsed=%.3fs\n%s", time.time()-start_time, report )
return jsonify( report )
def _parse_analyze_report( fname ):
"""Read the analysis report generated by the VASSAL shim."""
doc = ET.parse( fname )
report = {}
for node in doc.getroot():
report[ node.attrib["gpid"] ] = { "name": node.attrib["name"], "count": node.attrib["count"] }
return report
# ---------------------------------------------------------------------
class VassalShim:
"""Provide access to VASSAL via the Java shim."""
def __init__( self ): #pylint: disable=too-many-branches
# initialize
self.boards_dir = None
# locate the VASSAL engine
vassal_dir = app.config.get( "VASSAL_DIR" )
if not vassal_dir:
@ -245,22 +273,18 @@ class VassalShim:
"""Dump a scenario file."""
return self._run_vassal_shim( "dump", fname )
def analyze_scenario( self, vsav_fname, report_fname ):
"""Analyze a scenario file."""
return self._run_vassal_shim(
"analyze", VassalShim.get_boards_dir(), vsav_fname, report_fname
)
def update_scenario( self, vsav_fname, snippets_fname, output_fname, report_fname ):
"""Update a scenario file."""
# locate the boards
self.boards_dir = app.config.get( "BOARDS_DIR" )
if not self.boards_dir:
raise SimpleError( "The VASL boards directory has not been configured." )
if not os.path.isdir( self.boards_dir ):
raise SimpleError( "Can't find the VASL boards: {}".format( self.boards_dir ) )
# locate the VASL module
if not globvars.vasl_mod:
raise SimpleError( "The VASL module has not been configured." )
return self._run_vassal_shim(
"update", self.boards_dir, vsav_fname, snippets_fname, output_fname, report_fname
"update", VassalShim.get_boards_dir(), vsav_fname, snippets_fname, output_fname, report_fname
)
def _run_vassal_shim( self, *args ): #pylint: disable=too-many-locals
@ -282,7 +306,9 @@ class VassalShim:
java_path, "-classpath", class_path, "vassal_shim.Main",
args[0]
]
if args[0] in ("dump","update"):
if args[0] in ("dump","analyze","update"):
if not globvars.vasl_mod:
raise SimpleError( "The VASL module has not been configured." )
args2.append( globvars.vasl_mod.filename )
args2.extend( args[1:] )
@ -345,6 +371,48 @@ class VassalShim:
raise VassalShimError( proc.returncode, stdout, stderr )
return stdout
@staticmethod
def get_boards_dir():
"""Get the configured boards directory."""
boards_dir = app.config.get( "BOARDS_DIR" )
if not boards_dir:
raise SimpleError( "The VASL boards directory has not been configured." )
if not os.path.isdir( boards_dir ):
raise SimpleError( "Can't find the VASL boards: {}".format( boards_dir ) )
return boards_dir
@staticmethod
def translate_vassal_shim_exception( ex ):
"""Convert an exception thrown by the VassalShim to a JSON response to return to the caller."""
if isinstance( ex, VassalShimError ):
_logger.error( "VASSAL shim error: rc=%d", ex.retcode )
if ex.retcode != 0:
return jsonify( {
"error": "Unexpected return code from the VASSAL shim: {}".format( ex.retcode ),
"stdout": ex.stdout,
"stderr": ex.stderr,
} )
return jsonify( {
"error": "Unexpected error output from the VASSAL shim.",
"stdout": ex.stdout,
"stderr": ex.stderr,
} )
if isinstance( ex, subprocess.TimeoutExpired ):
return jsonify( {
"error": "<p>The VASSAL shim took too long to run, please try again." \
"<p>If this problem persists, try configuring a longer timeout."
} )
if isinstance( ex, SimpleError ):
_logger.error( "VSAV update error: %s", ex )
return jsonify( { "error": str(ex) } )
_logger.error( "Unexpected VSAV update error: %s", ex )
return jsonify( {
"error": str(ex),
"stdout": traceback.format_exc(),
} )
@staticmethod
def check_vassal_version( msg_store ):
"""Check the version of VASSAL."""

@ -0,0 +1,20 @@
package vassal_shim ;
// --------------------------------------------------------------------
public class AnalyzeNode
{
// POD container that holds information about a VASL piece.
String name ;
int count ;
public AnalyzeNode( String name )
{
// initialize the AnalyzeNode
this.name = name ;
this.count = 0 ;
}
public void incrementCount() { ++count ; }
}

@ -28,6 +28,12 @@ public class Main
shim.dumpScenario( args[2] ) ;
System.exit( 0 ) ;
}
else if ( cmd.equals( "analyze" ) ) {
checkArgs( args, 5, "the VASL .vmod file, boards directory, scenario file and output file" ) ;
VassalShim shim = new VassalShim( args[1], args[2] ) ;
shim.analyzeScenario( args[3], args[4] ) ;
System.exit( 0 ) ;
}
else if ( cmd.equals( "update" ) ) {
checkArgs( args, 7, "the VASL .vmod file, boards directory, scenario file, snippets file and output/report files" ) ;
VassalShim shim = new VassalShim( args[1], args[2] ) ;

@ -1,5 +1,16 @@
package vassal_shim ;
import java.io.FileOutputStream ;
import java.io.IOException ;
import javax.xml.transform.TransformerFactory ;
import javax.xml.transform.Transformer ;
import javax.xml.transform.OutputKeys ;
import javax.xml.transform.TransformerException ;
import javax.xml.transform.TransformerConfigurationException ;
import javax.xml.transform.dom.DOMSource ;
import javax.xml.transform.stream.StreamResult ;
import org.w3c.dom.Document ;
import org.w3c.dom.NodeList ;
import org.w3c.dom.Node ;
@ -7,6 +18,19 @@ import org.w3c.dom.Node ;
public class Utils
{
public static void saveXml( Document doc, String fname )
throws IOException, TransformerConfigurationException, TransformerException
{
// save the XML
Transformer trans = TransformerFactory.newInstance().newTransformer() ;
trans.setOutputProperty( OutputKeys.INDENT, "yes" ) ;
trans.setOutputProperty( "{http://xml.apache.org/xslt}indent-amount", "4" ) ;
trans.setOutputProperty( OutputKeys.METHOD, "xml" ) ;
trans.setOutputProperty( OutputKeys.ENCODING, "UTF-8" ) ;
trans.transform( new DOMSource(doc), new StreamResult(new FileOutputStream(fname)) ) ;
}
public static String getNodeTextContent( Node node )
{
// get the text content for an XML node (just itself, no descendants)
@ -42,4 +66,5 @@ public class Utils
}
return buf.toString() ;
}
}

@ -29,13 +29,8 @@ import java.awt.Dimension ;
import javax.xml.parsers.DocumentBuilderFactory ;
import javax.xml.parsers.DocumentBuilder ;
import javax.xml.parsers.ParserConfigurationException ;
import javax.xml.transform.Transformer ;
import javax.xml.transform.TransformerException ;
import javax.xml.transform.TransformerConfigurationException ;
import javax.xml.transform.TransformerFactory ;
import javax.xml.transform.OutputKeys ;
import javax.xml.transform.dom.DOMSource ;
import javax.xml.transform.stream.StreamResult ;
import javax.xml.xpath.XPathExpressionException ;
import org.w3c.dom.Document ;
import org.w3c.dom.NodeList ;
@ -61,7 +56,9 @@ import VASSAL.command.ConditionalCommand ;
import VASSAL.command.AlertCommand ;
import VASSAL.build.module.map.boardPicker.Board ;
import VASSAL.counters.GamePiece ;
import VASSAL.counters.BasicPiece ;
import VASSAL.counters.DynamicProperty ;
import VASSAL.counters.Hideable ;
import VASSAL.counters.PieceCloner ;
import VASSAL.preferences.Prefs ;
import VASSAL.tools.DataArchive ;
@ -77,6 +74,7 @@ import vassal_shim.Snippet ;
import vassal_shim.GamePieceLabelFields ;
import vassal_shim.LabelArea ;
import vassal_shim.ReportNode ;
import vassal_shim.AnalyzeNode ;
import vassal_shim.ModuleManagerMenuManager ;
import vassal_shim.Utils ;
@ -143,20 +141,43 @@ public class VassalShim
dumpCommand( cmd, "" ) ;
}
public void analyzeScenario( String scenarioFilename, String reportFilename )
throws IOException, ParserConfigurationException, TransformerConfigurationException, TransformerException
{
// load the scenario
configureBoards() ;
Command cmd = loadScenario( scenarioFilename ) ;
cmd.execute() ;
// analyze the scenario
HashMap<String,AnalyzeNode> results = new HashMap<String,AnalyzeNode>() ;
analyzeCommand( cmd, results ) ;
// generate the report
Document doc = DocumentBuilderFactory.newInstance().newDocumentBuilder().newDocument() ;
Element rootElem = doc.createElement( "analyzeReport" ) ;
doc.appendChild( rootElem ) ;
for ( String gpid: results.keySet() ) {
AnalyzeNode node = results.get( gpid ) ;
Element elem = doc.createElement( "piece" ) ;
elem.setAttribute( "gpid", gpid ) ;
elem.setAttribute( "name", node.name ) ;
elem.setAttribute( "count", Integer.toString( node.count ) ) ;
rootElem.appendChild( elem ) ;
}
// save the report
Utils.saveXml( doc, reportFilename ) ;
}
public void updateScenario( String scenarioFilename, String snippetsFilename, String saveFilename, String reportFilename )
throws IOException, ParserConfigurationException, SAXException, XPathExpressionException, TransformerException
{
// load the snippets supplied to us by the web server
Map<String,Snippet> snippets = parseSnippets( snippetsFilename ) ;
// NOTE: While we can get away with just disabling warnings about missing boards when dumping scenarios,
// they need to be present when we update a scenario, otherwise they get removed from the scenario :-/
logger.info( "Configuring boards directory: {}", boardsDir ) ;
Prefs prefs = GameModule.getGameModule().getPrefs() ;
String BOARD_DIR = "boardURL" ;
prefs.setValue( BOARD_DIR, new File(boardsDir) ) ;
// load the scenario
configureBoards() ;
Command cmd = loadScenario( scenarioFilename ) ;
// NOTE: The call to execute() is what's causing the VASSAL UI to appear on-screen. If we take it out,
// label creation still works, but any boards and existing labels are not detected, presumably because
@ -600,7 +621,7 @@ public class VassalShim
}
private void generateLabelReport( Map<String,ArrayList<ReportNode>> labelReport, String reportFilename )
throws TransformerException, TransformerConfigurationException, ParserConfigurationException, FileNotFoundException
throws TransformerException, TransformerConfigurationException, ParserConfigurationException, IOException
{
// generate the report
Document doc = DocumentBuilderFactory.newInstance().newDocumentBuilder().newDocument() ;
@ -626,12 +647,7 @@ public class VassalShim
rootElem.setAttribute( "wasModified", wasModified?"true":"false" ) ;
// save the report
Transformer trans = TransformerFactory.newInstance().newTransformer() ;
trans.setOutputProperty( OutputKeys.INDENT, "yes" ) ;
trans.setOutputProperty( "{http://xml.apache.org/xslt}indent-amount", "4" ) ;
trans.setOutputProperty( OutputKeys.METHOD, "xml" ) ;
trans.setOutputProperty( OutputKeys.ENCODING, "UTF-8" ) ;
trans.transform( new DOMSource(doc), new StreamResult(new FileOutputStream(reportFilename)) ) ;
Utils.saveXml( doc, reportFilename ) ;
}
private String makeVassalCoordString( Point pos, Snippet snippet )
@ -792,6 +808,27 @@ public class VassalShim
buf.append( ": " + cmd.getAllowedIds() ) ;
}
private static void analyzeCommand( Command cmd, Map<String,AnalyzeNode> results )
{
// analyze the command
if ( cmd instanceof AddPiece ) {
GamePiece target = ((AddPiece)cmd).getTarget() ;
// NOTE: Hideable's don't seem to be just a thing with old versions of VASSAL. We still get them
// when adding a "46mm granatnik wz. 36" (GPID 2172) using VASL 6.4.4 :-/
if ( target instanceof DynamicProperty || target instanceof Hideable || target.getClass().getName().equals("VASL.counters.TextInfo") ) {
int pos = target.getState().lastIndexOf( ";" ) ;
String gpid = target.getState().substring( pos+1 ) ;
if ( ! results.containsKey( gpid ) )
results.put( gpid, new AnalyzeNode( target.getName() ) ) ;
results.get( gpid ).incrementCount() ;
}
}
// analyze any sub-commands
for ( Command c: cmd.getSubCommands() )
analyzeCommand( c, results ) ;
}
private static void parseGamePieceState( String state, ArrayList<String> separators, ArrayList<String> fields )
{
// parse the GamePiece state
@ -805,6 +842,16 @@ public class VassalShim
fields.add( state.substring( pos ) ) ;
}
private void configureBoards()
{
// NOTE: While we can get away with just disabling warnings about missing boards when dumping scenarios,
// they need to be present when we update a scenario, otherwise they get removed from the scenario :-/
logger.info( "Configuring boards directory: {}", boardsDir ) ;
Prefs prefs = GameModule.getGameModule().getPrefs() ;
String BOARD_DIR = "boardURL" ;
prefs.setValue( BOARD_DIR, new File(boardsDir) ) ;
}
private void disableBoardWarnings()
{
// FUDGE! VASSAL shows a GUI error dialog warning about boards not being found, and while these can be disabled,

Loading…
Cancel
Save