#!/usr/bin/env python3 """DEEPFALL sweep analyzer — pure stdlib, no dependencies. Reads the CSV/JSON written by `deepfall-sim` into `out/` and renders the headline balance picture to the terminal: an ASCII Lineage x Calling heatmap, the strategy band, the pacing/feel numbers, and the deep-content reachability. Runs anywhere Python 3 does — no pandas/matplotlib needed. python3 analysis/analyze.py out """ import csv import json import sys from pathlib import Path LINEAGES = ["Dvergar", "Wisp", "Revenant", "Gnoll", "Sylphid", "GolemBorn"] CALLINGS = ["Reaver", "Lorekeeper", "Wardancer", "Hierophant", "Warden", "Prospector"] # Shading ramp from cold (weak) to hot (strong), keyed off winrate vs. the fair share. RAMP = " .:-=+*#%@" def shade(frac, lo=0.0, hi=0.45): t = max(0.0, min(1.0, (frac - lo) / (hi - lo))) return RAMP[min(len(RAMP) - 1, int(t * (len(RAMP) - 1)))] def load_combo(out: Path): grid = {} with open(out / "combo_winrates.csv") as f: for row in csv.DictReader(f): grid[(row["lineage"], row["calling"])] = float(row["wins_frac"]) return grid def heatmap(grid): print("\nCOMBO WIN-RATE HEATMAP (focal combo as Balanced vs. a mixed random field)") print(" rows = Lineage, cols = Calling. Each cell: win% and a shade (fair share 25%).\n") colw = 11 header = " " * 11 + "".join(c[:colw].ljust(colw) for c in CALLINGS) print(header) for l in LINEAGES: cells = [] for c in CALLINGS: wr = grid.get((l, c), 0.0) cells.append(f"{shade(wr)*3} {wr*100:4.1f}%".ljust(colw)) print(f" {l:<9}" + "".join(cells)) # Marginals. print() lmean = {l: sum(grid[(l, c)] for c in CALLINGS) / len(CALLINGS) for l in LINEAGES} cmean = {c: sum(grid[(l, c)] for l in LINEAGES) / len(LINEAGES) for c in CALLINGS} print(" Lineage averages : " + " ".join(f"{l} {lmean[l]*100:4.1f}%" for l in LINEAGES)) print(" Calling averages : " + " ".join(f"{c} {cmean[c]*100:4.1f}%" for c in CALLINGS)) vals = list(grid.values()) print(f"\n spread: {(max(vals)-min(vals))*100:.1f} pts " f"best: {max(grid, key=grid.get)} {max(vals)*100:.1f}% " f"worst: {min(grid, key=grid.get)} {min(vals)*100:.1f}%") def bar(label, frac, width=44, scale=0.5): n = int(min(1.0, frac / scale) * width) return f" {label:<11}{frac*100:5.1f}% {'#'*n}" def strategy(out: Path): print("\nSTRATEGY WIN-RATES (fair share 25%)") with open(out / "strategy_winrates.csv") as f: for row in csv.DictReader(f): print(bar(row["strategy"], float(row["winrate"]))) def depth(out: Path): print("\nDEEP-CONTENT REACHABILITY (claims per floor; deeper = richer + collapses first)") rows = list(csv.DictReader(open(out / "depth_claims.csv"))) mx = max(int(r["claims"]) for r in rows) or 1 for r in rows: n = int(int(r["claims"]) / mx * 40) print(f" floor {r['depth']} {'#'*n} {r['claims']}") def headline(out: Path): s = json.load(open(out / "summary.json")) print("\nHEADLINE") print(f" seeds : {s['seeds']}") print(f" game length : mean {s['mean_rounds']:.1f} rounds " f"({s['rounds_min']}-{s['rounds_max']})") print(f" victory margin : {s['mean_margin_frac']*100:.1f}% of winner (lower = tighter)") print(f" feel-bad / game : {s['feelbad_per_game']:.3f} (Taken w/o Last Stand)") print(f" deep claims by R1 : {s['deep_claim_drafted_round_share']*100:.1f}% " f"(low = deep game is NOT decided at game start)") print(f" dominant strategy : {'YES' if s['strategy_dominant'] else 'no'}") print(f" combo spread : {s['combo_spread']*100:.1f} pts") def main(): out = Path(sys.argv[1] if len(sys.argv) > 1 else "out") if not (out / "combo_winrates.csv").exists(): sys.exit(f"no sweep data in {out}/ — run: cargo run -p deepfall-sim -- --out {out}") print("=" * 78) print("DEEPFALL — balance sweep analysis") print("=" * 78) headline(out) strategy(out) heatmap(load_combo(out)) depth(out) print("\n" + "=" * 78) if __name__ == "__main__": main()