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