"""Headless tests for Phase 4: Narrator, chapter arcs, and the truth firewall. All GPU-free: the dummy/raising Narrators are pure Python and the Game runs on the random policy. Covers determinism, the None and raising fallbacks, the guard length cap, chapter advance rules, and the summarize_events truth firewall. Run: python -m pytest tests/ -q """ import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parents[1])) from ttw.chapters import CHAPTERS, next_chapter_index from ttw.dummy import make_random_policy from ttw.game import Game from ttw.narrator import DummyNarrator, guard_headline, summarize_events class RaisingNarrator: """A Narrator whose dramatize always throws, to exercise the Game fallback.""" def dramatize(self, events, chapter_name): raise RuntimeError("narrator boom") def _eventful(): """A small batch of public events with at least one salient lead line.""" return [ {"type": "trade", "buyer": "Pip", "seller": "Oona", "good": "honey", "qty": 2, "price": 9}, {"type": "gossip", "creature": "Fenn", "message": "honey looks frothy"}, ] def test_dummy_narrator_deterministic_nonempty_headline(): n = DummyNarrator() head1, dlg1 = n.dramatize(_eventful(), CHAPTERS[0][0]) head2, dlg2 = n.dramatize(_eventful(), CHAPTERS[0][0]) assert head1 # non-empty headline for an eventful turn assert (head1, dlg1) == (head2, dlg2) # same input -> same output def test_game_with_no_narrator_renders_plain_ticker(): g = Game(make_random_policy(seed=3), deck_seed=1, narrator=None) g.step() # must not raise assert g.headline == "" journal = g.journal_markdown() # With no headline/dialogue the journal degrades to exactly the ticker body. assert journal == g.ticker_markdown() assert g.ticker # the plain ticker lines were still built assert any(line in journal for line in g.ticker) def test_raising_narrator_does_not_crash_game_and_degrades(): g = Game(make_random_policy(seed=3), deck_seed=1, narrator=RaisingNarrator()) g.step() # the Game must swallow the Narrator exception assert g.headline == "" # failure leaves no headline # A gossip beat may fill dialogue from narrate.py; force the pure-degrade case # by asserting the journal still surfaces the deterministic ticker lines. assert g.ticker journal = g.journal_markdown() assert any(line in journal for line in g.ticker) def test_guard_headline_single_line_and_capped(): out = guard_headline("x" * 500) assert "\n" not in out assert len(out) <= 180 def test_guard_headline_strips_label_prefix_and_emphasis(): # gpt-oss prefixes a markdown label; the guard must show the real headline. assert guard_headline("**Headline:** Patron's Sweet Gambit") == "Patron's Sweet Gambit" assert guard_headline("**Headline:**\nThe Run on Oona's Hoard") == "The Run on Oona's Hoard" assert guard_headline("## News — Berries Vanish") == "Berries Vanish" def test_guard_headline_keeps_mid_headline_colon(): # A colon INSIDE the headline (not a label) must survive. assert guard_headline("Patron Turns Sour: Oona Reels") == "Patron Turns Sour: Oona Reels" def test_next_chapter_index_advances_on_verdict_and_cadence_not_quiet(): # Advances on an investigation_verdict event. assert next_chapter_index(0, 3, [{"type": "investigation_verdict", "verdict": "fine"}]) == 1 # Advances on a turn that crosses the CHAPTER_TURNS boundary (turn 8). assert next_chapter_index(0, 8, []) == 1 # Holds on a quiet turn 3 with no beat events. assert next_chapter_index(0, 3, [{"type": "trade"}]) == 0 # Clamps at the final chapter. assert next_chapter_index(len(CHAPTERS) - 1, 8, []) == len(CHAPTERS) - 1 def test_summarize_events_firewall_hides_tip_truth(): # A player_move tip (public) plus the engine's hidden tip ledger entry. The # narrator must see neither the legend_key nor any truth marker, and the # tip_planted event must be excluded entirely. events = [ {"type": "player_move", "applied": True, "kind": "tip", "target": "Pip", "good": "acorns", "truth": True, "legend_key": "acorn_mania"}, {"type": "tip_planted", "target": "Pip", "good": "acorns", "legend_key": "acorn_mania", "truth": True}, {"type": "gossip", "creature": "Fenn", "message": "acorns are hot"}, ] digest = summarize_events(events, CHAPTERS[0][0]) assert "acorn_mania" not in digest assert "truth" not in digest.lower() assert "True" not in digest assert "tip_planted" not in digest