Create attractive VASL scenarios, with loads of useful information embedded to assist with game play. https://vasl-templates.org
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 
vasl-templates/vasl_templates/webapp/vo_utils.py

479 lines
19 KiB

""" Utilities to help load the vehicle/ordnance listings. """
import os
import json
import re
import copy
import logging
from vasl_templates.webapp import app, globvars
from vasl_templates.webapp.config.constants import DATA_DIR
_vo_comments = None
# ---------------------------------------------------------------------
_NOTE_ID_PREFIXES = {
"US": "american",
"Br": "british",
"Ge": "german",
"Ru": "russian",
"Fr": "french",
"Ch": "chinese",
"AllM": "allied-minor",
}
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
_COMMENT_HANDLERS = {
"russian": {
"vehicles": {
"N": lambda vo_entry, note_id: _check_name( vo_entry, note_id, {
"(a)": "American ESB",
"(b)": "British ESB"
} ),
"LL": lambda vo_entry, note_id: _check_name( vo_entry, note_id, {
"(a)": "{? 01/1944- | Black TH# | Red TH# | Black TH#<sup>44+</sup> ?}"
}, "Black TH#" ),
}
},
"british": {
"vehicles": {
"A": lambda vo_entry, note_id: _check_name( vo_entry, note_id, {
"(a)": "American ESB+"
} )
}
},
"french": {
"vehicles": {
"F": lambda vo_entry, note_id: _check_name( vo_entry, note_id, {
"(a)": _french_veh_f,
"(b)": _french_veh_f,
"(f)": _french_veh_f,
} ),
}
},
"finnish": {
"vehicles": {
"D": lambda vo_entry, note_id: _check_name( vo_entry, note_id, {
"(b)": "British ESB",
"(g)": [ "German ESB", "Black TH#" ],
"(r)": "Russian ESB",
"(s)": [ "Swedish ESB", "Black TH#" ]
} )
},
"ordnance": {
"B": lambda vo_entry, note_id: _check_name( vo_entry, note_id, {
"(b)": "Black TH#",
"(f)": "Black TH#",
"(g)": [ "Black TH#", "No Captured Use penalty for Germans" ],
"(r)": "No Captured Use penalty for Russians",
"(s)": "Black TH#",
"(t)": "Black TH#",
} )
}
},
"chinese": {
"vehicles": {
"A": lambda vo_entry, note_id: _check_name( vo_entry, note_id, {
"(a)": [ "American ESB, +1 DRM" ],
"(b)": [ "British ESB, +1 DRM" ],
"(g)": [ "German ESB, +1 DRM" ],
"(i)": [ "Italian ESB, +1 DRM" ],
"(r)": [ "Russian ESB, +1 DRM" ],
} ),
"D": "2 TK DR"
}
},
"allied-minor": {
"vehicles": {
"A": lambda vo_entry, note_id: _check_name( vo_entry, note_id, {
"(a)": "American ESB+",
"(b)": "British ESB+",
"(f)": "French ESB+",
"(i)": "Italian ESB+",
} )
}
},
"axis-minor": {
"vehicles": {
"E": lambda vo_entry, note_id: _check_name( vo_entry, note_id, {
"(f)": "French ESB",
"(g)": _axis_minor_veh_e,
"(i)": "Italian ESB",
"(r)": "Russian ESB",
"(t)": _axis_minor_veh_e,
} ),
},
"ordnance": {
"E": lambda vo_entry, note_id: _check_name( vo_entry, note_id, {
"(g)": _axis_minor_ord_e,
"(t)": _axis_minor_ord_e,
} ),
}
},
"kfw-un": {
"vehicles": {
"UU": lambda vo_entry, note_id: _check_name( vo_entry, note_id, {
"(a)": "American ESB+",
} ),
}
}
}
def _check_comment_handlers( vo_entry, nat, vo_type, note_id, comments, orig_nat ):
"""Add any multi-applicable note-specific comments to the vehicle/ordnance."""
val = _COMMENT_HANDLERS.get( nat, {} ).get( vo_type, {} ).get( note_id )
if not val:
return
if isinstance( val, str ):
comments.append( val )
elif isinstance( val, list ):
comments.extend( val )
else:
assert callable( val )
val = val( vo_entry, orig_nat )
if val:
assert isinstance( val, list )
comments.extend( val )
def _french_veh_f( vo_entry, orig_nat ): #pylint: disable=unused-argument
"""Handle French Vehicle Note F."""
# NOTE: French Vehicle Note F says things like:
# "(a)" also indicates that this vehicle is treated as captured if crewed by other than Free French or U.S.
# so we would like to be smart here and check the owning player's nationality and add a "Captured Use" comment
# only if it applies. Unfortunately, while this technique works for the Allied/Axis Minor common vehicles/ordnance,
# it won't here :-(
# Consider a scenario where the British have an Ac de 40 CA(a). This piece won't appear in the list of
# British vehicles, so the user has to set up a 2nd scenario, with a Free French player, to get access to
# this piece. The code will detect the owning player is the Free French, and so conclude that it doesn't need
# to add a "Captured Use" comment. There's no way of fixing this (other than adding the Free French
# vehicles/ordnance to every nationality that could possibly use them), so we add the comment verbatim
# and let the user figure it out.
if "(a)" in vo_entry["name"]:
comments = [ "Black TH#", "American ESB+" ]
if vo_entry["id"] == "fr/v:020": # nb: AM Dodge(a)
comments.append( "Captured Use (unless Vichy French)" )
else:
comments.append( "Captured Use (unless Free French or US)" )
elif "(b)" in vo_entry["name"]:
comments = [ "Black TH#", "British ESB+" ]
comments.append( "Captured Use (unless Vichy French or British)" )
elif "(f)" in vo_entry["name"]:
comments = [ "Red TH#", "French ESB+" ]
comments.append( "Captured Use (unless Free/Vichy French)" )
else:
comments = []
return comments
def _axis_minor_veh_e( vo_entry, orig_nat ):
"""Handle Axis Minor Vehicle Note E (cases (g) and (t) only)."""
assert "(g)" in vo_entry["name"] or "(t)" in vo_entry["name"]
comments = [ "German ESB" if "(g)" in vo_entry["name"] else "Czech ESB" ]
if orig_nat in ( "romanian", "hungarian", "slovakian" ):
comments.append( "Black TH#" )
return comments
def _axis_minor_ord_e( vo_entry, orig_nat ):
"""Handle Axis Minor Ordnance Note E."""
assert "(g)" in vo_entry["name"] or "(t)" in vo_entry["name"]
if orig_nat in ( "romanian", "hungarian", "slovakian" ):
return [ "Black TH#" ]
return None
def _check_name( vo_entry, nat, cases, defaultVal=None ):
"""Check the vehicle/ordnance's name."""
for key,val in cases.items():
if key in vo_entry.get("name",""):
if isinstance( val, str ):
return [val]
elif isinstance( val, list ):
return val
else:
assert callable( val )
return val( vo_entry, nat )
if defaultVal:
return [defaultVal]
return None
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
def add_vo_comments( listings, vo_type, msg_store ):
"""Add comments to the vehicle/ordnance entries."""
# Melbourne, Australia (JUN/20)
# initialize
global _vo_comments
if not _vo_comments:
fname = os.path.join( app.config.get("DATA_DIR",DATA_DIR), "vo-comments.json" )
with open( fname, "r", encoding="utf-8" ) as fp:
_vo_comments = json.load( fp )
# process each vehicle/ordnance
for nat,vo_entries in listings.items():
for vo_entry in vo_entries:
if "copy_from" in vo_entry:
continue # nb: we do these later, when the entry is actually copied
_do_add_vo_comments( vo_entry, nat, vo_type, msg_store )
def _do_add_vo_comments( vo_entry, nat, vo_type, msg_store ): #pylint: disable=too-many-locals,too-many-branches
"""Add comments to a vehicle/ordnance entry."""
# figure out which comments have been disabled
disable_comments_for_note_ids = set() # disable all omments associated with these note ID's
disabled_comments = set() # disable these specific comments
prefixes = "|".join( _NOTE_ID_PREFIXES.keys() )
regex = re.compile( "^(({}) )?[A-Za-z]{{,2}}$".format( prefixes ) )
vals = vo_entry.get( "disabled_comments", [] )
for val in vals if isinstance(vals,list) else [vals]:
if regex.search( val ):
disable_comments_for_note_ids.add( val )
else:
disabled_comments.add( val )
# get the vehicle/ordnance's manually-defined comments
comments = vo_entry.get( "comments", [] )
if isinstance( comments, str ):
comments = [ comments ]
# add any generated comments
comments.extend(
_make_comments( vo_entry, nat, vo_type, disable_comments_for_note_ids )
)
# dedupe the comments
# NOTE: This needs to be done in the front-end as well, since some comments will be generated
# based on the scenario date, and we have no way of knowing what that is here.
comments2, comment_index = [], set()
for cmt in comments:
if cmt in comment_index:
continue
comments2.append( cmt )
comment_index.add( cmt )
# remove comments that have been disabled
# NOTE: This needs to be done in the front-end as well, since some comments will be generated
# based on the scenario date, and we have no way of knowing what that is here.
comments3 = []
def parse_cmd( cmt ):
"""Parse a disabled comment command."""
if cmt.startswith( "?:" ):
return cmt[2:].strip(), False # nb: this is an optional comment (i.e. don't warn if it's not there)
else:
return cmt, True
disabled_comments = dict( parse_cmd(c) for c in disabled_comments )
for cmt in comments2:
if cmt in disabled_comments:
del disabled_comments[ cmt ]
else:
comments3.append( cmt )
disabled_comments = { k: v for k,v in disabled_comments.items() if v }
if disabled_comments:
if msg_store:
msg_store.warning(
"Can't find disabled comments for {}: <ul> {} </ul>".format(
vo_entry["id"],
" ".join( "<li> {}".format(c) for c in disabled_comments )
)
)
# install the comments into the vehicle/ordnance entry
if comments3:
vo_entry["comments"] = comments3
else:
vo_entry.pop( "comments", None )
def _make_comments( vo_entry, nat, vo_type, disabled_note_ids ): #pylint: disable=too-many-branches
"""Automatically generate comments for a vehicle/ordnance."""
# initialize
all_comments = []
# process each multi-applicable note
vo_notes = vo_entry.get( "notes", [] )
for note_id in vo_notes:
# clean up the next note ID
pos = note_id.find( "\u2020" )
if pos >= 0:
note_id = note_id[:pos]
note_id = re.sub( r"\<sup\>.*?\</sup\>", "", note_id )
note_id = re.sub( r"\<s\>.*?\</s\>", "", note_id )
if not note_id:
continue
assert re.search( "^[A-Za-z0-9 ]+$", note_id )
# translate nationality-specific note ID's
orig_note_id = note_id
force_auto_comment = False
nat2 = nat
if globvars.template_pack:
nat_type = globvars.template_pack[ "nationalities" ].get( nat, {} ).get( "type" ) #pylint: disable=unsubscriptable-object
else:
nat_type = None
if nat in ( "kfw-uro", "kfw-bcfk", "kfw-un-common" ):
nat2 = "kfw-un"
elif nat in ( "kfw-kpa", "kfw-cpva" ):
nat2 = "kfw-comm"
elif nat_type == "allied-minor" or nat == "allied-minor-common":
nat2 = "allied-minor"
elif nat_type == "axis-minor" or nat == "axis-minor-common":
nat2 = "axis-minor"
words = note_id.split()
if len(words) > 1:
nat2 = _NOTE_ID_PREFIXES.get( words[0] )
if nat2:
note_id = " ".join( words[1:] )
force_auto_comment = True
# check if all comments for this note have been disabled
if orig_note_id in disabled_note_ids:
continue
# generate any comments associated with this multi-applicable note
comments = []
if not vo_entry.get( "extn_id" ):
# NOTE: We don't do this for extensions because if a vehicle/ordnance has Note X,
# that references the extension's Note X, not the nationality's normal Note X.
# However, vehicles/ordnance can reference things like "Ru M" or "AllM F", but these will
# set force_auto_comment and cause those comments to be added below.
if nat2 != nat:
_check_comment_handlers( vo_entry, nat2, vo_type, note_id, comments, nat )
else:
_check_comment_handlers( vo_entry, nat, vo_type, note_id, comments, None )
if not vo_entry.get( "extn_id" ) or force_auto_comment:
auto_comments = _vo_comments.get( nat2, {} ).get( vo_type, {} ).get( note_id )
if auto_comments:
_append_to( comments, auto_comments )
# update the vehicle/ordnance entry
_append_to( all_comments, comments )
return all_comments
def _append_to( dest, val ):
"""Append value(s) to a list."""
assert isinstance( dest, list )
if isinstance( val, str ):
dest.append( val )
elif isinstance( val, list ):
dest.extend( val )
else:
assert False
# ---------------------------------------------------------------------
def copy_vo_entry( placeholder_vo_entry, src_vo_entry, nat, vo_type, msg_store ): #pylint: disable=too-many-branches
"""Create a new vehicle/ordnance entry by copying an existing one."""
# Anjuna, India (FEB/19)
# create the new vehicle/ordnance entry
new_vo_entry = copy.deepcopy( src_vo_entry )
new_vo_entry["id"] = placeholder_vo_entry["id"]
if "name" in placeholder_vo_entry:
new_vo_entry["name"] = placeholder_vo_entry["name"]
if "gpid" in placeholder_vo_entry:
new_vo_entry["gpid"] = placeholder_vo_entry["gpid"]
elif "extra_gpids" in placeholder_vo_entry:
if not isinstance( new_vo_entry["gpid"], list ):
new_vo_entry["gpid"] = [ new_vo_entry["gpid"] ]
new_vo_entry["gpid"].extend( placeholder_vo_entry["extra_gpids"] )
def add_prefix( notes, prefix ):
"""Add a prefix to a list of note ID's."""
for i,note in enumerate(notes):
notes[i] = "{} {}".format( prefix, note )
# fixup any note numbers and multi-applicable notes
vo_id = placeholder_vo_entry[ "copy_from" ]
if vo_id.startswith( "br/" ):
prefix = "Br"
elif vo_id.startswith( "am/" ):
prefix = "US"
elif vo_id.startswith( "fr/" ):
prefix = "Fr"
else:
logging.warning( "Unexpected vehicle/ordnance reference nationality: %s", vo_id )
prefix = ""
if "note_number" in placeholder_vo_entry:
# replace the note# with the explicitly-defined one
new_vo_entry["note_number"] = placeholder_vo_entry["note_number"]
else:
# fixup the note# from the original vehicle/ordnance
new_vo_entry["note_number"] = "{} {}".format( prefix, new_vo_entry["note_number"] )
if "notes" in placeholder_vo_entry:
# replace the multi-applicable notes with the explicitly-defined ones
new_vo_entry["notes"] = placeholder_vo_entry["notes"]
elif "notes" in new_vo_entry:
# fixup the multi-applicable notes from the original vehicle/ordnance
add_prefix( new_vo_entry["notes"], prefix )
if "extra_notes" in placeholder_vo_entry:
new_vo_entry["notes"].extend( placeholder_vo_entry["extra_notes"] )
# fixup the comments
if "comments" in placeholder_vo_entry:
if "comments" in new_vo_entry:
new_vo_entry["comments"].extend( placeholder_vo_entry["comments"] )
else:
new_vo_entry["comments"] = placeholder_vo_entry["comments"]
if "disabled_comments" in new_vo_entry:
add_prefix( new_vo_entry["disabled_comments"], prefix )
if "disabled_comments" in placeholder_vo_entry:
if "disabled_comments" in new_vo_entry:
new_vo_entry["disabled_comments"].extend( placeholder_vo_entry["disabled_comments"] )
else:
new_vo_entry["disabled_comments"] = placeholder_vo_entry["disabled_comments"]
# NOTE: Dynamically adding comments complicates things a lot, since they sometimes depend on
# the vehicle/ordnance's name (e.g. if it contains "(a)"), or the owning nationality.
# We re-generate the comments here, which means that comments from the source entry will be
# re-added, but they will get deduped.
_do_add_vo_comments( new_vo_entry, nat, vo_type, msg_store )
return new_vo_entry
# ---------------------------------------------------------------------
def apply_extn_info( listings, extn_fname, extn_info, vo_index, vo_type ):
"""Update the vehicle/ordnance listings for the specified VASL extension."""
# initialize
logger = logging.getLogger( "vasl_mod" )
logger.info( "Updating %s for VASL extension '%s': %s",
vo_type, extn_info["extensionId"], os.path.split(extn_fname)[1]
)
# process each entry
for nat in extn_info:
if not isinstance( extn_info[nat], dict ):
continue
for entry in extn_info[nat].get( vo_type, [] ):
vo_entry = vo_index.get( entry["id"] )
if vo_entry:
# update an existing vehicle/ordnance
logger.debug( "- Updating GPID's for %s: %s", entry["id"], entry["gpid"] )
if vo_entry["gpid"]:
prev_gpids = vo_entry["gpid"]
if not isinstance( vo_entry["gpid"], list ):
vo_entry["gpid"] = [ vo_entry["gpid"] ]
vo_entry["gpid"].extend( entry["gpid"] )
else:
prev_gpids = "(none)"
vo_entry["gpid"] = entry["gpid"]
# NOTE: We can't really set the extension ID here because the counter is also in the core VASL module.
logger.debug( " - %s => %s", prev_gpids, vo_entry["gpid"] )
else:
# add a new vehicle/ordnance
if nat not in listings:
listings[ nat ] = []
entry[ "extn_id" ] = extn_info[ "extensionId" ]
listings[ nat ].append( entry )
# ---------------------------------------------------------------------
def make_vo_index( vo_entries ):
"""Generate an index of each vehicle/ordnance entry."""
vo_index = {}
for nat in vo_entries:
for vo_entry in vo_entries[nat]:
vo_index[ vo_entry["id"] ] = vo_entry
return vo_index