A 5e storytelling engine with an LLM DM
0
fork

Configure Feed

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

Add self-tunable DM style via tune tool

The DM can now persist storytelling preferences (pacing, tone, combat
frequency, narrative focus) to a world-level style.md that gets injected
into the system prompt. Players give feedback through /dm and the DM
decides whether it's table talk or something worth persisting — it can
also self-tune if it notices the player gravitating toward certain kinds
of play.

Also bumps coverage threshold from 48% to 70% with tests for execute_tool
dispatch, recall, note_discovery, end_session, and the planner/seeder
tool restrictions.

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

+355 -2
+9
prompts/dm-system.md
··· 14 14 | `note_discovery` | Record what the player learned | 15 15 | `update_character` | Modify character stats (HP, gold, equipment) | 16 16 | `create_character` | Create a new character | 17 + | `tune` | Update your style/personality tuning based on player feedback | 17 18 | `end_session` | Gracefully end the session | 18 19 19 20 ## After Every Response: Call `set_scene` ··· 437 438 threads=["Find the missing villagers", "The merchant's mysterious cargo - 50gp reward"] 438 439 ) 439 440 ``` 441 + 442 + ## Tuning Your Style 443 + 444 + You can adjust your storytelling style with the `tune` tool. Call it when: 445 + - The player gives explicit feedback (via `/dm`) 446 + - You notice the player consistently gravitating toward or away from certain kinds of play (e.g., skipping combat, seeking out NPCs) 447 + 448 + Read your current style from context (the "Style" section, if present), integrate new observations, and write the full replacement. Don't discard preferences the player hasn't contradicted. Acknowledge explicit feedback briefly; for self-tuning, no announcement needed. 440 449 441 450 ## Duration Guidelines 442 451
+1 -1
pyproject.toml
··· 66 66 branch = true 67 67 68 68 [tool.coverage.report] 69 - fail_under = 48 69 + fail_under = 70
+6 -1
src/storied/cli.py
··· 472 472 if not ooc_msg: 473 473 console.print("[dim]Usage: /dm <message>[/dim]") 474 474 continue 475 - action = f"[Out-of-character from the player: {ooc_msg}]" 475 + action = ( 476 + "[Out-of-character from the player. If this is style " 477 + "feedback (pacing, tone, narrative focus, combat frequency, " 478 + "etc.), use the tune tool to persist it. Message: " 479 + f"{ooc_msg}]" 480 + ) 476 481 477 482 # Handle /note command (add a note to the character sheet) 478 483 if action.strip().lower().startswith("/note"):
+9
src/storied/engine.py
··· 62 62 "establish": "Establishing", 63 63 "mark": "Recording", 64 64 "note_discovery": "Noting discovery", 65 + "tune": "Tuning style", 65 66 "end_session": "Saving session", 66 67 } 67 68 label = labels.get(short, short) ··· 150 151 """ 151 152 parts = [] 152 153 self._context_parts = {} 154 + 155 + # 0. DM style tuning (player preferences for pacing, tone, focus) 156 + if self.world_id: 157 + style_path = self.base_path / "worlds" / self.world_id / "style.md" 158 + if style_path.exists(): 159 + style_content = style_path.read_text().strip() 160 + self._context_parts["Style"] = style_content 161 + parts.append(style_content) 153 162 154 163 # 1. Character sheet 155 164 character = load_character(self.player_id, self.base_path)
+29
src/storied/tools.py
··· 664 664 return f"Noted: player learned about '{entity}'" 665 665 666 666 667 + def tune(tuning: str, ctx: ToolContext) -> str: 668 + """Update your storytelling style based on player feedback. 669 + 670 + Write the complete updated style as markdown prose. This replaces the 671 + entire current style. Incorporate existing preferences where they still 672 + apply — don't discard preferences the player hasn't contradicted. 673 + """ 674 + path = ctx.base_path / "worlds" / ctx.world_id / "style.md" 675 + path.write_text(f"# Style\n\n{tuning}\n") 676 + return "Style updated." 677 + 678 + 667 679 def end_session(situation: str, ctx: ToolContext, threads: list[str] | None = None) -> str: 668 680 """End the current session, saving the game state for next time. 669 681 ··· 930 942 }, 931 943 }, 932 944 { 945 + "name": "tune", 946 + "description": tune.__doc__, 947 + "input_schema": { 948 + "type": "object", 949 + "properties": { 950 + "tuning": { 951 + "type": "string", 952 + "description": "Complete updated style as markdown prose. Replaces the current style entirely.", 953 + }, 954 + }, 955 + "required": ["tuning"], 956 + }, 957 + }, 958 + { 933 959 "name": "end_session", 934 960 "description": end_session.__doc__, 935 961 "input_schema": { ··· 1034 1060 content_type=tool_input.get("content_type", "lore"), 1035 1061 tags=tool_input.get("tags"), 1036 1062 ) 1063 + 1064 + elif tool_name == "tune": 1065 + return tune(tuning=tool_input["tuning"], ctx=ctx) 1037 1066 1038 1067 elif tool_name == "end_session": 1039 1068 return end_session(
+252
tests/test_execute_tool.py
··· 1 + """Tests for execute_tool dispatch and uncovered tool functions.""" 2 + 3 + from storied.tools import ( 4 + ToolContext, 5 + _auto_mark_present, 6 + end_session, 7 + establish, 8 + execute_tool, 9 + note_discovery, 10 + planner_execute_tool, 11 + recall, 12 + seeder_execute_tool, 13 + ) 14 + 15 + 16 + class TestExecuteToolDispatch: 17 + """Tests that execute_tool routes to the correct tool.""" 18 + 19 + def test_roll_with_modifier(self, ctx: ToolContext): 20 + result = execute_tool("roll", {"notation": "1d20+5", "reason": "attack"}, ctx) 21 + 22 + assert "Rolled" in result 23 + assert "1d20+5" in result 24 + 25 + def test_roll_no_modifier(self, ctx: ToolContext): 26 + result = execute_tool("roll", {"notation": "1d6", "reason": "damage"}, ctx) 27 + 28 + assert "Rolled" in result 29 + 30 + def test_recall(self, ctx: ToolContext): 31 + result = execute_tool("recall", {"query": "nonexistent"}, ctx) 32 + 33 + assert "Nothing found" in result 34 + 35 + def test_update_character(self, ctx: ToolContext): 36 + # Create character first 37 + execute_tool("create_character", { 38 + "name": "Test", "race": "Human", "char_class": "Fighter", 39 + "level": 1, "abilities": { 40 + "strength": 16, "dexterity": 12, "constitution": 14, 41 + "intelligence": 10, "wisdom": 13, "charisma": 8, 42 + }, 43 + "hp_max": 12, "ac": 16, 44 + }, ctx) 45 + 46 + result = execute_tool( 47 + "update_character", {"updates": {"hp.current": 8}}, ctx, 48 + ) 49 + 50 + assert "updated" in result.lower() 51 + 52 + def test_set_scene(self, ctx: ToolContext): 53 + result = execute_tool("set_scene", { 54 + "event": "Spoke with guards", "duration": "10 min", 55 + }, ctx) 56 + 57 + assert result 58 + 59 + def test_establish(self, ctx: ToolContext): 60 + result = execute_tool("establish", { 61 + "entity_type": "npcs", "name": "Test NPC", 62 + }, ctx) 63 + 64 + assert "Established" in result 65 + 66 + def test_mark(self, ctx: ToolContext): 67 + execute_tool("establish", { 68 + "entity_type": "npcs", "name": "Vera", 69 + }, ctx) 70 + result = execute_tool("mark", { 71 + "entity_type": "npcs", "name": "Vera", 72 + "event": "Revealed her secret", 73 + }, ctx) 74 + 75 + assert "Marked" in result 76 + 77 + def test_note_discovery(self, ctx: ToolContext): 78 + result = execute_tool("note_discovery", { 79 + "entity": "Vera Blackwater", 80 + "content": "She used to be a smuggler", 81 + }, ctx) 82 + 83 + assert "Noted" in result 84 + 85 + def test_end_session(self, ctx: ToolContext): 86 + result = execute_tool("end_session", { 87 + "situation": "In the tavern", 88 + }, ctx) 89 + 90 + assert result == "SESSION_ENDED" 91 + 92 + def test_unknown_tool(self, ctx: ToolContext): 93 + result = execute_tool("nonexistent", {}, ctx) 94 + 95 + assert "Unknown tool" in result 96 + 97 + 98 + class TestNoteDiscovery: 99 + """Tests for the note_discovery tool.""" 100 + 101 + def test_creates_knowledge_file(self, ctx: ToolContext): 102 + note_discovery("The Rusty Anchor", "A seedy tavern on the docks", ctx) 103 + 104 + knowledge_dir = ( 105 + ctx.base_path / "players" / ctx.player_id / "worlds" 106 + / ctx.world_id / "lore" 107 + ) 108 + assert any(knowledge_dir.iterdir()) 109 + 110 + def test_with_content_type(self, ctx: ToolContext): 111 + note_discovery( 112 + "Vera", "Tavern owner", ctx, content_type="npcs", 113 + ) 114 + 115 + knowledge_dir = ( 116 + ctx.base_path / "players" / ctx.player_id / "worlds" 117 + / ctx.world_id / "npcs" 118 + ) 119 + assert any(knowledge_dir.iterdir()) 120 + 121 + def test_with_tags(self, ctx: ToolContext): 122 + note_discovery( 123 + "Old Map", "Shows a hidden passage", ctx, tags=["quest"], 124 + ) 125 + 126 + knowledge_dir = ( 127 + ctx.base_path / "players" / ctx.player_id / "worlds" 128 + / ctx.world_id / "lore" 129 + ) 130 + content = next(knowledge_dir.iterdir()).read_text() 131 + assert "quest" in content 132 + 133 + 134 + class TestEndSession: 135 + """Tests for the end_session tool.""" 136 + 137 + def test_returns_session_ended(self, ctx: ToolContext): 138 + result = end_session("In the tavern", ctx) 139 + 140 + assert result == "SESSION_ENDED" 141 + 142 + def test_with_threads(self, ctx: ToolContext): 143 + result = end_session( 144 + "In the tavern", ctx, 145 + threads=["Find the merchant", "Investigate the warehouse"], 146 + ) 147 + 148 + assert result == "SESSION_ENDED" 149 + 150 + 151 + class TestPlannerExecuteTool: 152 + """Tests for planner tool restriction.""" 153 + 154 + def test_allows_recall(self, ctx: ToolContext): 155 + result = planner_execute_tool("recall", {"query": "test"}, ctx) 156 + 157 + assert "Nothing found" in result 158 + 159 + def test_rejects_set_scene(self, ctx: ToolContext): 160 + result = planner_execute_tool("set_scene", { 161 + "event": "test", "duration": "1 min", 162 + }, ctx) 163 + 164 + assert "not available" in result 165 + 166 + 167 + class TestRecall: 168 + """Tests for the recall tool with indexed content.""" 169 + 170 + def test_recall_finds_indexed_entity(self, ctx: ToolContext): 171 + entity_dir = ctx.base_path / "worlds" / ctx.world_id / "npcs" 172 + entity_dir.mkdir(parents=True, exist_ok=True) 173 + entity_file = entity_dir / "Vera Blackwater.md" 174 + entity_file.write_text("# Vera Blackwater\n\nTavern owner.\n") 175 + 176 + ctx.vector_index.upsert( 177 + "world:npcs/Vera Blackwater.md:0", 178 + "Vera Blackwater. Tavern owner, former smuggler.", 179 + {"source": "world", "content_type": "npcs", 180 + "path": str(entity_file), "title": "Vera Blackwater"}, 181 + ) 182 + 183 + result = recall("Vera Blackwater", ctx) 184 + 185 + assert "Vera Blackwater" in result 186 + 187 + def test_recall_rules_scope(self, ctx: ToolContext): 188 + result = recall("fireball", ctx, scope="rules") 189 + 190 + assert "Nothing found" in result 191 + 192 + def test_recall_world_scope(self, ctx: ToolContext): 193 + result = recall("something", ctx, scope="world") 194 + 195 + assert "Nothing found" in result 196 + 197 + def test_recall_multiple_hits(self, ctx: ToolContext): 198 + for i in range(3): 199 + ctx.vector_index.upsert( 200 + f"world:npcs/npc{i}.md:0", 201 + f"NPC number {i} who hangs around the docks", 202 + {"source": "world", "content_type": "npcs", 203 + "path": f"/fake/npc{i}.md", "title": f"NPC {i}"}, 204 + ) 205 + 206 + result = recall("docks NPC", ctx) 207 + 208 + assert "Found" in result or "Nothing found" in result 209 + 210 + 211 + class TestAutoMarkPresent: 212 + """Tests for _auto_mark_present helper.""" 213 + 214 + def test_marks_present_entities(self, ctx: ToolContext): 215 + establish(entity_type="npcs", name="Vera", ctx=ctx, 216 + description="Tavern owner.") 217 + 218 + marked = _auto_mark_present( 219 + ["[[Vera]]"], "Witnessed the fight", ctx, 220 + ) 221 + 222 + assert "Vera" in marked 223 + content = (ctx.base_path / "worlds" / ctx.world_id / "npcs/Vera.md").read_text() 224 + assert "Witnessed the fight" in content 225 + 226 + def test_skips_non_wikilinks(self, ctx: ToolContext): 227 + marked = _auto_mark_present(["plain text"], "event", ctx) 228 + 229 + assert marked == [] 230 + 231 + def test_skips_unknown_entities(self, ctx: ToolContext): 232 + marked = _auto_mark_present(["[[Nobody]]"], "event", ctx) 233 + 234 + assert marked == [] 235 + 236 + 237 + class TestSeederExecuteTool: 238 + """Tests for seeder tool restriction.""" 239 + 240 + def test_allows_establish(self, ctx: ToolContext): 241 + result = seeder_execute_tool("establish", { 242 + "entity_type": "npcs", "name": "Test", 243 + }, ctx) 244 + 245 + assert "Established" in result 246 + 247 + def test_rejects_mark(self, ctx: ToolContext): 248 + result = seeder_execute_tool("mark", { 249 + "entity_type": "npcs", "name": "Test", "event": "test", 250 + }, ctx) 251 + 252 + assert "not available" in result
+49
tests/test_tune.py
··· 1 + """Tests for the DM style tuning system.""" 2 + 3 + from storied.tools import ToolContext, execute_tool, tune 4 + 5 + 6 + class TestTune: 7 + """Tests for the tune tool.""" 8 + 9 + def test_tune_creates_style_file(self, ctx: ToolContext): 10 + tune("Lean into intrigue and social encounters.", ctx) 11 + 12 + style_path = ctx.base_path / "worlds" / ctx.world_id / "style.md" 13 + assert style_path.exists() 14 + 15 + def test_tune_writes_content(self, ctx: ToolContext): 16 + tune("More exploration, less combat.", ctx) 17 + 18 + content = (ctx.base_path / "worlds" / ctx.world_id / "style.md").read_text() 19 + assert "More exploration, less combat." in content 20 + 21 + def test_tune_has_heading(self, ctx: ToolContext): 22 + tune("Keep pacing slow.", ctx) 23 + 24 + content = (ctx.base_path / "worlds" / ctx.world_id / "style.md").read_text() 25 + assert content.startswith("# Style\n") 26 + 27 + def test_tune_replaces_existing(self, ctx: ToolContext): 28 + tune("Lots of combat.", ctx) 29 + tune("Actually, less combat.", ctx) 30 + 31 + content = (ctx.base_path / "worlds" / ctx.world_id / "style.md").read_text() 32 + assert "Actually, less combat." in content 33 + assert "Lots of combat." not in content 34 + 35 + def test_tune_returns_confirmation(self, ctx: ToolContext): 36 + result = tune("Dark and atmospheric tone.", ctx) 37 + 38 + assert "updated" in result.lower() 39 + 40 + 41 + class TestExecuteToolTune: 42 + """Tests for tune dispatch through execute_tool.""" 43 + 44 + def test_execute_tune(self, ctx: ToolContext): 45 + result = execute_tool("tune", {"tuning": "More social encounters."}, ctx) 46 + 47 + assert "updated" in result.lower() 48 + content = (ctx.base_path / "worlds" / ctx.world_id / "style.md").read_text() 49 + assert "More social encounters." in content