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.
284 lines
12 KiB
284 lines
12 KiB
""" Main webapp handlers. """
|
|
|
|
import os
|
|
import threading
|
|
import concurrent
|
|
import json
|
|
import uuid
|
|
import logging
|
|
|
|
from flask import request, render_template, jsonify, send_file, redirect, url_for, abort
|
|
|
|
from vasl_templates.webapp import app, shutdown_event
|
|
from vasl_templates.webapp.utils import MsgStore, parse_int
|
|
import vasl_templates.webapp.config.constants
|
|
from vasl_templates.webapp.config.constants import BASE_DIR, DATA_DIR
|
|
from vasl_templates.webapp import globvars
|
|
from vasl_templates.webapp.lfa import DEFAULT_LFA_DICE_HOTNESS_WEIGHTS, DEFAULT_LFA_DICE_HOTNESS_THRESHOLDS
|
|
|
|
# NOTE: This is used to stop multiple instances of the program from running (see main.py in the desktop app).
|
|
INSTANCE_ID = uuid.uuid4().hex
|
|
|
|
startup_msg_store = MsgStore() # store messages generated during startup
|
|
_check_versions = True
|
|
|
|
# ---------------------------------------------------------------------
|
|
|
|
@app.route( "/" )
|
|
def main():
|
|
"""Return the main page."""
|
|
return render_template( "index.html" )
|
|
|
|
# ---------------------------------------------------------------------
|
|
|
|
@app.route( "/startup-msgs" )
|
|
def get_startup_msgs():
|
|
"""Return any messages generated during startup."""
|
|
|
|
global _check_versions
|
|
if _check_versions:
|
|
_check_versions = False
|
|
# check the VASSAL version
|
|
from vasl_templates.webapp.vassal import VassalShim
|
|
try:
|
|
VassalShim.check_vassal_version( startup_msg_store )
|
|
except Exception as ex: #pylint: disable=broad-except
|
|
msg = "Can't get the VASSAL version: {}".format( ex )
|
|
logging.error( "%s", msg )
|
|
startup_msg_store.error( msg )
|
|
|
|
# collect all the startup messages
|
|
startup_msgs = {}
|
|
for msg_type in ("info","warning","error"):
|
|
msgs = startup_msg_store.get_msgs( msg_type )
|
|
if msgs:
|
|
startup_msgs[ msg_type ] = msgs
|
|
|
|
# NOTE: Because we don't clear the collected messages here, if the web page is refreshed, or loaded
|
|
# via another browser instance, the user will see the same messages again, which, depending on
|
|
# the exact nature of those messages, could look odd, but is probably what we want.
|
|
return jsonify( startup_msgs )
|
|
|
|
# ---------------------------------------------------------------------
|
|
|
|
_APP_CONFIG_DEFAULTS = { # Bodhgaya, India (APR/19)
|
|
"THEATERS": [ "ETO", "DTO", "PTO", "Korea", "Burma", "other" ],
|
|
# NOTE: We use HTTP for static images, since VASSAL is already insanely slow loading images (done in serial?),
|
|
# so I don't even want to think about what it might be doing during a TLS handshake... :-/
|
|
"ONLINE_IMAGES_URL_BASE": "http://vasl-templates.org/services/static-images",
|
|
# NOTE: We would rather use https://github.com/vasl-developers/vasl/raw/develop/dist/images/ in the template,
|
|
# but VASSAL is already so slow to load images, and doing everything twice would make it that much worse :-/
|
|
"ONLINE_COUNTER_IMAGES_URL_TEMPLATE": "https://raw.githubusercontent.com/vasl-developers/vasl/develop/dist/images/{{PATH}}", #pylint: disable=line-too-long
|
|
"ONLINE_EXTN_COUNTER_IMAGES_URL_TEMPLATE": "http://vasl-templates.org/services/counter/{{EXTN_ID}}/{{PATH}}",
|
|
"ASA_UPLOAD_URL": "https://aslscenarioarchive.com/rest/update/{ID}?user={USER}&token={TOKEN}",
|
|
}
|
|
|
|
@app.route( "/app-config" )
|
|
def get_app_config():
|
|
"""Get the application config."""
|
|
|
|
def get_json_val( key, default ):
|
|
"""Get a JSON value from the app config."""
|
|
try:
|
|
val = app.config.get( key, default )
|
|
return val if isinstance(val,dict) else json.loads(val)
|
|
except json.decoder.JSONDecodeError:
|
|
msg = "Couldn't parse app config setting: {}".format( key )
|
|
logging.error( "%s", msg )
|
|
startup_msg_store.error( msg )
|
|
return default
|
|
|
|
# include the basic app config
|
|
vals = {
|
|
key: app.config.get( key, default )
|
|
for key,default in _APP_CONFIG_DEFAULTS.items()
|
|
}
|
|
if isinstance( vals["THEATERS"], str ):
|
|
vals["THEATERS"] = vals["THEATERS"].split()
|
|
for key in ["APP_NAME","APP_VERSION","APP_DESCRIPTION","APP_HOME_URL"]:
|
|
vals[ key ] = getattr( vasl_templates.webapp.config.constants, key )
|
|
|
|
# include the ASL Scenario Archive config
|
|
for key in ["ASA_MAX_VASL_SETUP_SIZE","ASA_MAX_SCREENSHOT_SIZE"]:
|
|
vals[ key ] = app.config[ key ]
|
|
|
|
# include the dice hotness config
|
|
vals[ "LFA_DICE_HOTNESS_WEIGHTS" ] = get_json_val(
|
|
"LFA_DICE_HOTNESS_WEIGHTS", DEFAULT_LFA_DICE_HOTNESS_WEIGHTS
|
|
)
|
|
vals[ "LFA_DICE_HOTNESS_THRESHOLDS" ] = get_json_val(
|
|
"LFA_DICE_HOTNESS_THRESHOLDS", DEFAULT_LFA_DICE_HOTNESS_THRESHOLDS
|
|
)
|
|
vals[ "DISABLE_LFA_HOTNESS_FADEIN" ] = app.config.get( "DISABLE_LFA_HOTNESS_FADEIN" )
|
|
|
|
# NOTE: We allow the front-end to generate snippets that point to an alternative webapp server (so that
|
|
# VASSAL can get images, etc. from a Docker container rather than the desktop app). However, since it's
|
|
# unlikely anyone else will want this option, we implement it as a debug setting, rather than exposing it
|
|
# as an option in the UI.
|
|
alt_webapp_base_url = app.config.get( "ALTERNATE_WEBAPP_BASE_URL" )
|
|
if alt_webapp_base_url:
|
|
vals[ "ALTERNATE_WEBAPP_BASE_URL" ] = alt_webapp_base_url
|
|
|
|
# include information about VASSAL and VASL
|
|
from vasl_templates.webapp.vassal import VassalShim
|
|
try:
|
|
vals[ "VASSAL_VERSION" ] = VassalShim.get_version()
|
|
except Exception as ex: #pylint: disable=broad-except
|
|
logging.error( "Can't check the VASSAL version: %s", str(ex) )
|
|
if globvars.vasl_mod:
|
|
vals["VASL_VERSION"] = globvars.vasl_mod.vasl_version
|
|
|
|
# include information about VASL extensions
|
|
if globvars.vasl_mod and globvars.vasl_mod.extns:
|
|
vals["VASL_EXTENSIONS"] = {}
|
|
for extn in globvars.vasl_mod.extns:
|
|
extn_info = {}
|
|
for key in ("version","displayName","displayNameAbbrev"):
|
|
if key in extn[1]:
|
|
extn_info[ key ] = extn[1][ key ]
|
|
vals["VASL_EXTENSIONS"][ extn[1]["extensionId"] ] = extn_info
|
|
|
|
# include the ASL Scenario Archive config data
|
|
for key in ["ASA_SCENARIO_URL","ASA_PUBLICATION_URL","ASA_PUBLISHER_URL"]:
|
|
vals[ key ] = app.config[ key ]
|
|
for key in ["BALANCE_GRAPH_THRESHOLD"]:
|
|
vals[ key ] = app.config.get( key )
|
|
fname = os.path.join( DATA_DIR, "asl-scenario-archive.json" )
|
|
if os.path.isfile( fname ):
|
|
with open( fname, "r" ) as fp:
|
|
try:
|
|
vals[ "SCENARIOS_CONFIG" ] = json.load( fp )
|
|
except json.decoder.JSONDecodeError as ex:
|
|
msg = "Couldn't load the ASL Scenario Archive config."
|
|
logging.error( "%s", msg )
|
|
startup_msg_store.error( msg )
|
|
|
|
return jsonify( vals )
|
|
|
|
# ---------------------------------------------------------------------
|
|
|
|
@app.route( "/help" )
|
|
def show_help():
|
|
"""Show the help page."""
|
|
url = url_for( "static", filename="help/index.html" )
|
|
if request.args:
|
|
args = [ "{}={}".format( arg, request.args[arg] ) for arg in request.args ]
|
|
url += "?{}".format( "&".join( args ) )
|
|
return redirect( url, code=302 )
|
|
|
|
# ---------------------------------------------------------------------
|
|
|
|
@app.route( "/license" )
|
|
def get_license():
|
|
"""Get the license."""
|
|
|
|
# locate the license file
|
|
dname = BASE_DIR
|
|
fname = os.path.join( dname, "../../LICENSE.txt" ) # nb: if we're running from source
|
|
if not os.path.isfile( fname ):
|
|
fname = os.path.join( dname, "LICENSE.txt" ) # nb: if we're running as a compiled binary
|
|
if not os.path.isfile( fname ):
|
|
# FUDGE! If we've been pip install'ed walk up the directory tree, looking for the license file :-/
|
|
while True:
|
|
# go up a directory
|
|
prev_dname = dname
|
|
dname = os.path.split( dname )[0]
|
|
if dname == prev_dname:
|
|
break
|
|
# check if we can find the license file
|
|
fname = os.path.join( dname, "vasl-templates/LICENSE.txt" )
|
|
if os.path.isfile( fname ):
|
|
break
|
|
if not os.path.isfile( fname ):
|
|
abort( 404 )
|
|
|
|
return send_file( fname, "text/plain" )
|
|
|
|
# ---------------------------------------------------------------------
|
|
|
|
default_scenario = None
|
|
|
|
@app.route( "/default-scenario" )
|
|
def get_default_scenario():
|
|
"""Return the default scenario."""
|
|
|
|
# check if a default scenario has been configured
|
|
if default_scenario:
|
|
fname = default_scenario
|
|
else:
|
|
fname = os.path.join( app.config.get("DATA_DIR",DATA_DIR), "default-scenario.json" )
|
|
|
|
# return the default scenario
|
|
with open(fname,"r") as fp:
|
|
return jsonify( json.load( fp ) )
|
|
|
|
# ---------------------------------------------------------------------
|
|
|
|
_control_tests_port_no = None
|
|
|
|
@app.route( "/control-tests" )
|
|
def get_control_tests():
|
|
"""Return information about the remote test control service."""
|
|
|
|
def get_port():
|
|
"""Get the configured gRPC service port."""
|
|
# NOTE: The Docker container configures this setting via an environment variable.
|
|
return app.config.get( "CONTROL_TESTS_PORT", os.environ.get("CONTROL_TESTS_PORT") )
|
|
|
|
# check if the test control service should be made available
|
|
port_no = get_port()
|
|
if not port_no:
|
|
abort( 404 )
|
|
|
|
# check if we've already started the service
|
|
if not _control_tests_port_no:
|
|
|
|
# nope - make it so
|
|
print( "*** WARNING: Remote test control enabled! ***" )
|
|
started_event = threading.Event()
|
|
def run_service(): #pylint: disable=missing-docstring
|
|
import grpc
|
|
server = grpc.server( concurrent.futures.ThreadPoolExecutor( max_workers=1 ) )
|
|
from vasl_templates.webapp.tests.proto.generated.control_tests_pb2_grpc \
|
|
import add_ControlTestsServicer_to_server
|
|
from vasl_templates.webapp.tests.control_tests_servicer import ControlTestsServicer #pylint: disable=cyclic-import
|
|
servicer = ControlTestsServicer( app )
|
|
add_ControlTestsServicer_to_server( servicer, server )
|
|
port_no = parse_int( get_port(), -1 ) # nb: have to get this again?!
|
|
if port_no <= 0:
|
|
# NOTE: Requesting port 0 tells grpc to use any free port, which is usually OK, unless
|
|
# we're running inside a Docker container, in which case it needs to be pre-defined,
|
|
# so that the port can be mapped to an external port when the container is started.
|
|
port_no = 0
|
|
port_no = server.add_insecure_port( "[::]:{}".format( port_no ) )
|
|
logging.getLogger( "control_tests" ).debug(
|
|
"Started the gRPC test control service: port=%s", str(port_no)
|
|
)
|
|
server.start()
|
|
global _control_tests_port_no
|
|
_control_tests_port_no = port_no
|
|
started_event.set()
|
|
shutdown_event.wait()
|
|
server.stop( None )
|
|
server.wait_for_termination()
|
|
thread = threading.Thread( target=run_service, daemon=True )
|
|
thread.start()
|
|
|
|
# wait for the service to start (since the caller will probably try to connect
|
|
# to it as soon as we return a response).
|
|
started_event.wait( timeout=10 )
|
|
|
|
# wait for the gRPC server to end cleanly when we shutdown
|
|
def cleanup(): #pylint: disable=missing-docstring
|
|
thread.join()
|
|
globvars.cleanup_handlers.append( cleanup )
|
|
|
|
# return the service info to the caller
|
|
return jsonify( { "port": _control_tests_port_no } )
|
|
|
|
# ---------------------------------------------------------------------
|
|
|
|
@app.route( "/ping" )
|
|
def ping():
|
|
"""Let the caller know we're alive."""
|
|
return "pong: {}".format( INSTANCE_ID )
|
|
|