Claude Opus 4.8
Judge-facing polish: attract mode, model attribution, per-creature reasoning
a4cd5c0 Download tests/test_narrator.py from build-small-hackathon/thousand-token-wood-sim: direct link, hf CLI and curl.
- Browser
- Download file 4.64 kB
-
https://huggingface.co/spaces/build-small-hackathon/thousand-token-wood-sim/resolve/main/tests/test_narrator.py
- Command line
-
hf download hf://spaces/build-small-hackathon/thousand-token-wood-sim/tests/test_narrator.py
-
curl -L -o test_narrator.py https://huggingface.co/spaces/build-small-hackathon/thousand-token-wood-sim/resolve/main/tests/test_narrator.py
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 | |