Something went wrong. Try again.
[READ-ONLY] Mirror of https://github.com/quarterback/viperball. Version of pre-1906 Gridiron Football based off a silly Reddit question viperball.xyz
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371#!/usr/bin/env python3"""Batch simulation for verifying engine rebalance metrics."""
import sysimport osimport randomimport globimport jsonfrom pathlib import Pathfrom collections import defaultdict
sys.path.insert(0, str(Path(__file__).parent))
from engine import ViperballEngine, load_team_from_json
def run_batch(num_games=200): team_files = glob.glob("data/teams/*.json") if len(team_files) < 2: print("Need at least 2 team files") return
# Accumulators total_plays = [] home_scores = [] away_scores = [] home_tds = [] away_tds = [] home_fgs = [] away_fgs = [] home_snap_kicks = [] away_snap_kicks = [] home_fumbles = [] away_fumbles = [] home_turnovers_on_downs = [] away_turnovers_on_downs = [] home_yards = [] away_yards = [] lateral_attempts = [] lateral_successes = [] lateral_interceptions_total = [] down_4_attempts = [] down_4_conversions = [] down_5_attempts = [] down_5_conversions = [] down_6_attempts = [] down_6_conversions = [] home_timeouts_used = [] away_timeouts_used = [] punts_total = [] kick_pass_attempts = [] kick_pass_completions = [] kick_pass_ints = [] first_downs_total = [] drives_total = [] play_family_totals = defaultdict(int)
for i in range(num_games): # Pick two random different teams pair = random.sample(team_files, 2) try: home = load_team_from_json(pair[0]) away = load_team_from_json(pair[1]) engine = ViperballEngine(home, away) result = engine.simulate_game() except Exception as e: print(f"Game {i+1} ERROR: {e}") import traceback traceback.print_exc() continue
fs = result['final_score'] home_scores.append(fs['home']['score']) away_scores.append(fs['away']['score'])
hs = result.get('stats', {}).get('home', {}) aws = result.get('stats', {}).get('away', {})
# Plays h_plays = hs.get('total_plays', 0) a_plays = aws.get('total_plays', 0) total_plays.append(h_plays + a_plays)
# TDs home_tds.append(hs.get('touchdowns', 0)) away_tds.append(aws.get('touchdowns', 0))
# FGs (place_kicks_made) home_fgs.append(hs.get('place_kicks_made', 0)) away_fgs.append(aws.get('place_kicks_made', 0))
# Snap kicks (drop_kicks_made) home_snap_kicks.append(hs.get('drop_kicks_made', 0)) away_snap_kicks.append(aws.get('drop_kicks_made', 0))
# Fumbles home_fumbles.append(hs.get('fumbles_lost', 0)) away_fumbles.append(aws.get('fumbles_lost', 0))
# Turnovers on downs home_turnovers_on_downs.append(hs.get('turnovers_on_downs', 0)) away_turnovers_on_downs.append(aws.get('turnovers_on_downs', 0))
# Yards home_yards.append(hs.get('total_yards', 0)) away_yards.append(aws.get('total_yards', 0))
# Laterals lat_att = hs.get('lateral_chains', 0) + aws.get('lateral_chains', 0) lat_suc = hs.get('successful_laterals', 0) + aws.get('successful_laterals', 0) lateral_attempts.append(lat_att) lateral_successes.append(lat_suc)
# Lateral interceptions lat_int = hs.get('lateral_interceptions', 0) + aws.get('lateral_interceptions', 0) lateral_interceptions_total.append(lat_int)
# Down conversions - nested as {4: {'attempts': N, 'converted': N}} h_dc = hs.get('down_conversions', {}) a_dc = aws.get('down_conversions', {})
d4h = h_dc.get(4, {}) d4a = a_dc.get(4, {}) down_4_attempts.append(d4h.get('attempts', 0) + d4a.get('attempts', 0)) down_4_conversions.append(d4h.get('converted', 0) + d4a.get('converted', 0))
d5h = h_dc.get(5, {}) d5a = a_dc.get(5, {}) down_5_attempts.append(d5h.get('attempts', 0) + d5a.get('attempts', 0)) down_5_conversions.append(d5h.get('converted', 0) + d5a.get('converted', 0))
d6h = h_dc.get(6, {}) d6a = a_dc.get(6, {}) down_6_attempts.append(d6h.get('attempts', 0) + d6a.get('attempts', 0)) down_6_conversions.append(d6h.get('converted', 0) + d6a.get('converted', 0))
# Timeouts - check the engine's game_state if available # The stats don't track timeouts directly, so estimate from game_state # For now, just track what we can home_timeouts_used.append(0) away_timeouts_used.append(0)
# Punts punts_total.append(hs.get('punts', 0) + aws.get('punts', 0))
# Kick pass stats kp_att = hs.get('kick_passes_attempted', 0) + aws.get('kick_passes_attempted', 0) kp_comp = hs.get('kick_passes_completed', 0) + aws.get('kick_passes_completed', 0) kp_int = hs.get('kick_pass_interceptions', 0) + aws.get('kick_pass_interceptions', 0) kick_pass_attempts.append(kp_att) kick_pass_completions.append(kp_comp) kick_pass_ints.append(kp_int)
# Play family breakdown from stats h_pfb = hs.get('play_family_breakdown', {}) a_pfb = aws.get('play_family_breakdown', {}) for fam, count in h_pfb.items(): play_family_totals[fam] += count for fam, count in a_pfb.items(): play_family_totals[fam] += count
# First downs from play-by-play pbp = result.get('play_by_play', []) fd_count = len([p for p in pbp if p.get('result') == 'first_down']) first_downs_total.append(fd_count)
# Avg yards_to_go on down 4 d4_plays = [p for p in pbp if p.get('down') == 4 and p.get('play_type') not in ['punt', 'drop_kick', 'place_kick']] if d4_plays: avg_ytg_d4 = sum(p.get('yards_to_go', 0) for p in d4_plays) / len(d4_plays) else: avg_ytg_d4 = 0 if not hasattr(run_batch, '_d4_ytg_samples'): run_batch._d4_ytg_samples = [] run_batch._d4_ytg_samples.append(avg_ytg_d4)
# Track drive outcomes if not hasattr(run_batch, '_drive_outcomes'): run_batch._drive_outcomes = defaultdict(int) for ds in result.get('drive_summary', []): outcome = ds.get('result', 'unknown') run_batch._drive_outcomes[outcome] += 1
if (i + 1) % 25 == 0: print(f" Completed {i+1}/{num_games} games...")
# Compute stats def avg(lst): return sum(lst) / len(lst) if lst else 0
def pct(num_list, denom_list): n = sum(num_list) d = sum(denom_list) return (n / d * 100) if d > 0 else 0
n = len(total_plays) print(f"\n{'='*60}") print(f"BATCH SIMULATION RESULTS ({n} games)") print(f"{'='*60}\n")
print(f"{'Metric':<35} {'Result':>12} {'Target':>12}") print(f"{'-'*60}")
avg_plays = avg(total_plays) print(f"{'Plays/game':<35} {avg_plays:>12.1f} {'140-220':>12}") if total_plays: print(f"{' Min/Max plays':<35} {min(total_plays):>5}/{max(total_plays):<6}")
avg_home = avg(home_scores) avg_away = avg(away_scores) avg_score = (avg_home + avg_away) / 2 print(f"{'Avg score/team':<35} {avg_score:>12.1f} {'65-85':>12}") print(f"{' Home/Away avg':<35} {avg_home:>5.1f}/{avg_away:<6.1f}")
avg_h_td = avg(home_tds) avg_a_td = avg(away_tds) avg_td = (avg_h_td + avg_a_td) / 2 print(f"{'Avg TDs/team':<35} {avg_td:>12.1f} {'6-8':>12}")
avg_h_sk = avg(home_snap_kicks) avg_a_sk = avg(away_snap_kicks) avg_sk = (avg_h_sk + avg_a_sk) / 2 print(f"{'Snap kicks made/team':<35} {avg_sk:>12.1f} {'2-4':>12}")
avg_h_fg = avg(home_fgs) avg_a_fg = avg(away_fgs) avg_fg = (avg_h_fg + avg_a_fg) / 2 print(f"{'FGs made/team':<35} {avg_fg:>12.1f} {'1-3':>12}")
avg_h_fum = avg(home_fumbles) avg_a_fum = avg(away_fumbles) avg_fum = (avg_h_fum + avg_a_fum) / 2 print(f"{'Fumbles/team':<35} {avg_fum:>12.1f} {'~1.6':>12}")
avg_h_tod = avg(home_turnovers_on_downs) avg_a_tod = avg(away_turnovers_on_downs) avg_tod = (avg_h_tod + avg_a_tod) / 2 print(f"{'Turnovers on downs/team':<35} {avg_tod:>12.1f} {'2-3':>12}")
avg_h_yds = avg(home_yards) avg_a_yds = avg(away_yards) avg_yds = (avg_h_yds + avg_a_yds) / 2 print(f"{'Avg yards/team':<35} {avg_yds:>12.1f} {'600-750':>12}")
# Lateral efficiency total_lat_att = sum(lateral_attempts) total_lat_suc = sum(lateral_successes) lat_eff = (total_lat_suc / total_lat_att * 100) if total_lat_att > 0 else 0 print(f"{'Lateral efficiency':<35} {lat_eff:>11.1f}% {'59-72%':>12}") print(f"{' Total laterals att/suc':<35} {total_lat_att:>5}/{total_lat_suc:<6}")
total_lat_int = sum(lateral_interceptions_total) print(f"{'Lateral interceptions (total)':<35} {total_lat_int:>12}") if total_lat_att > 0: print(f"{' Lat INT rate':<35} {total_lat_int/total_lat_att*100:>11.1f}%")
d4_pct = pct(down_4_conversions, down_4_attempts) d5_pct = pct(down_5_conversions, down_5_attempts) d6_pct = pct(down_6_conversions, down_6_attempts) print(f"{'4th down conversion':<35} {d4_pct:>11.1f}% {'~85%':>12}") print(f"{' 4th down att/conv':<35} {sum(down_4_attempts):>5}/{sum(down_4_conversions):<6}") print(f"{'5th down conversion':<35} {d5_pct:>11.1f}% {'~71%':>12}") print(f"{' 5th down att/conv':<35} {sum(down_5_attempts):>5}/{sum(down_5_conversions):<6}") print(f"{'6th down conversion':<35} {d6_pct:>11.1f}% {'~63%':>12}") print(f"{' 6th down att/conv':<35} {sum(down_6_attempts):>5}/{sum(down_6_conversions):<6}")
avg_punts = avg(punts_total) print(f"{'Punts/game':<35} {avg_punts:>12.1f}")
total_kp_att = sum(kick_pass_attempts) total_kp_comp = sum(kick_pass_completions) total_kp_int = sum(kick_pass_ints) kp_comp_pct = (total_kp_comp / total_kp_att * 100) if total_kp_att > 0 else 0 kp_int_pct = (total_kp_int / total_kp_att * 100) if total_kp_att > 0 else 0 print(f"{'Kick pass completion %':<35} {kp_comp_pct:>11.1f}%") print(f"{' KP att/comp/int':<35} {total_kp_att:>4}/{total_kp_comp:>4}/{total_kp_int:<4}") print(f"{'Kick pass INT rate':<35} {kp_int_pct:>11.1f}% {'3-4%':>12}")
avg_fd = avg(first_downs_total) print(f"{'First downs/game':<35} {avg_fd:>12.1f}") if avg_plays > 0: fd_rate = avg_fd / avg_plays * 100 print(f"{'First down rate (% of plays)':<35} {fd_rate:>11.1f}%") if hasattr(run_batch, '_d4_ytg_samples') and run_batch._d4_ytg_samples: avg_d4_ytg = avg(run_batch._d4_ytg_samples) print(f"{'Avg yards_to_go on 4th down':<35} {avg_d4_ytg:>12.1f}")
# Drive outcome breakdown if hasattr(run_batch, '_drive_outcomes'): outcomes = run_batch._drive_outcomes total_dr = sum(outcomes.values()) print(f"\n{'DRIVE OUTCOMES':<35} {'Count':>8} {'%':>8}") for k in sorted(outcomes.keys(), key=lambda x: -outcomes[x]): pct_val = outcomes[k] / total_dr * 100 if total_dr > 0 else 0 print(f" {k:<33} {outcomes[k]:>8} {pct_val:>7.1f}%")
# ── V2.1: Play Family Distribution ── total_family_plays = sum(play_family_totals.values()) if total_family_plays > 0: print(f"\n{'='*60}") print("PLAY FAMILY DISTRIBUTION (V2.1)") print(f"{'='*60}") print(f"{'Family':<25} {'Count':>8} {'%':>8} {'Target':>12}") print(f"{'-'*55}")
# Aggregate runs run_families = ['dive_option', 'power', 'sweep_option', 'speed_option', 'counter', 'draw', 'viper_jet'] run_total = sum(play_family_totals.get(f, 0) for f in run_families) run_pct = run_total / total_family_plays * 100 print(f"{'RUN (aggregate)':<25} {run_total:>8} {run_pct:>7.1f}% {'~28%':>12}") for f in run_families: c = play_family_totals.get(f, 0) if c > 0: print(f"{' ' + f:<25} {c:>8} {c/total_family_plays*100:>7.1f}%")
kp_total = play_family_totals.get('kick_pass', 0) kp_pct = kp_total / total_family_plays * 100 print(f"{'KICK PASS':<25} {kp_total:>8} {kp_pct:>7.1f}% {'~34%':>12}")
sk_total = play_family_totals.get('snap_kick', 0) sk_pct = sk_total / total_family_plays * 100 print(f"{'SNAP KICK':<25} {sk_total:>8} {sk_pct:>7.1f}% {'15-18%':>12}")
fg_total = play_family_totals.get('field_goal', 0) fg_pct = fg_total / total_family_plays * 100 print(f"{'FIELD GOAL':<25} {fg_total:>8} {fg_pct:>7.1f}% {'5-8%':>12}")
punt_total = play_family_totals.get('punt', 0) punt_pct = punt_total / total_family_plays * 100 print(f"{'PUNT':<25} {punt_total:>8} {punt_pct:>7.1f}% {'3-5%':>12}")
lat_total = play_family_totals.get('lateral_spread', 0) lat_pct = lat_total / total_family_plays * 100 print(f"{'LATERAL':<25} {lat_total:>8} {lat_pct:>7.1f}% {'4-6%':>12}")
trick_total = play_family_totals.get('trick_play', 0) trick_pct = trick_total / total_family_plays * 100 print(f"{'TRICK/OTHER':<25} {trick_total:>8} {trick_pct:>7.1f}% {'5-6%':>12}")
# Also show territory_kick (deprecated) if any tk_total = play_family_totals.get('territory_kick', 0) if tk_total > 0: tk_pct = tk_total / total_family_plays * 100 print(f"{'TERRITORY_KICK (legacy)':<25} {tk_total:>8} {tk_pct:>7.1f}%")
# Per-team-game averages print(f"\n{'Per-team-game averages:'}") teams_total = n * 2 # each game has 2 teams print(f" Snap kicks called/team: {sk_total / max(1, teams_total):.1f}") print(f" Field goals called/team: {fg_total / max(1, teams_total):.1f}") print(f" Punts called/team: {punt_total / max(1, teams_total):.1f}")
# Avg fatigue (from the single-game sample) print(f"\n{'='*60}") print("SCORE DISTRIBUTION") print(f"{'='*60}") all_scores = home_scores + away_scores buckets = defaultdict(int) for s in all_scores: bucket = int(s // 10) * 10 buckets[bucket] += 1 for b in sorted(buckets.keys()): bar = '#' * (buckets[b] // 2) print(f" {b:>3}-{b+9:<3}: {buckets[b]:>4} {bar}")
if __name__ == "__main__": num = int(sys.argv[1]) if len(sys.argv) > 1 else 200 print(f"Running {num} game batch simulation...") run_batch(num)