thousand-token-wood-sim / tests /test_narrator.py
AdmiralTaco's picture
Claude Opus 4.8
Judge-facing polish: attract mode, model attribution, per-creature reasoning
a4cd5c0
Raw History Blame Contribute Delete
4.64 kB
"""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