A 5e storytelling engine with an LLM DM
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