# 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 json from pathlib import Path from 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_session from storied.testing import call_tool from storied.tools import ToolContext from storied.tools.entities import establish, mark @pytest.fixture def 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-4-7", ) 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-4-7", ) 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