"""Record the attract-mode run: a scripted Patron arc over the live council. v4 ("the wood fights back") arc: the SAME short-into-legend gambit, run twice. The first time the wood is calm and the crash pays in full; in between the Patron burns the wood (shorting goods sours their producers toward him); the second, identical gambit lands blunted because wary holders stand against the crash, and Heron's inquiry arrives faster because the soured creatures testify. The decay is the whole point -- a repeatable manipulation is a self-destroying strategy. Mechanics (heat, resistance, P&L, investigation) are deterministic from the moves and the deck; the council + narrator only make the thoughts and headlines genuine. The deck is overridden to a single repeating legend (Run on Oona's Hoard, the only one that crashes its good) so the two gambits are truly identical. Run (engines deployed + warm): TTW_COUNCIL=1 TTW_NARRATOR=1 python scripts/record_attract.py Free dry-run to tune the arc (no Modal, rule-based creatures): python scripts/record_attract.py --dummy --out data/_dryrun.json """ from __future__ import annotations import argparse import os import re import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parents[1])) from ttw import relationships # noqa: E402 from ttw.events import EVENTS, EventDeck # noqa: E402 from ttw.game import Game # noqa: E402 from ttw.replay import ATTRACT_PATH, record_frame, save_frames # noqa: E402 from ttw.world import INVESTIGATION_THRESHOLD # noqa: E402 DECK_SEED = 7 def _run_on_hoard(): """The one legend that crashes its good (honey, crash_mult=0.5). Repeating it makes the two gambits identical so only the wood's wariness differs.""" return next(e for e in EVENTS if e.key == "run_on_hoard") def build_game(dummy: bool) -> Game: if dummy: from ttw.dummy import make_random_policy g = Game(make_random_policy(seed=3), deck_seed=DECK_SEED, narrator=None) else: os.environ.setdefault("TTW_COUNCIL", "1") os.environ.setdefault("TTW_NARRATOR", "1") from ttw.agents import make_council_policy from ttw.llm import build_council_clients from ttw.narrator import make_narrator clients = build_council_clients() _warm_engines(clients) # all hot before the timed arc, no mid-run cold start g = Game(make_council_policy(clients, temperature=0.7), deck_seed=DECK_SEED, narrator=make_narrator()) # Override the deck so every Tempt Fate springs the same crashing legend. g.deck = EventDeck(seed=DECK_SEED, events=[_run_on_hoard()]) return g def _warm_engines(clients) -> None: """Ping every engine once so none cold-starts mid-recording (which can hard -timeout and kill the run). Failures are ignored; the policy is resilient.""" ping = [[{"role": "user", "content": "ready?"}]] for eid, client in clients.items(): try: client.chat_batch(ping, max_tokens=8) print(f" warmed {eid}") except Exception as e: # noqa: BLE001 print(f" warm {eid} failed (continuing): {e!r}") # The arc. Each entry: (list of pre-step moves, draw_legend?). A move is (kind, kwargs). # t1 arm gambit 1 while the wood is calm. Pre-bribe Oona (+2) so the honey short's # -3 to her (the producer) nets -1, leaving 0 wary holders -> a FULL crash. # t2 spring the legend: gambit 1 settles at the full payday. # t3 burn the wood: re-short honey (Oona -> wary) and short two foods the wood also # holds, souring their producers, so wary holders reach the resistance cap. The # soured creatures testify -> Heron's case, and the exposure meter's expected # payoff for the next identical gambit drops. # t4 arm gambit 2: an identical fresh honey short. # t5 spring the identical legend: the crash is blunted to its floor by the wary # holders -> honey barely dips on the chart where it cratered the first time. # t6-9 the inquiry runs to its verdict: the Patron is caught. SCRIPT: list[tuple[list[tuple[str, dict]], bool]] = [ ([("bribe", {"target": "Oona", "amount": 40}), ("short", {"good": "honey", "qty": 12})], False), ([], True), # gambit 1 settles (full) ([("short", {"good": "honey", "qty": 6}), ("short", {"good": "berries", "qty": 6}), ("short", {"good": "mushrooms", "qty": 6})], False), # Oona/Bramble/Fenn -> wary ([("short", {"good": "honey", "qty": 12})], False), # arm gambit 2 (identical) ([], True), # gambit 2 settles (blunted) ([], False), ([], False), ([], False), ([], False), ] def _payoff(md: str) -> str: m = re.search(r"Tempt Fate now: \*\*([+-]?\d+)p\*\*", md) return m.group(1) + "p" if m else "n/a" def main() -> None: ap = argparse.ArgumentParser() ap.add_argument("--dummy", action="store_true", help="rule-based creatures, no Modal (free dry-run to tune the arc)") ap.add_argument("--out", default=str(ATTRACT_PATH), help="where to save frames (use a temp path for dry-runs)") args = ap.parse_args() g = build_game(args.dummy) edge = g.deck.peek().affected_good print(f"insider edge good: {edge} (deck forced to Run on Oona's Hoard)\n") frames = [record_frame(g)] # frame 0: the opening tableau last_pnl = 0 for i, (moves, draw) in enumerate(SCRIPT, start=1): for kind, kw in moves: print(f" t{i}: {g.queue_player_move(kind, **kw)}") if draw: print(f" t{i}: LEGEND -> {g.tempt_fate()}") g.step() p = g.world.patron turn = g.world.turn fired = {e["type"] for e in g.world.log if e.get("turn") == turn} flags = " ".join(s for s in ("legend_crash", "wood_braces", "wood_testifies", "suspicious_win") if s in fired) n_wary = relationships.count_wary_of(g.world) n_hold = relationships.count_wary_holders(g.world, edge) oona = g.world.creatures["Oona"].relationships.get("Patron") delta = p.realized_pnl - last_pnl last_pnl = p.realized_pnl print(f"turn {turn}: heat={p.heat}/{INVESTIGATION_THRESHOLD} pnl={p.realized_pnl} " f"(d{delta:+d}) wary={n_wary} honey_holders={n_hold} " f"oona={oona.sentiment if oona else 0} exp_payoff={_payoff(g.exposure_markdown())}" f"{(' <' + flags + '>') if flags else ''}") frames.append(record_frame(g)) out = Path(args.out) save_frames(frames, out) print(f"\nsaved {len(frames)} frames to {out}") print(f"final heat {g.world.patron.heat}, investigation: {g.investigation_markdown()[:120]!r}") if __name__ == "__main__": main()