| """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 |
|
|
| |
| |
| from scrypt.engine.bots import ( |
| bot_turn, |
| choose_lane, |
| choose_sacrifices, |
| simulate, |
| wants_fodder, |
| ) |
| from scrypt.engine.combat import Result |
|
|
| REPORTS = Path(__file__).parent / "reports" |
|
|
|
|
| |
|
|
| 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() |
|
|