Something went wrong. Try again.
A 5e storytelling engine with an LLM DM
Something went wrong. Try again.
Python
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342"""Tests for world seeding — empty world detection and initial worldbuilding."""
import asyncioimport jsonfrom pathlib import Pathfrom unittest.mock import MagicMock, patch
import pytest
from storied.character import create_characterfrom storied.mcp_server import _compose_serverfrom storied.planner import SeedResult, seed_world
class TestSeederTools: """Tests for seeder tool filtering via tag-based composition."""
def _seeder_names(self) -> set[str]: async def _gather() -> set[str]: server = await _compose_server("seeder") return {t.name for t in await server.list_tools()}
return asyncio.run(_gather())
def test_seeder_has_world_building_tools(self): assert self._seeder_names() == { "establish", "set_scene", "forge_culture", "generate_names", }
def test_seeder_excludes_disallowed_tools(self): names = self._seeder_names() for forbidden in ("roll", "recall", "mark", "note_discovery", "end_session"): assert forbidden not in names
def test_seeder_does_not_have_commit_arc(self): # commit_arc is the architect's tool — never the seeder's. assert "commit_arc" not in self._seeder_names()
class TestArcArchitectTools: """The arc_architect role gets only commit_arc and recall."""
def _architect_names(self) -> set[str]: async def _gather() -> set[str]: server = await _compose_server("arc_architect") return {t.name for t in await server.list_tools()}
return asyncio.run(_gather())
def test_architect_only_has_commit_arc_and_recall(self): assert self._architect_names() == {"commit_arc", "recall"}
def test_architect_excludes_world_building_tools(self): names = self._architect_names() for forbidden in ( "establish", "set_scene", "mark", "amend_mark", "note_discovery", "tune", "damage", "heal", "adjust_coins", "create_character", "end_session", ): assert forbidden not in names
@pytest.fixturedef character_world(tmp_path: Path) -> Path: """Create a base directory with a character but no session.""" create_character( player_id="default", name="Kael Stormborn", race="Human", char_class="Fighter", level=1, abilities={ "strength": 16, "dexterity": 14, "constitution": 14, "intelligence": 10, "wisdom": 12, "charisma": 8, }, hp_max=12, ac=16, background="Soldier", purse={"gp": 50}, equipment={"on_person": ["Longsword", "Chain mail", "Shield"]}, backstory="A former soldier haunted by a battle gone wrong.", ) return tmp_path
class TestSeedWorld: """Tests for the seed_world orchestrator."""
@patch("storied.claude.subprocess.Popen") def test_seed_world_calls_claude( self, mock_popen: MagicMock, character_world: Path ): result_line = json.dumps( { "type": "result", "session_id": "sess-1", "usage": {"input_tokens": 500, "output_tokens": 100}, "duration_ms": 3000, } ) 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
seed_world( world_id="default", player_id="default", )
# Verify the subprocess was called with claude args mock_popen.assert_called_once() call_args = mock_popen.call_args args_list = call_args[0][0] if call_args[0] else call_args[1].get("args", []) # Should include --system-prompt and --mcp-config assert any("--system-prompt" in str(a) for a in args_list)
@patch("storied.claude.subprocess.Popen") def test_seed_world_counts_tool_calls( self, mock_popen: MagicMock, character_world: Path ): lines = [ json.dumps( { "type": "stream_event", "event": { "type": "content_block_start", "index": 0, "content_block": { "type": "tool_use", "id": "t1", "name": "mcp__storied__establish", }, }, } ), json.dumps( { "type": "stream_event", "event": { "type": "content_block_start", "index": 1, "content_block": { "type": "tool_use", "id": "t2", "name": "mcp__storied__set_scene", }, }, } ), json.dumps( { "type": "result", "session_id": "sess-2", "usage": {"input_tokens": 800, "output_tokens": 150}, "duration_ms": 5000, } ), ]
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 = seed_world( world_id="default", player_id="default", )
assert result.tool_calls == 2
@patch("storied.claude.subprocess.Popen") def test_seed_world_returns_result( self, mock_popen: MagicMock, character_world: Path ): result_line = json.dumps( { "type": "result", "session_id": "sess-1", "usage": {"input_tokens": 500, "output_tokens": 100}, "duration_ms": 3000, } ) 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 = seed_world( world_id="default", player_id="default", )
assert isinstance(result, SeedResult) assert result.input_tokens == 500 assert result.output_tokens == 100 assert result.elapsed > 0
@patch("storied.claude.subprocess.Popen") def test_seed_world_reports_progress( self, mock_popen: MagicMock, character_world: Path ): lines = [ json.dumps( { "type": "stream_event", "event": { "type": "content_block_start", "index": 0, "content_block": { "type": "tool_use", "id": "t1", "name": "mcp__storied__establish", }, }, } ), json.dumps( { "type": "result", "session_id": "sess-1", "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
progress_messages: list[str] = [] seed_world( world_id="default", player_id="default", on_progress=progress_messages.append, )
assert any("establish" in msg for msg in progress_messages)
def test_seed_world_no_character_returns_empty(self, tmp_path: Path): result = seed_world( world_id="default", player_id="default", ) assert isinstance(result, SeedResult) assert result.tool_calls == 0
class TestSeedWorldStyleContext: """seed_world prepends style.md as a Player Preferences block."""
@patch("storied.planner.run_with_tools") def test_seed_world_includes_style_when_present( self, mock_run: MagicMock, character_world: Path, ): from storied.paths import world_path
world_dir = world_path("default") world_dir.mkdir(parents=True, exist_ok=True) (world_dir / "style.md").write_text( "Grim political intrigue. No heroic fantasy. Slow-burn " "investigation with moral compromise." )
mock_run.return_value = None
seed_world(world_id="default", player_id="default")
mock_run.assert_called_once() user_message = mock_run.call_args.kwargs["user_message"] assert "## Player Preferences" in user_message assert "Grim political intrigue" in user_message # Preferences come before the character sheet pref_idx = user_message.index("Player Preferences") char_idx = user_message.index("Kael Stormborn") assert pref_idx < char_idx
@patch("storied.planner.run_with_tools") def test_seed_world_omits_style_block_when_absent( self, mock_run: MagicMock, character_world: Path, ): mock_run.return_value = None seed_world(world_id="default", player_id="default")
mock_run.assert_called_once() user_message = mock_run.call_args.kwargs["user_message"] assert "Player Preferences" not in user_message assert "Kael Stormborn" in user_message
@patch("storied.planner.run_with_tools") def test_seed_world_ignores_empty_style_file( self, mock_run: MagicMock, character_world: Path, ): from storied.paths import world_path
world_dir = world_path("default") world_dir.mkdir(parents=True, exist_ok=True) (world_dir / "style.md").write_text(" \n\n \n")
mock_run.return_value = None seed_world(world_id="default", player_id="default")
mock_run.assert_called_once() user_message = mock_run.call_args.kwargs["user_message"] assert "Player Preferences" not in user_message