Spaces:
Running on Zero
Running on Zero
| """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() | |