Something went wrong. Try again.
A 5e storytelling engine with an LLM DM
Something went wrong. Try again.
Python
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593# pyright: reportArgumentType=false# Tests pass session dicts with `object` values where helpers expect `str`;# the test harness shapes the dict correctly."""Tests for the world planner — entity richness scoring, discovery, and context."""
import jsonfrom pathlib import Pathfrom unittest.mock import MagicMock, patch
import pytest
from storied.planner import ( BackgroundTicker, PlanResult, _find_entities_with_will, build_planning_context, build_tick_context, entity_richness, find_nearby_entities, plan_world,)from storied.session import save_sessionfrom storied.testing import call_toolfrom storied.tools import ToolContextfrom storied.tools.entities import establish, mark
@pytest.fixturedef populated_world(ctx: ToolContext) -> ToolContext: """Create a world with some entities for testing.""" call_tool( establish, entity_type="locations", name="Town Square", description=( "The center of [[Millford]]. A fountain stands here, " "surrounded by market stalls." ), location="Central [[Millford]]", knows=["The fountain was built by [[Old Gregor]]"], wants=["To be a gathering place"], will=["If market day → attract crowds"], ) call_tool( establish, entity_type="npcs", name="Old Gregor", description="An elderly stonemason.", location="[[Town Square]]", ) call_tool( establish, entity_type="locations", name="Millford", description="A small riverside town.", ) call_tool( establish, entity_type="npcs", name="Thin NPC", description="Someone with no inner life.", ) return ctx
class TestEntityRichness: """Tests for richness scoring."""
def test_empty_entity_scores_zero(self, ctx: ToolContext, tmp_path: Path): call_tool(establish, entity_type="npcs", name="Empty") path = tmp_path / "worlds/test-world/npcs/Empty.md" assert entity_richness(path) == 0.0
def test_description_only(self, ctx: ToolContext, tmp_path: Path): call_tool( establish, entity_type="npcs", name="Described", description="A tall warrior.", ) path = tmp_path / "worlds/test-world/npcs/Described.md" assert entity_richness(path) == pytest.approx(0.2)
def test_fully_rich_entity(self, ctx: ToolContext, tmp_path: Path): call_tool( establish, entity_type="npcs", name="Rich NPC", description="A fully fleshed out character in [[Town Square]].", knows=["Secret one", "Secret two"], wants=["Goal one"], will=["If X → do Y"], ) call_tool( mark, entity_type="npcs", name="Rich NPC", event="Something happened", ) path = tmp_path / "worlds/test-world/npcs/Rich NPC.md" score = entity_richness(path) assert score == pytest.approx(1.0)
def test_partial_richness(self, ctx: ToolContext, tmp_path: Path): call_tool( establish, entity_type="npcs", name="Partial", description="Has description and knows.", knows=["A secret"], ) path = tmp_path / "worlds/test-world/npcs/Partial.md" score = entity_richness(path) # description (0.2) + knows (0.2) = 0.4 assert score == pytest.approx(0.4)
def test_wikilinks_contribute(self, ctx: ToolContext, tmp_path: Path): call_tool( establish, entity_type="npcs", name="Linked", description="Hangs out at [[The Tavern]] with [[Bob]].", ) path = tmp_path / "worlds/test-world/npcs/Linked.md" score = entity_richness(path) # description (0.2) + wikilinks (0.1) = 0.3 assert score == pytest.approx(0.3)
class TestFindNearbyEntities: """Tests for discovering entities near the player."""
def test_finds_entities_from_location_links(self, populated_world: ToolContext): session = { "location": "Town Square", "body": "", } nearby = find_nearby_entities(session, "test-world") names = {name for name, _ in nearby} assert "Old Gregor" in names assert "Millford" in names
def test_finds_entities_from_session_body(self, populated_world: ToolContext): session = { "location": "Town Square", "body": "## Present\n- [[Thin NPC]]", } nearby = find_nearby_entities(session, "test-world") names = {name for name, _ in nearby} assert "Thin NPC" in names
def test_includes_current_location(self, populated_world: ToolContext): session = { "location": "Town Square", "body": "", } nearby = find_nearby_entities(session, "test-world") names = {name for name, _ in nearby} assert "Town Square" in names
def test_no_duplicates(self, populated_world: ToolContext): session = { "location": "Town Square", "body": "## Present\n- [[Old Gregor]]", } nearby = find_nearby_entities(session, "test-world") names = [name for name, _ in nearby] assert names.count("Old Gregor") == 1
def test_empty_session(self, ctx: ToolContext): session = {"body": ""} nearby = find_nearby_entities(session, "test-world") assert nearby == []
class TestGetRecentEntries: """Tests for CampaignLog.get_recent_entries()."""
def test_returns_current_day_entries(self, ctx: ToolContext): ctx.campaign_log.append_entry("Arrived at the tavern", "10 min") ctx.campaign_log.append_entry("Spoke with bartender", "15 min")
entries = ctx.campaign_log.get_recent_entries(days=1) events = [e.event for e in entries] assert "Arrived at the tavern" in events assert "Spoke with bartender" in events
def test_returns_previous_day_entries(self, ctx: ToolContext): ctx.campaign_log.append_entry("Morning event", "10 min") ctx.campaign_log.append_entry("Traveled all day", "18 hours") ctx.campaign_log.append_entry("Day 2 event", "10 min")
entries = ctx.campaign_log.get_recent_entries(days=2) events = [e.event for e in entries] assert "Morning event" in events assert "Day 2 event" in events
def test_respects_days_limit(self, ctx: ToolContext): ctx.campaign_log.append_entry("Day 1 event", "18 hours") ctx.campaign_log.append_entry("Day 2 event", "18 hours") ctx.campaign_log.append_entry("Day 3 event", "1 hour")
entries = ctx.campaign_log.get_recent_entries(days=1) events = [e.event for e in entries] assert "Day 3 event" in events assert "Day 1 event" not in events
def test_empty_log(self, ctx: ToolContext): entries = ctx.campaign_log.get_recent_entries(days=2) assert entries == []
class TestBuildPlanningContext: """Tests for assembling the planner's context."""
def test_includes_session_info(self, populated_world: ToolContext): save_session( "default", { "location": "Town Square", "body": ( "## Situation\nThe player just arrived.\n\n" "## Open Threads\n- Find the missing cat" ), }, ) context = build_planning_context( world_id=populated_world.world_id, player_id=populated_world.player_id, candidates=[], ) assert "Town Square" in context assert "missing cat" in context
def test_includes_candidate_content( self, populated_world: ToolContext, tmp_path: Path, ): save_session( "default", {"location": "Town Square", "body": ""}, ) path = tmp_path / "worlds/test-world/npcs/Thin NPC.md" context = build_planning_context( world_id=populated_world.world_id, player_id=populated_world.player_id, candidates=[("Thin NPC", path)], ) assert "Thin NPC" in context assert "Someone with no inner life" in context
def test_empty_candidates(self, populated_world: ToolContext): save_session( "default", {"location": "Town Square", "body": ""}, ) context = build_planning_context( world_id=populated_world.world_id, player_id=populated_world.player_id, candidates=[], ) assert "No entities" in context
def test_includes_recent_log_entries(self, populated_world: ToolContext): save_session( "default", {"location": "Town Square", "body": ""}, ) populated_world.campaign_log.append_entry( "Fought three goblins in the clearing", "5 rounds" ) populated_world.campaign_log.append_entry( "Spotted two lookouts near the old mill", "30 min" )
context = build_planning_context( world_id=populated_world.world_id, player_id=populated_world.player_id, candidates=[], ) assert "Recent Events" in context assert "three goblins" in context assert "two lookouts" in context
class TestPlanWorld: """Tests for the plan_world orchestrator."""
def test_dry_run_returns_candidates(self, populated_world: ToolContext): save_session( "default", { "location": "Town Square", "body": "## Present\n- [[Thin NPC]]", }, ) result = plan_world( world_id=populated_world.world_id, player_id=populated_world.player_id, dry_run=True, ) assert isinstance(result, PlanResult) assert result.dry_run is True assert len(result.candidates) > 0 names = [name for name, _, _ in result.candidates] assert "Thin NPC" in names
def test_dry_run_respects_threshold(self, populated_world: ToolContext): save_session( "default", {"location": "Town Square", "body": ""}, ) result = plan_world( world_id=populated_world.world_id, player_id=populated_world.player_id, dry_run=True, threshold=0.0, ) assert len(result.candidates) == 0
def test_dry_run_respects_max_entities(self, populated_world: ToolContext): save_session( "default", {"location": "Town Square", "body": ""}, ) result = plan_world( world_id=populated_world.world_id, player_id=populated_world.player_id, dry_run=True, max_entities=1, ) assert len(result.candidates) <= 1
def test_no_session_returns_empty(self, ctx: ToolContext): result = plan_world( world_id=ctx.world_id, player_id=ctx.player_id, dry_run=True, ) assert len(result.candidates) == 0
@patch("storied.claude.subprocess.Popen") def test_plan_world_calls_claude( self, mock_popen: MagicMock, populated_world: ToolContext ): save_session( "default", { "location": "Town Square", "body": "## Present\n- [[Thin NPC]]", }, )
# Mock subprocess returning a result event result_line = json.dumps( { "type": "result", "session_id": "sess-1", "usage": {"input_tokens": 1000, "output_tokens": 200, "tool_calls": 0}, "duration_ms": 5000, } ) mock_proc = MagicMock() mock_proc.stdin = MagicMock() mock_proc.stdout = iter([result_line.encode() + b"\n"]) mock_proc.stderr = iter([]) mock_proc.wait.return_value = 0 mock_proc.returncode = 0 mock_popen.return_value = mock_proc
result = plan_world( world_id=populated_world.world_id, player_id=populated_world.player_id, model="claude-opus-5", )
assert result.dry_run is False assert result.input_tokens == 1000 assert result.output_tokens == 200 mock_popen.assert_called_once()
@patch("storied.claude.subprocess.Popen") def test_plan_world_counts_tool_calls( self, mock_popen: MagicMock, populated_world: ToolContext ): save_session( "default", { "location": "Town Square", "body": "## Present\n- [[Thin NPC]]", }, )
# Stream with tool_use events followed by result lines = [ json.dumps( { "type": "stream_event", "event": { "type": "content_block_start", "index": 1, "content_block": { "type": "tool_use", "id": "t1", "name": "mcp__storied__establish", }, }, } ), json.dumps( { "type": "result", "session_id": "sess-2", "usage": {"input_tokens": 800, "output_tokens": 100}, "duration_ms": 3000, } ), ]
mock_proc = MagicMock() mock_proc.stdin = MagicMock() mock_proc.stdout = iter([line.encode() + b"\n" for line in lines]) mock_proc.stderr = iter([]) mock_proc.wait.return_value = 0 mock_proc.returncode = 0 mock_popen.return_value = mock_proc
result = plan_world( world_id=populated_world.world_id, player_id=populated_world.player_id, model="claude-opus-5", )
assert result.tool_calls == 1
# --- World tick helpers (subprocess-bound tick_world is pragma'd out) ------
class TestFindEntitiesWithWill: def test_returns_empty_when_world_missing(self, ctx: ToolContext): results = _find_entities_with_will("nonexistent") assert results == []
def test_finds_entities_with_will_triggers(self, populated_world: ToolContext): # Establish one with will, one without call_tool( establish, entity_type="npcs", name="Triggered", description="Has triggers.", will=["If approached → flee"], ) call_tool( establish, entity_type="npcs", name="Idle", description="No triggers.", ) results = _find_entities_with_will( populated_world.world_id, ) names = {name for name, _ in results} assert "Triggered" in names assert "Idle" not in names
def test_skips_missing_type_directories( self, populated_world: ToolContext, ): # The fixture creates npcs and locations but not items/factions/threads. # The function should iterate without crashing. results = _find_entities_with_will( populated_world.world_id, ) assert isinstance(results, list)
class TestBuildTickContext: def test_includes_current_time(self, populated_world: ToolContext): ctx_str = build_tick_context( populated_world.world_id, populated_world.player_id, entities=[], ) assert "Current Game Time" in ctx_str
def test_includes_session_location(self, populated_world: ToolContext): save_session( "default", { "location": "Town Square", "body": "## Present\n- [[Old Gregor]]", }, )
ctx_str = build_tick_context( populated_world.world_id, populated_world.player_id, entities=[], ) assert "Town Square" in ctx_str assert "Old Gregor" in ctx_str
def test_includes_recent_events(self, populated_world: ToolContext): populated_world.campaign_log.append_entry( "Met the merchant", "10 min", ) populated_world.campaign_log.append_entry( "Found the secret door", "5 min", ) ctx_str = build_tick_context( populated_world.world_id, populated_world.player_id, entities=[], ) assert "Recent Events" in ctx_str assert "Met the merchant" in ctx_str
def test_includes_entity_with_triggers(self, populated_world: ToolContext): call_tool( establish, entity_type="npcs", name="Lurker", description="Hides in shadows.", will=["If alone → emerge"], ) triggers = _find_entities_with_will( populated_world.world_id, ) ctx_str = build_tick_context( populated_world.world_id, populated_world.player_id, entities=triggers, ) assert "Active Triggers" in ctx_str assert "Lurker" in ctx_str assert "If alone → emerge" in ctx_str
class TestBackgroundTicker: """Cover the non-subprocess paths of BackgroundTicker.
The threaded `_run` method is pragma'd because it spawns a real claude subprocess. Everything around it (init, day-tracking, no-op fast paths) is testable here. """
def test_init_stores_state(self, tmp_path: Path): ticker = BackgroundTicker( world_id="test", player_id="default", ) assert ticker._world_id == "test" assert ticker._last_tick_day == 0 assert ticker._thread is None
def test_maybe_tick_skips_when_day_unchanged( self, populated_world: ToolContext, ): ticker = BackgroundTicker( world_id=populated_world.world_id, player_id=populated_world.player_id, ) ticker._last_tick_day = populated_world.campaign_log.current_day ticker.maybe_tick(populated_world.campaign_log) # No thread should have been spawned assert ticker._thread is None
def test_maybe_tick_skips_when_no_triggers(self, ctx: ToolContext): # ctx has a fresh empty world with no entities → no triggers, # so maybe_tick should mark the day done without spawning anything. ticker = BackgroundTicker( world_id=ctx.world_id, player_id=ctx.player_id, ) # Advance the day so the day-changed guard passes ctx.campaign_log.append_entry("Travel", "18 hours") ticker.maybe_tick(ctx.campaign_log) assert ticker._thread is None assert ticker._last_tick_day == ctx.campaign_log.current_day
def test_pop_result_returns_none_when_no_thread(self, tmp_path: Path): ticker = BackgroundTicker( world_id="test", player_id="default", ) assert ticker.pop_result() is None