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 341 lines 11 kB view raw
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