A 5e storytelling engine with an LLM DM
0
fork

Configure Feed

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

Split equipment table rows and add engine tests

The paragraph-break splitter as a final-resort chunking level means
individual weapon and armor entries (rapier, chain mail, etc.) now
surface in recall — they were buried in one big table chunk before.

Also adds engine unit tests (load_prompt, context building, style
injection) which gets coverage to 87%, and bumps the threshold to 85%.
Slow SRD recall tests are marked so they don't drag down the fast suite.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

+167 -5
+1 -1
pyproject.toml
··· 69 69 omit = ["src/storied/cli.py", "src/storied/srd/*"] 70 70 71 71 [tool.coverage.report] 72 - fail_under = 80 72 + fail_under = 85
+1
src/storied/search.py
··· 25 25 r"\n(?=### )", # ### headings 26 26 r"\n(?=#### )", # #### headings (class features, combat sub-topics) 27 27 r"\n(?=\*\*[A-Z])", # bold definitions (glossary entries, level features) 28 + r"\n\n", # paragraph breaks (table rows, final resort) 28 29 ] 29 30 30 31
+145
tests/test_engine.py
··· 1 + """Tests for engine helper functions and context building.""" 2 + 3 + from pathlib import Path 4 + from unittest.mock import patch 5 + 6 + import pytest 7 + 8 + from storied.engine import ( 9 + _extract_roll_reason, 10 + _tool_notification, 11 + load_prompt, 12 + ) 13 + 14 + 15 + class TestLoadPrompt: 16 + 17 + def test_loads_dm_system(self): 18 + result = load_prompt("dm-system") 19 + assert "Dungeon Master" in result 20 + 21 + def test_custom_prompts_path(self, tmp_path: Path): 22 + prompt_file = tmp_path / "test-prompt.md" 23 + prompt_file.write_text("You are a test prompt.") 24 + result = load_prompt("test-prompt", prompts_path=tmp_path) 25 + assert result == "You are a test prompt." 26 + 27 + def test_missing_prompt_raises(self, tmp_path: Path): 28 + with pytest.raises(FileNotFoundError): 29 + load_prompt("nonexistent", prompts_path=tmp_path) 30 + 31 + 32 + class TestExtractRollReason: 33 + 34 + def test_extracts_reason(self): 35 + assert _extract_roll_reason('{"reason": "Athletics"}') == "Athletics" 36 + 37 + def test_no_reason_field(self): 38 + assert _extract_roll_reason('{"notation": "1d20"}') is None 39 + 40 + def test_invalid_json(self): 41 + assert _extract_roll_reason("not json") is None 42 + 43 + def test_empty_string(self): 44 + assert _extract_roll_reason("") is None 45 + 46 + 47 + class TestToolNotification: 48 + 49 + def test_known_tool(self): 50 + assert "Rolling" in _tool_notification("roll") 51 + 52 + def test_mcp_prefixed_tool(self): 53 + assert "Establishing" in _tool_notification("mcp__storied__establish") 54 + 55 + def test_unknown_tool(self): 56 + result = _tool_notification("something_new") 57 + assert "something_new" in result 58 + 59 + def test_tune_tool(self): 60 + assert "Tuning" in _tool_notification("tune") 61 + 62 + 63 + class TestDMEngineContext: 64 + """Tests for DMEngine context building (mocks MCP server startup).""" 65 + 66 + @pytest.fixture(autouse=True) 67 + def _mock_terminal(self): 68 + import os 69 + size = os.terminal_size((120, 40)) 70 + with patch("storied.engine.os.get_terminal_size", return_value=size): 71 + yield 72 + 73 + @pytest.fixture 74 + def engine(self, tmp_path: Path): 75 + from storied.engine import DMEngine 76 + 77 + world_dir = tmp_path / "worlds" / "test" 78 + world_dir.mkdir(parents=True) 79 + 80 + prompts_dir = tmp_path / "prompts" 81 + prompts_dir.mkdir() 82 + (prompts_dir / "dm-system.md").write_text("You are a DM.") 83 + 84 + with patch("storied.engine.start_mcp_server") as mock_mcp: 85 + from storied.tools import EntityIndex 86 + 87 + mock_mcp.return_value = type("Handle", (), { 88 + "url": "http://localhost:0/sse", 89 + "ctx": type("Ctx", (), { 90 + "entity_index": EntityIndex(world_dir), 91 + "vector_index": None, 92 + })(), 93 + })() 94 + return DMEngine( 95 + world_id="test", 96 + player_id="default", 97 + base_path=tmp_path, 98 + prompt_name="dm-system", 99 + ) 100 + 101 + def test_build_context_no_style(self, engine): 102 + context = engine._build_context() 103 + assert "Style" not in engine._context_parts 104 + 105 + def test_build_context_with_style(self, engine): 106 + style_path = engine.base_path / "worlds" / "test" / "style.md" 107 + style_path.write_text("# Style\n\nMore intrigue, less combat.\n") 108 + 109 + context = engine._build_context() 110 + 111 + assert "Style" in engine._context_parts 112 + assert "intrigue" in engine._context_parts["Style"] 113 + 114 + def test_style_is_first_context_part(self, engine): 115 + style_path = engine.base_path / "worlds" / "test" / "style.md" 116 + style_path.write_text("# Style\n\nDark tone.\n") 117 + 118 + context = engine._build_context() 119 + parts = list(engine._context_parts.keys()) 120 + 121 + assert parts[0] == "Style" 122 + 123 + def test_estimate_tokens(self): 124 + from storied.engine import DMEngine 125 + assert DMEngine._estimate_tokens("a" * 400) == 100 126 + 127 + def test_format_entity(self, engine): 128 + result = engine._format_entity("Npc", { 129 + "name": "Vera", "body": "Tavern owner.", 130 + }) 131 + assert "## Npc: Vera" in result 132 + assert "Tavern owner." in result 133 + 134 + def test_parse_knowledge_file_no_frontmatter(self, engine, tmp_path: Path): 135 + f = tmp_path / "note.md" 136 + f.write_text("Just some text.") 137 + result = engine._parse_knowledge_file(f) 138 + assert result["body"] == "Just some text." 139 + 140 + def test_parse_knowledge_file_with_frontmatter(self, engine, tmp_path: Path): 141 + f = tmp_path / "note.md" 142 + f.write_text("---\ntype: npc\nname: Vera\n---\n\nTavern owner.") 143 + result = engine._parse_knowledge_file(f) 144 + assert result["name"] == "Vera" 145 + assert result["body"] == "Tavern owner."
+6 -4
tests/test_search.py
··· 124 124 assert any("Second Wind" in t for t in texts) 125 125 assert any("Action Surge" in t for t in texts) 126 126 127 - def test_oversized_with_no_splittable_headings(self): 128 - content = "# Blob\n\n" + "word " * 1_000 127 + def test_oversized_with_no_headings_splits_on_paragraphs(self): 128 + content = "# Blob\n\n" + "\n\n".join( 129 + "word " * 200 for _ in range(5) 130 + ) 129 131 chunks = chunk_document(Path("blob.md"), content) 130 - assert len(chunks) >= 1 131 - assert "word" in chunks[0][1] 132 + assert len(chunks) > 1 133 + assert any("word" in text for _, text in chunks) 132 134 133 135 def test_chunks_get_title_context(self): 134 136 content = "# Rogue\n\n" + "\n".join(
+14
tests/test_srd_recall.py
··· 119 119 _assert_any_hit_contains( 120 120 srd_index, "concentration spell", "Concentration", 121 121 ) 122 + 123 + 124 + # --- Equipment --- 125 + 126 + class TestEquipmentRecall: 127 + 128 + def test_rapier(self, srd_index: VectorIndex): 129 + _assert_any_hit_contains(srd_index, "rapier", "Rapier") 130 + 131 + def test_chain_mail(self, srd_index: VectorIndex): 132 + _assert_any_hit_contains(srd_index, "chain mail armor", "Chain Mail") 133 + 134 + def test_shield(self, srd_index: VectorIndex): 135 + _assert_any_hit_contains(srd_index, "shield", "Shield")