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>
110 lines
4.1 KiB
Python
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()
|