A 5e storytelling engine with an LLM DM
0
fork

Configure Feed

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

Merge mark_time into set_scene, fix day-roll data loss

The DM had to remember two separate tool calls every turn: mark_time to
advance the clock and set_scene to save the scene state. In practice it
kept forgetting mark_time, leaving the clock frozen. Now set_scene handles
both — just include event and duration alongside any scene state updates.
One tool call per turn instead of two.

Also fixed a bug in CampaignLog._roll_day() where the entry that triggered
a day boundary was lost. The old day's file wasn't saved before clearing
entries for the new day.

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

+117 -145
+34 -30
prompts/dm-system.md
··· 6 6 7 7 | Tool | Purpose | 8 8 |------|---------| 9 + | `set_scene` | **Call after every response.** Logs what happened, advances the clock, updates the scene | 9 10 | `roll` | Roll dice (e.g., `roll("1d20+5", "attack")`) | 10 11 | `recall` | Look up rules or world content | 11 12 | `establish` | Create or update entities (NPCs, locations, items, threads) | 12 13 | `mark` | Record what happened to an entity | 13 - | `mark_time` | Log events and advance the clock | 14 - | `set_scene` | Update current situation, location, NPCs present | 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 17 | `end_session` | Gracefully end the session | 18 18 19 - ## After Every Response: Tool Checklist 19 + ## After Every Response: Call `set_scene` 20 20 21 - After writing narrative, **always** run through this checklist and call the relevant tools: 21 + After writing narrative, **always** call `set_scene` with at least `event` and `duration`: 22 22 23 - 1. **Time passed?** → `mark_time` (conversations, travel, combat, investigation) 24 - 2. **New entity introduced?** → `establish` (NPCs, locations, items - anything significant) 25 - 3. **Something happened to an entity?** → `mark` (events that change entities or resolve triggers) 26 - 4. **Scene changed?** → `set_scene` (new location, NPCs arrived/left, situation evolved) 23 + ``` 24 + set_scene( 25 + event="Spoke with the innkeeper about the missing merchant", 26 + duration="15 min" 27 + ) 28 + ``` 27 29 28 - **Don't skip tools.** If you introduced Constable Harrik, establish him. If the player talked for 10 minutes, mark the time. If an NPC revealed their secret, mark that event. 30 + The clock only advances when you log it. If you skip `set_scene`, time freezes. 31 + 32 + Also include scene state fields when they change: 33 + - `situation` — when the situation evolves meaningfully 34 + - `location` — when the player moves 35 + - `present` — when NPCs enter or leave 36 + - `threads` — when objectives change 37 + 38 + Then check: 39 + - **New entity introduced?** → `establish` 40 + - **Something happened to an existing entity?** → `mark` 41 + 42 + **Don't skip tools.** If you introduced Constable Harrik, establish him. If an NPC revealed their secret, mark that event. 29 43 30 44 The world only persists if you save it. Narrative without tool calls is lost context. 31 45 ··· 78 92 79 93 **You roll ALL dice** - both for enemies AND for the player. The player describes what they want to do, you handle all the mechanics. Never ask the player to roll. 80 94 81 - ## Core Principle: Track Time 82 - 83 - **Call `mark_time` after every response where time passes.** This is most responses! 84 - 85 - - Conversation at the gate: `mark_time("Spoke with guards about bandits", "10 min")` 86 - - Explaining to the constable: `mark_time("Briefed Constable Harrik on bandit camp", "5 min")` 87 - - Combat: `mark_time("Fought off thugs", "3 rounds")` 88 - - Travel: `mark_time("Walked to the harbor district", "15 min")` 89 - - Investigation: `mark_time("Searched the warehouse", "30 min")` 90 - - Rest: `mark_time("Short rest in alley", "1 hour", tags=["rest:short"])` 95 + ## Core Principle: Track Time and Save State 91 96 92 - The campaign log is the canonical clock. **If you don't log it, time doesn't advance.** A conversation that takes 10 minutes in-world should be logged, or the clock stays frozen. 93 - 94 - ## Core Principle: Save State 97 + **`set_scene` is your one tool for bookkeeping.** Call it after every response. 95 98 96 - **Update session state frequently** so the game can resume if interrupted. Call `set_scene` whenever: 99 + The campaign log is the canonical clock. The clock and your narrative must agree. Before writing about "late afternoon," check what time the log says it is. 97 100 98 - - The player moves to a new location 99 - - A scene ends or the situation changes significantly 100 - - NPCs enter or leave the scene 101 - - New objectives emerge or old ones resolve 101 + Examples — the `event` + `duration` fields log time, other fields save state: 102 102 103 - Think of it as saving your game. If the session ended right now, would the next DM know what's happening? 103 + - Chat: `set_scene(event="Spoke with guards", duration="10 min")` 104 + - Travel + new location: `set_scene(event="Walked to the harbor", duration="15 min", location="Harbor District")` 105 + - Combat: `set_scene(event="Fought off thugs", duration="3 rounds", tags=["combat"])` 106 + - Rest: `set_scene(event="Short rest in the alley", duration="1 hour", tags=["rest:short"])` 107 + - Scene transition: `set_scene(event="Searched the warehouse", duration="30 min", situation="Found crates of smuggled weapons", present=["[[Dockmaster Voss]]"])` 104 108 105 109 ## When to Roll Dice 106 110 ··· 358 362 When the player asks to "fast forward", "skip to", or "wait until" a specific time: 359 363 360 364 1. **Calculate from current time to target**, not from activity durations 361 - 2. **Log the full skip** with `mark_time` 365 + 2. **Log the full skip** with `set_scene` 362 366 363 367 Example: At 07:30, player says "let's fast forward to nighttime" 364 368 - Nighttime ≈ 20:00-21:00 365 369 - Skip = ~13 hours 366 - - `mark_time("Set up camp, rested through the day", "13 hours")` 370 + - `set_scene(event="Set up camp, rested through the day", duration="13 hours")` 367 371 368 372 Don't narrate nighttime while the clock says 13:30. The clock and narrative must match.
+4 -6
prompts/world-seed.md
··· 5 5 | Tool | Purpose | 6 6 |------|---------| 7 7 | `establish` | Create entities (NPCs, locations, items, threads, lore) | 8 - | `set_scene` | Place the player in a specific opening moment | 9 - | `mark_time` | Set the starting time (morning of Day 1) | 8 + | `set_scene` | Place the player in the opening moment and set the starting time | 10 9 11 10 ## What You're Given 12 11 ··· 31 30 32 31 6. **The opening moment** — use `set_scene` to place the player in a specific, actionable situation. Not "you're in a tavern" — something with momentum. Walking toward something, arriving somewhere, witnessing something. 33 32 34 - 7. **The clock** — use `mark_time` to establish the starting time. Morning of Day 1. 33 + 7. **The clock** — include `event` and `duration` in your final `set_scene` to start the clock. Morning of Day 1. 35 34 36 35 ## Rules 37 36 ··· 56 55 57 56 ## Order of Operations 58 57 59 - 1. First, `mark_time` to set the clock (morning of Day 1) 60 - 2. Then `establish` all entities, starting with locations, then NPCs, then threads 61 - 3. Finally, `set_scene` to place the player in the opening moment 58 + 1. `establish` all entities, starting with locations, then NPCs, then threads 59 + 2. `set_scene` to place the player in the opening moment — include `event="Day 1 begins. {Character name} arrives at {location}."` and `duration="0 min"` to start the clock 62 60 63 61 When you've built the world, stop.
-1
src/storied/engine.py
··· 64 64 "establish": "Establishing", 65 65 "mark": "Recording", 66 66 "note_discovery": "Noting discovery", 67 - "mark_time": "Logging time", 68 67 "end_session": "Saving session", 69 68 } 70 69 label = labels.get(short, short)
+3
src/storied/log.py
··· 333 333 334 334 def _roll_day(self) -> None: 335 335 """Archive current day and start a new one.""" 336 + # Save the old day's entries (including the one that triggered the roll) 337 + self._save_day_file(self.current_day, self.current_entries) 338 + 336 339 # Create summary for current day 337 340 if self.current_entries: 338 341 events = [e.event for e in self.current_entries[:3]]
+57 -96
src/storied/tools.py
··· 212 212 213 213 214 214 def set_scene( 215 + event: str | None = None, 216 + duration: str | None = None, 215 217 situation: str | None = None, 216 218 location: str | None = None, 217 219 present: list[str] | None = None, 218 220 threads: list[str] | None = None, 221 + tags: list[str] | None = None, 219 222 player_id: str = "default", 220 223 base_path: Path | None = None, 224 + campaign_log: CampaignLog | None = None, 225 + world_id: str = "default", 221 226 ) -> str: 222 - """Update the current scene when the situation changes significantly. 223 - 224 - Call this when: 225 - - The player moves to a new location 226 - - A significant scene transition occurs (combat ends, time passes) 227 - - Important NPCs enter or leave the scene 228 - - The player's goals or situation change meaningfully 227 + """Call this after every response. Logs what happened, advances the clock, 228 + and updates the scene state. 229 229 230 - Write situation summaries as if briefing another DM taking over mid-session. 231 - Focus on: where we are, what's happening, what's at stake. 230 + Always include event and duration — time only advances when you log it. 232 231 233 232 Args: 234 - situation: New situation summary (replaces current). Write in present tense, 235 - describing the current state of affairs. 236 - location: Location slug when player moves (e.g., "rusty-anchor", "docks") 237 - present: List of entities currently present, using [[Name]] format 238 - (e.g., ["[[Vera Blackwater]] - information broker", "[[Henrik]] - barkeep"]) 239 - threads: List of open plot threads or objectives 240 - (e.g., ["Investigate merchant attacks - 50gp reward", "Vera's mysterious offer"]) 233 + event: What happened this turn (e.g., "Spoke with the innkeeper", 234 + "Searched the warehouse", "Fought off thugs"). 235 + duration: How long it took (e.g., "10 min", "1 hour", "3 rounds", 236 + "1 day"). The clock advances by this amount. 237 + situation: Updated situation summary. Write in present tense as if 238 + briefing another DM taking over mid-session. 239 + location: New location when the player moves (e.g., "The Rusty Anchor") 240 + present: Entities currently present, using [[Name]] format 241 + (e.g., ["[[Vera Blackwater]]", "[[Henrik]] - barkeep"]) 242 + threads: Open plot threads or objectives 243 + tags: Optional tags: "combat", "rest:short", "rest:long", "travel" 241 244 player_id: Player identifier 242 245 base_path: Base path for players directory 246 + campaign_log: CampaignLog instance (injected by engine) 247 + world_id: World identifier (injected by engine) 243 248 244 249 Returns: 245 250 Confirmation of what was updated 246 251 """ 252 + parts = [] 253 + 254 + # Log event and advance clock 255 + if event and duration: 256 + log = campaign_log or load_log(world_id, base_path) 257 + anchor = log.append_entry(event, duration, tags=tags) 258 + parts.append(f"Logged: {anchor} | {event} | {duration}") 259 + 260 + # Update session state 247 261 updates = {} 248 262 if situation is not None: 249 263 updates["situation"] = situation ··· 254 268 if threads is not None: 255 269 updates["threads"] = threads 256 270 257 - return session_update(player_id, updates, base_path) 271 + if updates: 272 + result = session_update(player_id, updates, base_path) 273 + parts.append(result) 274 + 275 + return "; ".join(parts) if parts else "No updates" 258 276 259 277 260 278 def establish( ··· 592 610 return f"Noted: player learned about '{entity}'" 593 611 594 612 595 - def mark_time( 596 - event: str, 597 - duration: str, 598 - advance_time: bool = True, 599 - tags: list[str] | None = None, 600 - campaign_log: CampaignLog | None = None, 601 - world_id: str = "default", 602 - base_path: Path | None = None, 603 - ) -> str: 604 - """Record an event and advance game time. 605 - 606 - Use to track significant happenings and the passage of time: 607 - - Scene transitions: "Entered the tavern", "5 min" 608 - - Conversations: "Spoke with Vera about the job", "30 min" 609 - - Travel: "Traveled to Millford", "1 day" 610 - - Combat: "Fought warehouse guards", "4 rounds" 611 - - Rests: "Short rest", "1 hour" with tags=["rest:short"] 612 - 613 - The log is the canonical source of time in the game. Keep entries brief. 614 - 615 - Args: 616 - event: Brief description of what happened 617 - duration: How long it took (e.g., "30 min", "2 hours", "1 day", "5 rounds") 618 - advance_time: Whether to advance current time by duration (default: True) 619 - tags: Optional tags like ["combat"], ["rest:short"], ["rest:long"], ["travel"] 620 - campaign_log: Existing CampaignLog instance to use (avoids stale cache) 621 - world_id: World identifier (log is world-scoped) 622 - base_path: Base path for worlds directory 623 - 624 - Returns: 625 - Confirmation with the timestamp anchor (e.g., "#d1-0700") 626 - """ 627 - log = campaign_log or load_log(world_id, base_path) 628 - anchor = log.append_entry(event, duration, advance_time, tags) 629 - return f"Logged: {anchor} | {event} | {duration}" 630 - 631 - 632 613 def end_session( 633 614 situation: str, 634 615 threads: list[str] | None = None, ··· 758 739 "input_schema": { 759 740 "type": "object", 760 741 "properties": { 742 + "event": { 743 + "type": "string", 744 + "description": "What happened this turn (logged to campaign journal)", 745 + }, 746 + "duration": { 747 + "type": "string", 748 + "description": "How long it took (e.g., '10 min', '1 hour', '3 rounds')", 749 + }, 761 750 "situation": { 762 751 "type": "string", 763 - "description": "New situation summary describing current state of affairs", 752 + "description": "Updated situation summary in present tense", 764 753 }, 765 754 "location": { 766 755 "type": "string", 767 - "description": "Location slug when player moves (e.g., 'rusty-anchor')", 756 + "description": "New location when the player moves", 768 757 }, 769 758 "present": { 770 759 "type": "array", ··· 776 765 "items": {"type": "string"}, 777 766 "description": "Open plot threads or objectives", 778 767 }, 768 + "tags": { 769 + "type": "array", 770 + "items": {"type": "string"}, 771 + "description": "Optional: 'combat', 'rest:short', 'rest:long', 'travel'", 772 + }, 779 773 }, 780 - "required": [], 774 + "required": ["event", "duration"], 781 775 }, 782 776 }, 783 777 { ··· 879 873 }, 880 874 }, 881 875 { 882 - "name": "mark_time", 883 - "description": mark_time.__doc__, 884 - "input_schema": { 885 - "type": "object", 886 - "properties": { 887 - "event": { 888 - "type": "string", 889 - "description": "Brief description of what happened", 890 - }, 891 - "duration": { 892 - "type": "string", 893 - "description": "How long it took (e.g., '30 min', '2 hours', '1 day', '5 rounds')", 894 - }, 895 - "advance_time": { 896 - "type": "boolean", 897 - "description": "Whether to advance game time by the duration (default: true)", 898 - }, 899 - "tags": { 900 - "type": "array", 901 - "items": {"type": "string"}, 902 - "description": "Optional tags like 'combat', 'rest:short', 'rest:long', 'travel'", 903 - }, 904 - }, 905 - "required": ["event", "duration"], 906 - }, 907 - }, 908 - { 909 876 "name": "end_session", 910 877 "description": end_session.__doc__, 911 878 "input_schema": { ··· 998 965 999 966 elif tool_name == "set_scene": 1000 967 return set_scene( 968 + event=tool_input.get("event"), 969 + duration=tool_input.get("duration"), 1001 970 situation=tool_input.get("situation"), 1002 971 location=tool_input.get("location"), 1003 972 present=tool_input.get("present"), 1004 973 threads=tool_input.get("threads"), 974 + tags=tool_input.get("tags"), 1005 975 player_id=player_id, 1006 976 base_path=base_path, 977 + campaign_log=campaign_log, 978 + world_id=world_id or "default", 1007 979 ) 1008 980 1009 981 elif tool_name == "establish": ··· 1041 1013 base_path=base_path, 1042 1014 ) 1043 1015 1044 - elif tool_name == "mark_time": 1045 - return mark_time( 1046 - event=tool_input["event"], 1047 - duration=tool_input["duration"], 1048 - advance_time=tool_input.get("advance_time", True), 1049 - tags=tool_input.get("tags"), 1050 - campaign_log=campaign_log, 1051 - world_id=world_id, 1052 - base_path=base_path, 1053 - ) 1054 - 1055 1016 elif tool_name == "end_session": 1056 1017 return end_session( 1057 1018 situation=tool_input["situation"], ··· 1088 1049 ) 1089 1050 1090 1051 1091 - SEEDER_TOOLS = {"establish", "set_scene", "mark_time"} 1052 + SEEDER_TOOLS = {"establish", "set_scene"} 1092 1053 1093 1054 SEEDER_TOOL_DEFINITIONS = [t for t in TOOL_DEFINITIONS if t["name"] in SEEDER_TOOLS] 1094 1055
+11
tests/test_log.py
··· 189 189 day_file = tmp_path / "worlds" / "test" / "log" / "day+001.md" 190 190 assert day_file.exists() 191 191 192 + def test_roll_day_preserves_crossing_entry(self, tmp_path): 193 + """Entry that triggers a day roll must appear in the old day's file.""" 194 + log = CampaignLog(world_id="test", base_path=tmp_path) 195 + log.append_entry("Morning event", "2 hours") 196 + log.append_entry("Traveled all day", "20 hours") 197 + 198 + day1 = tmp_path / "worlds" / "test" / "log" / "day+001.md" 199 + content = day1.read_text() 200 + assert "Morning event" in content 201 + assert "Traveled all day" in content 202 + 192 203 def test_format_for_context(self, tmp_path): 193 204 log = CampaignLog(world_id="test", base_path=tmp_path) 194 205 log.append_entry("Arrived at tavern", "30 min")
+8 -12
tests/test_seeder.py
··· 19 19 """Tests for seeder tool filtering.""" 20 20 21 21 def test_seeder_tools_set(self): 22 - assert SEEDER_TOOLS == {"establish", "set_scene", "mark_time"} 22 + assert SEEDER_TOOLS == {"establish", "set_scene"} 23 23 24 24 def test_seeder_tool_definitions_filtered(self): 25 25 names = {t["name"] for t in SEEDER_TOOL_DEFINITIONS} 26 26 assert names == SEEDER_TOOLS 27 27 28 28 def test_seeder_rejects_disallowed_tools(self, tmp_path: Path): 29 - for tool_name in ["roll", "recall", "mark", "note_discovery", "end_session"]: 29 + for tool_name in ["roll", "recall", "mark", "mark_time", "note_discovery", "end_session"]: 30 30 result = seeder_execute_tool( 31 31 tool_name, 32 32 {}, ··· 49 49 (tmp_path / "players" / "default").mkdir(parents=True) 50 50 result = seeder_execute_tool( 51 51 "set_scene", 52 - {"situation": "Walking through the forest."}, 52 + { 53 + "event": "Dawn breaks", 54 + "duration": "0 min", 55 + "situation": "Walking through the forest.", 56 + }, 53 57 world_id="test", 54 58 player_id="default", 55 59 base_path=tmp_path, 56 60 ) 57 - assert "Session updated" in result 58 - 59 - def test_seeder_allows_mark_time(self, tmp_path: Path): 60 - result = seeder_execute_tool( 61 - "mark_time", 62 - {"event": "Dawn breaks", "duration": "0 min"}, 63 - world_id="test", 64 - base_path=tmp_path, 65 - ) 66 61 assert "Logged" in result 62 + assert "Session updated" in result 67 63 68 64 69 65 @pytest.fixture