Scrypt / balance /sim.py
IMJONEZZ's picture
SCRYPT: initial commit — game, sandbox, Warden, Space web layer
9fca766
Raw
History Blame Contribute Delete
3.63 kB
"""Headless balance simulator: a greedy bot plays every deck against every
encounter across many seeds.
Outputs a win-rate matrix (markdown report + JSON table). The JSON table is
what the director's validator consults when judging whether a deck edit
pushes an encounter outside its difficulty band.
Run: uv run python -m balance.sim
"""
from __future__ import annotations
import json
from collections import Counter
from pathlib import Path
from scrypt.data import Content, load_content
from scrypt.engine.cards import Card
# The bot lives in the shipped package (the encounter author sim-gates
# against it at runtime); these re-exports keep balance/ callers working.
from scrypt.engine.bots import ( # noqa: F401
bot_turn,
choose_lane,
choose_sacrifices,
simulate,
wants_fodder,
)
from scrypt.engine.combat import Result
REPORTS = Path(__file__).parent / "reports"
# ------------------------------------------------------------ the matrix
def winrate(content: Content, deck: list[Card], encounter_id: str, seeds: int = 100) -> dict:
script = content.encounters[encounter_id]["script"]
side = [content.card("bit")] * 20
outcomes = Counter()
turns_to_win: list[int] = []
for seed in range(seeds):
state = simulate(deck, side, script, seed)
if state.result is Result.PLAYER_WIN:
outcomes["win"] += 1
turns_to_win.append(state.turn + 1)
elif state.result is Result.PLAYER_LOSS:
outcomes["loss"] += 1
else:
outcomes["stall"] += 1
n = sum(outcomes.values())
return {
"winrate": outcomes["win"] / n,
"stall": outcomes["stall"] / n,
"avg_turns_to_win": (sum(turns_to_win) / len(turns_to_win)) if turns_to_win else None,
}
def standard_decks(content: Content) -> dict[str, list[Card]]:
decks: dict[str, list[Card]] = {
deck_id: list(deck["cards"]) for deck_id, deck in content.starter_decks.items()
}
decks["vanilla_drafted"] = decks["vanilla"] + [
content.card(c) for c in ("segfault", "kernel-panic", "root")
]
return decks
def run_matrix(content: Content | None = None, seeds: int = 100) -> dict:
content = content or load_content()
table: dict[str, dict[str, dict]] = {}
for deck_name, deck in standard_decks(content).items():
table[deck_name] = {}
for enc_id in content.encounters:
table[deck_name][enc_id] = winrate(content, deck, enc_id, seeds=seeds)
return table
def render_report(table: dict) -> str:
encounters = sorted(next(iter(table.values())))
lines = [
"# Balance report (greedy bot baseline)",
"",
"Win rate / stall rate / avg turns-to-win. The greedy bot is a floor —",
"humans play better, so target bands sit above these numbers.",
"",
"| deck | " + " | ".join(encounters) + " |",
"|---|" + "---|" * len(encounters),
]
for deck_name, row in table.items():
cells = []
for enc in encounters:
r = row[enc]
t = f"{r['avg_turns_to_win']:.0f}t" if r["avg_turns_to_win"] else "—"
cells.append(f"{r['winrate']:.0%} / {r['stall']:.0%} / {t}")
lines.append(f"| {deck_name} | " + " | ".join(cells) + " |")
return "\n".join(lines) + "\n"
def main() -> None:
table = run_matrix()
REPORTS.mkdir(exist_ok=True)
(REPORTS / "strength_table.json").write_text(json.dumps(table, indent=2))
report = render_report(table)
(REPORTS / "baseline.md").write_text(report)
print(report)
if __name__ == "__main__":
main()