From d2369cbbcbeb23e7252e31a45d0f72e377fd7930 Mon Sep 17 00:00:00 2001 From: Chris Guidry Date: Sat, 11 Apr 2026 00:44:02 +0000 Subject: [PATCH] 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) --- prompts/xp-evaluator.md | 1 - src/storied/advancement.py | 29 ++++++++++++++++++++++------- tests/test_advancement.py | 21 +++++++++------------ 3 file(s) changed, 31 insertion(s)(+), 20 deletion(s)(-) diff --git a/prompts/xp-evaluator.md b/prompts/xp-evaluator.md --- a/prompts/xp-evaluator.md +++ b/prompts/xp-evaluator.md @@ -81,5 +81,4 @@ ## Important - 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. -- 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. - 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. diff --git a/src/storied/advancement.py b/src/storied/advancement.py --- a/src/storied/advancement.py +++ b/src/storied/advancement.py @@ -6,6 +6,7 @@ from dataclasses import dataclass from pathlib import Path from threading import Thread +from storied import notifications from storied.character import format_character_context, load_character from storied.claude import run_with_tools from storied.engine import load_prompt @@ -32,15 +33,10 @@ base_path: Path, ) -> str | None: """Build context for the advancement evaluator. - Returns None if there's nothing to evaluate (no character or - advancement_ready already set). + Returns None if there's no character to evaluate. """ character = load_character(player_id, base_path) if character is None: - return None - - # If advancement_ready is already set, the DM hasn't acted yet — skip - if character.get("advancement_ready"): return None parts: list[str] = [] @@ -98,9 +94,28 @@ on_progress(msg) start_time = time.monotonic() + character = load_character(player_id, base_path) + if character is None: + progress("Skipped (no character)") + return AdvancementResult(elapsed=time.monotonic() - start_time) + + pending_level = character.get("advancement_ready") + if pending_level: + char_name = character.get("identity", {}).get("name", "The character") + notifications.append( + world_id, + base_path, + f"Reminder: {char_name} is still pending advancement to " + f"level {pending_level}. The next narratively appropriate " + f"moment — a rest, a quiet pause, after a triumph — should " + f"acknowledge their growth before it loses meaning.", + ) + progress(f"Posted reminder: pending level {pending_level}") + return AdvancementResult(elapsed=time.monotonic() - start_time) + context = build_advancement_context(world_id, player_id, base_path) if context is None: - progress("Skipped (no character or advancement already pending)") + progress("Skipped (no context)") return AdvancementResult(elapsed=time.monotonic() - start_time) system_prompt = load_prompt("xp-evaluator") diff --git a/tests/test_advancement.py b/tests/test_advancement.py --- a/tests/test_advancement.py +++ b/tests/test_advancement.py @@ -107,17 +107,6 @@ ctx.world_id, ctx.player_id, ctx.base_path ) assert result is None - def test_returns_none_when_advancement_ready( - self, ctx: ToolContext, character: dict - ): - character["advancement_ready"] = 4 - save_character("default", character, ctx.base_path) - - result = build_advancement_context( - ctx.world_id, ctx.player_id, ctx.base_path - ) - assert result is None - def test_includes_character_info( self, ctx: ToolContext, character: dict ): @@ -277,7 +266,7 @@ base_path=ctx.base_path, ) assert result.evaluated is False - def test_skips_when_advancement_ready( + def test_posts_reminder_when_advancement_pending( self, ctx: ToolContext, character: dict ): character["advancement_ready"] = 4 @@ -288,7 +277,15 @@ world_id=ctx.world_id, player_id=ctx.player_id, base_path=ctx.base_path, ) + assert result.evaluated is False + path = ( + ctx.base_path / "worlds" / ctx.world_id / "dm_notifications.md" + ) + assert path.exists() + contents = path.read_text() + assert "Kira" in contents + assert "level 4" in contents @patch("storied.claude.subprocess.Popen") def test_calls_claude_when_character_exists( -- tangled.sh