reikhelm/deepfall/analysis/analyze.py
Parley Hatch 277c158378 feat(deepfall): board-game design + balance-by-bot simulator
DEEPFALL: a tabletop game mashing up 9 loved games (SmashUp, Dragon
Rampage, Blood Rage, Catan, EverQuest, Daggerfall, Eye of the Beholder,
Quarriors, SmallWorld). Core fusion = SmallWorld decline + Blood Rage
glorious death -> three exits (Cash Out / Press On / Worthy End) under a
rising Maw that collapses a reikhelm dungeon floor-by-floor.

- deepfall-core: pure deterministic engine (vendored ChaCha8, 11 tests)
- deepfall-sim: balance-by-bot sweeps -> CSV/JSON
- analysis/analyze.py: stdlib ASCII heatmap (no deps)
- DESIGN.md (rules + post-critique revisions), FINDINGS.md (9-iteration
  balance journey), README.md
- reikhelm-core/examples/sample_dungeon.rs: dumps a real generated board

Method: adversarial design critics -> build -> simulate -> tune. 9
iterations compressed combo spread 77.8 -> 32.5 pts (all 36 combos
viable). Fixed deep-content lockout (value rises with the Maw), the
multi-collapse feel-bad (1 collapse/round cap; feel-bad now 0.00/game),
and dead combos. Open finding: Cash-Out and Worthy-End are substitutes
for a glory-maximizing bot.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 22:49:33 -06:00

110 lines
4.1 KiB
Python

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