A 5e storytelling engine with an LLM DM
0
fork

Configure Feed

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

Nudge the DM when advancement has been pending for a while

The xp evaluator was skipping silently whenever `advancement_ready` was
already set, which meant once a level-up was queued the DM had to
remember to act on it on their own. In practice that turned out to be
easy to lose track of — the flag would just sit there.

Now when the evaluator wakes up and finds a pending advancement, instead
of doing nothing it appends a short reminder to the DM notification
channel and returns without spending tokens on a Claude call. The
repetition itself (one reminder per evaluator wake) creates the urgency,
without needing a counter or escalation state. If that turns out to be
too quiet we can layer on stronger language after N reminders later.

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

+31 -20
-1
prompts/xp-evaluator.md
··· 81 81 ## Important 82 82 83 83 - Only recommend one level at a time. If they've earned multiple levels, recommend the next one. The evaluator will run again and catch the subsequent level. 84 - - If `advancement_ready` is already set on the character sheet and the DM hasn't acted on it yet, do nothing. Don't stack notifications. 85 84 - Err slightly on the side of generosity. A player stuck at the same level for too long is worse than leveling one session too early. The goal is to keep the game feeling rewarding and the character's growth matching their story.
+22 -7
src/storied/advancement.py
··· 6 6 from pathlib import Path 7 7 from threading import Thread 8 8 9 + from storied import notifications 9 10 from storied.character import format_character_context, load_character 10 11 from storied.claude import run_with_tools 11 12 from storied.engine import load_prompt ··· 32 33 ) -> str | None: 33 34 """Build context for the advancement evaluator. 34 35 35 - Returns None if there's nothing to evaluate (no character or 36 - advancement_ready already set). 36 + Returns None if there's no character to evaluate. 37 37 """ 38 38 character = load_character(player_id, base_path) 39 39 if character is None: 40 - return None 41 - 42 - # If advancement_ready is already set, the DM hasn't acted yet — skip 43 - if character.get("advancement_ready"): 44 40 return None 45 41 46 42 parts: list[str] = [] ··· 98 94 99 95 start_time = time.monotonic() 100 96 97 + character = load_character(player_id, base_path) 98 + if character is None: 99 + progress("Skipped (no character)") 100 + return AdvancementResult(elapsed=time.monotonic() - start_time) 101 + 102 + pending_level = character.get("advancement_ready") 103 + if pending_level: 104 + char_name = character.get("identity", {}).get("name", "The character") 105 + notifications.append( 106 + world_id, 107 + base_path, 108 + f"Reminder: {char_name} is still pending advancement to " 109 + f"level {pending_level}. The next narratively appropriate " 110 + f"moment — a rest, a quiet pause, after a triumph — should " 111 + f"acknowledge their growth before it loses meaning.", 112 + ) 113 + progress(f"Posted reminder: pending level {pending_level}") 114 + return AdvancementResult(elapsed=time.monotonic() - start_time) 115 + 101 116 context = build_advancement_context(world_id, player_id, base_path) 102 117 if context is None: 103 - progress("Skipped (no character or advancement already pending)") 118 + progress("Skipped (no context)") 104 119 return AdvancementResult(elapsed=time.monotonic() - start_time) 105 120 106 121 system_prompt = load_prompt("xp-evaluator")
+9 -12
tests/test_advancement.py
··· 107 107 ) 108 108 assert result is None 109 109 110 - def test_returns_none_when_advancement_ready( 111 - self, ctx: ToolContext, character: dict 112 - ): 113 - character["advancement_ready"] = 4 114 - save_character("default", character, ctx.base_path) 115 - 116 - result = build_advancement_context( 117 - ctx.world_id, ctx.player_id, ctx.base_path 118 - ) 119 - assert result is None 120 - 121 110 def test_includes_character_info( 122 111 self, ctx: ToolContext, character: dict 123 112 ): ··· 277 266 ) 278 267 assert result.evaluated is False 279 268 280 - def test_skips_when_advancement_ready( 269 + def test_posts_reminder_when_advancement_pending( 281 270 self, ctx: ToolContext, character: dict 282 271 ): 283 272 character["advancement_ready"] = 4 ··· 288 277 player_id=ctx.player_id, 289 278 base_path=ctx.base_path, 290 279 ) 280 + 291 281 assert result.evaluated is False 282 + path = ( 283 + ctx.base_path / "worlds" / ctx.world_id / "dm_notifications.md" 284 + ) 285 + assert path.exists() 286 + contents = path.read_text() 287 + assert "Kira" in contents 288 + assert "level 4" in contents 292 289 293 290 @patch("storied.claude.subprocess.Popen") 294 291 def test_calls_claude_when_character_exists(