|
|
|
#!/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
|