A 5e storytelling engine with an LLM DM
1"""Tests for world seeding — empty world detection and initial worldbuilding."""
2
3import asyncio
4import json
5from pathlib import Path
6from unittest.mock import MagicMock, patch
7
8import pytest
9
10from storied.character import create_character
11from storied.mcp_server import _compose_server
12from storied.planner import SeedResult, seed_world
13
14
15class TestSeederTools:
16 """Tests for seeder tool filtering via tag-based composition."""
17
18 def _seeder_names(self) -> set[str]:
19 async def _gather() -> set[str]:
20 server = await _compose_server("seeder")
21 return {t.name for t in await server.list_tools()}
22
23 return asyncio.run(_gather())
24
25 def test_seeder_has_world_building_tools(self):
26 assert self._seeder_names() == {
27 "establish",
28 "set_scene",
29 "forge_culture",
30 "generate_names",
31 }
32
33 def test_seeder_excludes_disallowed_tools(self):
34 names = self._seeder_names()
35 for forbidden in ("roll", "recall", "mark", "note_discovery", "end_session"):
36 assert forbidden not in names
37
38 def test_seeder_does_not_have_commit_arc(self):
39 # commit_arc is the architect's tool — never the seeder's.
40 assert "commit_arc" not in self._seeder_names()
41
42
43class TestArcArchitectTools:
44 """The arc_architect role gets only commit_arc and recall."""
45
46 def _architect_names(self) -> set[str]:
47 async def _gather() -> set[str]:
48 server = await _compose_server("arc_architect")
49 return {t.name for t in await server.list_tools()}
50
51 return asyncio.run(_gather())
52
53 def test_architect_only_has_commit_arc_and_recall(self):
54 assert self._architect_names() == {"commit_arc", "recall"}
55
56 def test_architect_excludes_world_building_tools(self):
57 names = self._architect_names()
58 for forbidden in (
59 "establish",
60 "set_scene",
61 "mark",
62 "amend_mark",
63 "note_discovery",
64 "tune",
65 "damage",
66 "heal",
67 "adjust_coins",
68 "create_character",
69 "end_session",
70 ):
71 assert forbidden not in names
72
73
74@pytest.fixture
75def character_world(tmp_path: Path) -> Path:
76 """Create a base directory with a character but no session."""
77 create_character(
78 player_id="default",
79 name="Kael Stormborn",
80 race="Human",
81 char_class="Fighter",
82 level=1,
83 abilities={
84 "strength": 16,
85 "dexterity": 14,
86 "constitution": 14,
87 "intelligence": 10,
88 "wisdom": 12,
89 "charisma": 8,
90 },
91 hp_max=12,
92 ac=16,
93 background="Soldier",
94 purse={"gp": 50},
95 equipment={"on_person": ["Longsword", "Chain mail", "Shield"]},
96 backstory="A former soldier haunted by a battle gone wrong.",
97 )
98 return tmp_path
99
100
101class TestSeedWorld:
102 """Tests for the seed_world orchestrator."""
103
104 @patch("storied.claude.subprocess.Popen")
105 def test_seed_world_calls_claude(
106 self, mock_popen: MagicMock, character_world: Path
107 ):
108 result_line = json.dumps(
109 {
110 "type": "result",
111 "session_id": "sess-1",
112 "usage": {"input_tokens": 500, "output_tokens": 100},
113 "duration_ms": 3000,
114 }
115 )
116 mock_proc = MagicMock()
117 mock_proc.stdin = MagicMock()
118 mock_proc.stdout = iter([result_line.encode() + b"\n"])
119 mock_proc.stderr = iter([])
120 mock_proc.wait.return_value = 0
121 mock_proc.returncode = 0
122 mock_popen.return_value = mock_proc
123
124 seed_world(
125 world_id="default",
126 player_id="default",
127 )
128
129 # Verify the subprocess was called with claude args
130 mock_popen.assert_called_once()
131 call_args = mock_popen.call_args
132 args_list = call_args[0][0] if call_args[0] else call_args[1].get("args", [])
133 # Should include --system-prompt and --mcp-config
134 assert any("--system-prompt" in str(a) for a in args_list)
135
136 @patch("storied.claude.subprocess.Popen")
137 def test_seed_world_counts_tool_calls(
138 self, mock_popen: MagicMock, character_world: Path
139 ):
140 lines = [
141 json.dumps(
142 {
143 "type": "stream_event",
144 "event": {
145 "type": "content_block_start",
146 "index": 0,
147 "content_block": {
148 "type": "tool_use",
149 "id": "t1",
150 "name": "mcp__storied__establish",
151 },
152 },
153 }
154 ),
155 json.dumps(
156 {
157 "type": "stream_event",
158 "event": {
159 "type": "content_block_start",
160 "index": 1,
161 "content_block": {
162 "type": "tool_use",
163 "id": "t2",
164 "name": "mcp__storied__set_scene",
165 },
166 },
167 }
168 ),
169 json.dumps(
170 {
171 "type": "result",
172 "session_id": "sess-2",
173 "usage": {"input_tokens": 800, "output_tokens": 150},
174 "duration_ms": 5000,
175 }
176 ),
177 ]
178
179 mock_proc = MagicMock()
180 mock_proc.stdin = MagicMock()
181 mock_proc.stdout = iter([line.encode() + b"\n" for line in lines])
182 mock_proc.stderr = iter([])
183 mock_proc.wait.return_value = 0
184 mock_proc.returncode = 0
185 mock_popen.return_value = mock_proc
186
187 result = seed_world(
188 world_id="default",
189 player_id="default",
190 )
191
192 assert result.tool_calls == 2
193
194 @patch("storied.claude.subprocess.Popen")
195 def test_seed_world_returns_result(
196 self, mock_popen: MagicMock, character_world: Path
197 ):
198 result_line = json.dumps(
199 {
200 "type": "result",
201 "session_id": "sess-1",
202 "usage": {"input_tokens": 500, "output_tokens": 100},
203 "duration_ms": 3000,
204 }
205 )
206 mock_proc = MagicMock()
207 mock_proc.stdin = MagicMock()
208 mock_proc.stdout = iter([result_line.encode() + b"\n"])
209 mock_proc.stderr = iter([])
210 mock_proc.wait.return_value = 0
211 mock_proc.returncode = 0
212 mock_popen.return_value = mock_proc
213
214 result = seed_world(
215 world_id="default",
216 player_id="default",
217 )
218
219 assert isinstance(result, SeedResult)
220 assert result.input_tokens == 500
221 assert result.output_tokens == 100
222 assert result.elapsed > 0
223
224 @patch("storied.claude.subprocess.Popen")
225 def test_seed_world_reports_progress(
226 self, mock_popen: MagicMock, character_world: Path
227 ):
228 lines = [
229 json.dumps(
230 {
231 "type": "stream_event",
232 "event": {
233 "type": "content_block_start",
234 "index": 0,
235 "content_block": {
236 "type": "tool_use",
237 "id": "t1",
238 "name": "mcp__storied__establish",
239 },
240 },
241 }
242 ),
243 json.dumps(
244 {
245 "type": "result",
246 "session_id": "sess-1",
247 "usage": {"input_tokens": 800, "output_tokens": 100},
248 "duration_ms": 3000,
249 }
250 ),
251 ]
252
253 mock_proc = MagicMock()
254 mock_proc.stdin = MagicMock()
255 mock_proc.stdout = iter([line.encode() + b"\n" for line in lines])
256 mock_proc.stderr = iter([])
257 mock_proc.wait.return_value = 0
258 mock_proc.returncode = 0
259 mock_popen.return_value = mock_proc
260
261 progress_messages: list[str] = []
262 seed_world(
263 world_id="default",
264 player_id="default",
265 on_progress=progress_messages.append,
266 )
267
268 assert any("establish" in msg for msg in progress_messages)
269
270 def test_seed_world_no_character_returns_empty(self, tmp_path: Path):
271 result = seed_world(
272 world_id="default",
273 player_id="default",
274 )
275 assert isinstance(result, SeedResult)
276 assert result.tool_calls == 0
277
278
279class TestSeedWorldStyleContext:
280 """seed_world prepends style.md as a Player Preferences block."""
281
282 @patch("storied.planner.run_with_tools")
283 def test_seed_world_includes_style_when_present(
284 self,
285 mock_run: MagicMock,
286 character_world: Path,
287 ):
288 from storied.paths import world_path
289
290 world_dir = world_path("default")
291 world_dir.mkdir(parents=True, exist_ok=True)
292 (world_dir / "style.md").write_text(
293 "Grim political intrigue. No heroic fantasy. Slow-burn "
294 "investigation with moral compromise."
295 )
296
297 mock_run.return_value = None
298
299 seed_world(world_id="default", player_id="default")
300
301 mock_run.assert_called_once()
302 user_message = mock_run.call_args.kwargs["user_message"]
303 assert "## Player Preferences" in user_message
304 assert "Grim political intrigue" in user_message
305 # Preferences come before the character sheet
306 pref_idx = user_message.index("Player Preferences")
307 char_idx = user_message.index("Kael Stormborn")
308 assert pref_idx < char_idx
309
310 @patch("storied.planner.run_with_tools")
311 def test_seed_world_omits_style_block_when_absent(
312 self,
313 mock_run: MagicMock,
314 character_world: Path,
315 ):
316 mock_run.return_value = None
317 seed_world(world_id="default", player_id="default")
318
319 mock_run.assert_called_once()
320 user_message = mock_run.call_args.kwargs["user_message"]
321 assert "Player Preferences" not in user_message
322 assert "Kael Stormborn" in user_message
323
324 @patch("storied.planner.run_with_tools")
325 def test_seed_world_ignores_empty_style_file(
326 self,
327 mock_run: MagicMock,
328 character_world: Path,
329 ):
330 from storied.paths import world_path
331
332 world_dir = world_path("default")
333 world_dir.mkdir(parents=True, exist_ok=True)
334 (world_dir / "style.md").write_text(" \n\n \n")
335
336 mock_run.return_value = None
337 seed_world(world_id="default", player_id="default")
338
339 mock_run.assert_called_once()
340 user_message = mock_run.call_args.kwargs["user_message"]
341 assert "Player Preferences" not in user_message