A 5e storytelling engine with an LLM DM
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 482 lines 17 kB view raw
1"""Tests for the arc planner — helpers and plot_arc orchestrator.""" 2 3from pathlib import Path 4from unittest.mock import MagicMock, patch 5 6import pytest 7 8from storied import paths 9from storied.character import create_character 10from storied.planner import ( 11 _draw_oblique_strategies, 12 _load_concept_pools, 13 _load_world_preferences, 14 _pick_random_concepts, 15 plot_arc, 16 seed_world, 17) 18 19# ---- _load_concept_pools ---------------------------------------------------- 20 21 22class TestLoadConceptPools: 23 def test_loads_shipped_pools_by_default(self): 24 pools = _load_concept_pools() 25 assert pools # shipped file has categories 26 assert all(isinstance(items, list) for items in pools.values()) 27 assert all(len(items) > 0 for items in pools.values()) 28 29 def test_user_override_replaces_shipped(self, tmp_path: Path): 30 user_dir = paths.user_rules_path() 31 user_dir.mkdir(parents=True, exist_ok=True) 32 (user_dir / "concept_pools.md").write_text( 33 "# My Pools\n\n## Custom\n- alpha\n- beta\n- gamma\n" 34 ) 35 36 pools = _load_concept_pools() 37 assert pools == {"Custom": ["alpha", "beta", "gamma"]} 38 39 def test_returns_empty_when_neither_exists( 40 self, 41 tmp_path: Path, 42 monkeypatch, 43 ): 44 monkeypatch.setattr( 45 paths, 46 "user_rules_path", 47 lambda: tmp_path / "missing-user", 48 ) 49 from storied import planner 50 51 monkeypatch.setattr( 52 planner, 53 "_REPO_PROMPTS", 54 tmp_path / "missing-shipped", 55 ) 56 57 pools = _load_concept_pools() 58 assert pools == {} 59 60 def test_skips_categories_without_items(self, tmp_path: Path): 61 user_dir = paths.user_rules_path() 62 user_dir.mkdir(parents=True, exist_ok=True) 63 (user_dir / "concept_pools.md").write_text( 64 "## Empty\n\n## Has items\n- one\n- two\n" 65 ) 66 67 pools = _load_concept_pools() 68 assert "Empty" not in pools 69 assert pools["Has items"] == ["one", "two"] 70 71 72# ---- _pick_random_concepts -------------------------------------------------- 73 74 75class TestPickRandomConcepts: 76 def test_picks_requested_count_across_categories(self, tmp_path: Path): 77 user_dir = paths.user_rules_path() 78 user_dir.mkdir(parents=True, exist_ok=True) 79 (user_dir / "concept_pools.md").write_text( 80 "## A\n- a1\n- a2\n\n## B\n- b1\n- b2\n\n## C\n- c1\n- c2\n" 81 ) 82 83 picks = _pick_random_concepts(count=3) 84 assert len(picks) == 3 85 assert all(p in ("a1", "a2", "b1", "b2", "c1", "c2") for p in picks) 86 87 def test_one_pick_per_category(self, tmp_path: Path): 88 # Each pick comes from a distinct category, so picks from a 89 # 3-category pool of 100 items each shouldn't exceed 3 distinct 90 # category-tagged items. 91 user_dir = paths.user_rules_path() 92 user_dir.mkdir(parents=True, exist_ok=True) 93 (user_dir / "concept_pools.md").write_text( 94 "## A\n- a1\n- a2\n\n## B\n- b1\n- b2\n\n## C\n- c1\n- c2\n" 95 ) 96 97 picks = _pick_random_concepts(count=3) 98 # Each category contributes at most one pick. 99 from_a = sum(1 for p in picks if p.startswith("a")) 100 from_b = sum(1 for p in picks if p.startswith("b")) 101 from_c = sum(1 for p in picks if p.startswith("c")) 102 assert from_a <= 1 103 assert from_b <= 1 104 assert from_c <= 1 105 106 def test_count_exceeding_categories_returns_all(self, tmp_path: Path): 107 user_dir = paths.user_rules_path() 108 user_dir.mkdir(parents=True, exist_ok=True) 109 (user_dir / "concept_pools.md").write_text("## A\n- a1\n\n## B\n- b1\n") 110 111 picks = _pick_random_concepts(count=10) 112 assert len(picks) == 2 113 114 def test_empty_pools_returns_empty(self, tmp_path: Path, monkeypatch): 115 monkeypatch.setattr( 116 paths, 117 "user_rules_path", 118 lambda: tmp_path / "missing-user", 119 ) 120 from storied import planner 121 122 monkeypatch.setattr( 123 planner, 124 "_REPO_PROMPTS", 125 tmp_path / "missing-shipped", 126 ) 127 128 assert _pick_random_concepts(count=8) == [] 129 130 131# ---- _draw_oblique_strategies ----------------------------------------------- 132 133 134class TestDrawObliqueStrategies: 135 def test_draws_from_shipped_by_default(self): 136 drawn = _draw_oblique_strategies(count=4) 137 assert len(drawn) == 4 138 # Each entry should be a stripped non-empty string 139 assert all(isinstance(s, str) and s for s in drawn) 140 141 def test_no_duplicates_within_a_call(self): 142 drawn = _draw_oblique_strategies(count=10) 143 assert len(drawn) == len(set(drawn)) 144 145 def test_user_override_replaces_shipped(self, tmp_path: Path): 146 user_dir = paths.user_rules_path() 147 user_dir.mkdir(parents=True, exist_ok=True) 148 (user_dir / "oblique_strategies.md").write_text( 149 "# My Strategies\n\n- one\n- two\n- three\n" 150 ) 151 152 drawn = _draw_oblique_strategies(count=2) 153 assert all(d in ("one", "two", "three") for d in drawn) 154 assert len(drawn) == 2 155 156 def test_count_exceeding_deck_returns_all(self, tmp_path: Path): 157 user_dir = paths.user_rules_path() 158 user_dir.mkdir(parents=True, exist_ok=True) 159 (user_dir / "oblique_strategies.md").write_text("- only\n- two\n") 160 161 drawn = _draw_oblique_strategies(count=10) 162 assert len(drawn) == 2 163 164 def test_empty_deck_returns_empty(self, tmp_path: Path, monkeypatch): 165 monkeypatch.setattr( 166 paths, 167 "user_rules_path", 168 lambda: tmp_path / "missing-user", 169 ) 170 from storied import planner 171 172 monkeypatch.setattr( 173 planner, 174 "_REPO_PROMPTS", 175 tmp_path / "missing-shipped", 176 ) 177 178 assert _draw_oblique_strategies(count=4) == [] 179 180 181# ---- _load_world_preferences ------------------------------------------------ 182 183 184@pytest.fixture 185def world_with_style(tmp_path: Path) -> str: 186 world_dir = paths.world_path("default") 187 world_dir.mkdir(parents=True, exist_ok=True) 188 (world_dir / "style.md").write_text("Grim. Slow-burn investigation.") 189 return "default" 190 191 192@pytest.fixture 193def world_with_style_and_arc(tmp_path: Path) -> str: 194 world_dir = paths.world_path("default") 195 world_dir.mkdir(parents=True, exist_ok=True) 196 (world_dir / "style.md").write_text("Grim. Slow-burn investigation.") 197 (world_dir / "arc.md").write_text( 198 "# Campaign Arc\n\n## Premise\nA quiet mystery.\n" 199 ) 200 return "default" 201 202 203class TestLoadWorldPreferences: 204 def test_returns_empty_when_neither_exists(self): 205 assert _load_world_preferences("default", include_arc=True) == "" 206 207 def test_style_only_when_arc_excluded(self, world_with_style_and_arc): 208 out = _load_world_preferences("default", include_arc=False) 209 assert "Player Preferences" in out 210 assert "Grim. Slow-burn" in out 211 assert "Campaign Arc" not in out 212 213 def test_includes_arc_when_requested(self, world_with_style_and_arc): 214 out = _load_world_preferences("default", include_arc=True) 215 assert "Player Preferences" in out 216 assert "Campaign Arc" in out 217 218 def test_style_only_when_no_arc_on_disk(self, world_with_style): 219 out = _load_world_preferences("default", include_arc=True) 220 assert "Player Preferences" in out 221 assert "Campaign Arc" not in out 222 223 def test_ends_with_separator(self, world_with_style): 224 out = _load_world_preferences("default", include_arc=True) 225 assert out.endswith("---\n\n") 226 227 228# ---- plot_arc orchestrator -------------------------------------------------- 229 230 231@pytest.fixture 232def character_world(tmp_path: Path) -> Path: 233 create_character( 234 player_id="default", 235 name="Seren", 236 race="Half-Elf", 237 char_class="Ranger", 238 level=1, 239 abilities={ 240 "strength": 10, 241 "dexterity": 16, 242 "constitution": 12, 243 "intelligence": 14, 244 "wisdom": 13, 245 "charisma": 14, 246 }, 247 hp_max=11, 248 ac=14, 249 background="Outlander", 250 backstory="A coastal wanderer.", 251 ) 252 world_dir = paths.world_path("default") 253 world_dir.mkdir(parents=True, exist_ok=True) 254 (world_dir / "style.md").write_text("Eerie atmospheric mystery.") 255 return tmp_path 256 257 258class TestPlotArc: 259 @patch("storied.planner.start_mcp_server") 260 @patch("storied.planner.run_with_tools") 261 @patch("storied.planner.run_prompt") 262 def test_pass_a_then_pass_b( 263 self, 264 mock_run_prompt: MagicMock, 265 mock_run_with_tools: MagicMock, 266 mock_start_mcp: MagicMock, 267 character_world: Path, 268 ): 269 mock_run_prompt.return_value = "## Cold treatment\n\nTotally generic." 270 mock_run_with_tools.return_value = MagicMock( 271 usage={"input_tokens": 1000, "output_tokens": 200, "tool_calls": 1} 272 ) 273 mock_start_mcp.return_value = MagicMock(url="http://localhost:0/sse") 274 275 plot_arc(world_id="default", player_id="default") 276 277 # Pass A: cold draft via run_prompt 278 mock_run_prompt.assert_called_once() 279 assert mock_run_prompt.call_args.kwargs["effort"] == "max" 280 assert mock_run_prompt.call_args.kwargs["model"] == "claude-opus-4-7" 281 282 # Pass B: architect via run_with_tools 283 mock_run_with_tools.assert_called_once() 284 assert mock_run_with_tools.call_args.kwargs["effort"] == "max" 285 286 # Pass B uses the arc_architect MCP role 287 mock_start_mcp.assert_called_once() 288 assert mock_start_mcp.call_args[0][2] == "arc_architect" 289 290 @patch("storied.planner.start_mcp_server") 291 @patch("storied.planner.run_with_tools") 292 @patch("storied.planner.run_prompt") 293 def test_pass_a_receives_only_character_and_style( 294 self, 295 mock_run_prompt: MagicMock, 296 mock_run_with_tools: MagicMock, 297 mock_start_mcp: MagicMock, 298 character_world: Path, 299 ): 300 mock_run_prompt.return_value = "cold draft" 301 mock_run_with_tools.return_value = MagicMock(usage={}) 302 mock_start_mcp.return_value = MagicMock(url="http://localhost:0/sse") 303 304 plot_arc(world_id="default", player_id="default") 305 306 pass_a_user = mock_run_prompt.call_args.kwargs["user_message"] 307 # Pass A should NOT contain novelty injection or the cold draft itself 308 assert "Random Concept Seeds" not in pass_a_user 309 assert "Thinking Moves For This Run" not in pass_a_user 310 assert "Cold Draft" not in pass_a_user 311 # But it SHOULD contain the style and the character sheet 312 assert "Player Preferences" in pass_a_user 313 assert "Eerie atmospheric mystery" in pass_a_user 314 assert "Seren" in pass_a_user 315 316 @patch("storied.planner.start_mcp_server") 317 @patch("storied.planner.run_with_tools") 318 @patch("storied.planner.run_prompt") 319 def test_pass_b_receives_cold_draft_and_novelty_levers( 320 self, 321 mock_run_prompt: MagicMock, 322 mock_run_with_tools: MagicMock, 323 mock_start_mcp: MagicMock, 324 character_world: Path, 325 ): 326 mock_run_prompt.return_value = "## Cold draft\n\nGeneric coastal horror." 327 mock_run_with_tools.return_value = MagicMock(usage={}) 328 mock_start_mcp.return_value = MagicMock(url="http://localhost:0/sse") 329 330 plot_arc(world_id="default", player_id="default") 331 332 pass_b_user = mock_run_with_tools.call_args.kwargs["user_message"] 333 assert "## Cold Draft" in pass_b_user 334 assert "Generic coastal horror." in pass_b_user 335 assert "## Random Concept Seeds" in pass_b_user 336 assert "## Thinking Moves For This Run" in pass_b_user 337 assert "Player Preferences" in pass_b_user 338 assert "Seren" in pass_b_user 339 340 @patch("storied.planner.start_mcp_server") 341 @patch("storied.planner.run_with_tools") 342 @patch("storied.planner.run_prompt") 343 def test_pass_b_uses_arc_architect_prompt( 344 self, 345 mock_run_prompt: MagicMock, 346 mock_run_with_tools: MagicMock, 347 mock_start_mcp: MagicMock, 348 character_world: Path, 349 ): 350 mock_run_prompt.return_value = "cold draft" 351 mock_run_with_tools.return_value = MagicMock(usage={}) 352 mock_start_mcp.return_value = MagicMock(url="http://localhost:0/sse") 353 354 plot_arc(world_id="default", player_id="default") 355 356 system_prompt = mock_run_with_tools.call_args.kwargs["system_prompt"] 357 assert "Arc Architect" in system_prompt 358 359 @patch("storied.planner.run_prompt") 360 def test_no_character_returns_empty_result( 361 self, 362 mock_run_prompt: MagicMock, 363 tmp_path: Path, 364 ): 365 result = plot_arc(world_id="default", player_id="default") 366 assert result.tool_calls == 0 367 mock_run_prompt.assert_not_called() 368 369 370# ---- seed_world: arc + effort ----------------------------------------------- 371 372 373class TestSeedWorldArcAndEffort: 374 @patch("storied.planner.run_with_tools") 375 def test_seed_world_passes_effort_max( 376 self, 377 mock_run_with_tools: MagicMock, 378 character_world: Path, 379 ): 380 mock_run_with_tools.return_value = None 381 382 seed_world(world_id="default", player_id="default") 383 384 mock_run_with_tools.assert_called_once() 385 assert mock_run_with_tools.call_args.kwargs["effort"] == "max" 386 387 @patch("storied.planner.run_with_tools") 388 def test_seed_world_includes_arc_when_present( 389 self, 390 mock_run_with_tools: MagicMock, 391 character_world: Path, 392 ): 393 world_dir = paths.world_path("default") 394 (world_dir / "arc.md").write_text( 395 "# Campaign Arc\n\n## Premise\nA quiet mystery.\n" 396 ) 397 mock_run_with_tools.return_value = None 398 399 seed_world(world_id="default", player_id="default") 400 401 user_message = mock_run_with_tools.call_args.kwargs["user_message"] 402 assert "Campaign Arc" in user_message 403 assert "A quiet mystery." in user_message 404 405 @patch("storied.planner.run_with_tools") 406 def test_seed_world_includes_concept_seeds_and_obliques( 407 self, 408 mock_run_with_tools: MagicMock, 409 character_world: Path, 410 ): 411 # The seeder gets the same anti-rut levers the architect gets: 412 # random concept seeds (mandate) and a drawn hand of Oblique 413 # Strategies cards (thinking moves). 414 mock_run_with_tools.return_value = None 415 416 seed_world(world_id="default", player_id="default") 417 418 user_message = mock_run_with_tools.call_args.kwargs["user_message"] 419 assert "## Random Concept Seeds" in user_message 420 assert "## Thinking Moves For This Run" in user_message 421 assert "Oblique Strategies" in user_message 422 423 424class TestPlannerInspirationContext: 425 """The planner gets obliques only — no random concept seeds. 426 427 The planner is constrained to enriching existing entities; forcing 428 in random concept material would distort what's already there. 429 Obliques (process directives) are safe because they shape HOW the 430 model thinks rather than WHAT it adds. 431 """ 432 433 def test_planner_context_includes_obliques( 434 self, 435 character_world: Path, 436 ): 437 from storied.planner import build_planning_context 438 from storied.session import save_session 439 440 save_session( 441 "default", 442 { 443 "location": "Tavern", 444 "world": "default", 445 "body": "## Situation\nSeren is at the bar.", 446 }, 447 ) 448 449 context = build_planning_context( 450 world_id="default", 451 player_id="default", 452 candidates=[], 453 ) 454 455 assert "## Thinking Moves For This Run" in context 456 assert "Oblique Strategies" in context 457 458 def test_planner_context_does_not_include_concept_seeds( 459 self, 460 character_world: Path, 461 ): 462 from storied.planner import build_planning_context 463 from storied.session import save_session 464 465 save_session( 466 "default", 467 { 468 "location": "Tavern", 469 "world": "default", 470 "body": "## Situation\nSeren is at the bar.", 471 }, 472 ) 473 474 context = build_planning_context( 475 world_id="default", 476 player_id="default", 477 candidates=[], 478 ) 479 480 # No mandate to weave random concepts — that would distort 481 # existing entities the planner is enriching. 482 assert "## Random Concept Seeds" not in context