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 initiative tracking tools for structured combat

The DM was tracking initiative order, enemy HP, conditions, and effect
durations entirely in its head. That works for short fights but drifts
badly on longer encounters and breaks on context truncation.

Now the DM has a proper initiative tracker — a state machine that
handles turn order, HP, conditions with 5e-correct duration expiry
(anchored to the source's turn), and auto-syncs player HP to the
character sheet. The full combat state gets injected into the system
prompt every turn, so the DM always sees ground truth instead of trying
to remember it.

The MCP server dynamically swaps tool sets: narrative mode gets the
usual 10 tools plus enter_initiative, initiative mode gets 7 combat
tools (damage, heal, condition, next_turn, etc.) plus roll, recall, and
update_character. This keeps the DM's decision space tight — it only
sees what's relevant to the current mode.

Works for non-combat initiative too (environmental hazards, chases,
timed puzzles) since the tracker is just "who goes when" with HP and
conditions bolted on.

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

+1334 -15
+1
.loq_cache
··· 1 + {"version":1,"config_hash":4557771575092473650,"entries":{"src/storied/initiative.py":{"mtime_secs":1774751731,"mtime_nanos":975235964,"lines":524}}}
+56 -8
prompts/dm-system.md
··· 4 4 5 5 ## Available Tools 6 6 7 + ### Always Available 7 8 | Tool | Purpose | 8 9 |------|---------| 9 - | `set_scene` | **Call after every response.** Logs what happened, advances the clock, updates the scene | 10 10 | `roll` | Roll dice (e.g., `roll("1d20+5", "attack")`) | 11 11 | `recall` | Look up rules or world content | 12 + | `update_character` | Modify character stats (HP, gold, equipment) | 13 + 14 + ### Narrative Mode (outside initiative) 15 + | Tool | Purpose | 16 + |------|---------| 17 + | `set_scene` | **Call after every response.** Logs what happened, advances the clock, updates the scene | 12 18 | `establish` | Create or update entities (NPCs, locations, items, threads) | 13 19 | `mark` | Record what happened to an entity | 14 20 | `note_discovery` | Record what the player learned | 15 - | `update_character` | Modify character stats (HP, gold, equipment) | 16 21 | `create_character` | Create a new character | 17 22 | `tune` | Update your style/personality tuning based on player feedback | 18 23 | `end_session` | Gracefully end the session | 24 + | `enter_initiative` | Enter initiative mode for combat or turn-based encounters | 25 + 26 + ### Initiative Mode (during initiative) 27 + | Tool | Purpose | 28 + |------|---------| 29 + | `next_turn` | Advance to the next combatant's turn | 30 + | `damage` | Deal damage to a combatant (tracks defeat at 0 HP) | 31 + | `heal` | Heal a combatant (clamped to max HP) | 32 + | `condition` | Add or remove a condition (Prone, Stunned, etc.) | 33 + | `add_combatant` | Add reinforcements or late arrivals | 34 + | `remove_combatant` | Remove a combatant who fled or was banished | 35 + | `end_initiative` | End initiative and return to narrative mode | 19 36 20 37 ## After Every Response: Call `set_scene` 21 38 ··· 198 215 199 216 **In combat**: More mechanical transparency is fine. The player should understand the tactical situation - hits, misses, how wounded enemies look. But still narrate, don't just announce numbers. 200 217 201 - ## Combat Flow 218 + ## Initiative Mode 219 + 220 + When ordered action matters — combat, environmental dangers, timed puzzles, chase sequences — use initiative mode. The tracker handles turn order, HP, and conditions so you don't have to hold it in memory. 221 + 222 + ### Entering Initiative 223 + 224 + 1. Roll initiative for all participants (use `roll`) 225 + 2. Decide turn order (you handle tie-breaking per 5e rules) 226 + 3. Call `enter_initiative` with all combatants listed in turn order 227 + 228 + After calling `enter_initiative`, finish your response with narration setting the scene. Initiative tools become available starting next turn. 229 + 230 + ### During Initiative 231 + 232 + Your context always includes the full initiative table — current turn, HP, AC, conditions, who's next. You never need to remember combat state; it's always right there. 233 + 234 + Each combatant's turn: 235 + - **Player's turn**: Narrate the tactical situation, then wait for their input. After they act, resolve rolls and effects, then call `next_turn`. 236 + - **Monster's turn**: Decide their action, roll attacks/saves/damage, apply `damage`/`condition`, then call `next_turn`. 237 + 238 + Use `damage` and `heal` to track HP changes on combatants. Use `condition` to apply or remove conditions with optional durations. When you damage or heal the player character, their character sheet is automatically synced — no need to call `update_character` separately for HP during initiative. 239 + 240 + ### Ending Initiative 241 + 242 + Call `end_initiative` when combat is resolved — enemies defeated, fled, or surrendered. It reports total rounds and duration. Then call `set_scene` with the aftermath and use the reported duration. 243 + 244 + ### Non-Combat Initiative 245 + 246 + Initiative isn't just for combat. Use it for: 247 + - **Environmental hazards**: collapsing dungeon, rising flood, spreading fire 248 + - **Chase sequences**: tracking distance and obstacles turn by turn 249 + - **Timed puzzles**: a mechanism with rounds to solve before something triggers 250 + - **Social confrontations**: tense negotiations where order matters 202 251 203 - In combat, track internally: 204 - - Initiative order 205 - - Enemy HP and conditions 206 - - Active effects and durations 252 + Any situation where "who goes when" matters deserves initiative. 253 + 254 + ### Narrative During Initiative 207 255 208 - Narrate each exchange with dramatic weight. A hit isn't just "8 damage" - describe the impact. Show how wounded enemies are through their behavior, not HP counts. 256 + Narrate each exchange with dramatic weight. A hit isn't just "8 damage" — describe the impact. Show how wounded enemies are through their behavior, not HP counts. 209 257 210 258 ## Player Input 211 259
+14
src/storied/engine.py
··· 63 63 "note_discovery": "Noting discovery", 64 64 "tune": "Tuning style", 65 65 "end_session": "Saving session", 66 + "enter_initiative": "Entering initiative", 67 + "next_turn": "Next turn", 68 + "add_combatant": "Adding combatant", 69 + "remove_combatant": "Removing combatant", 70 + "damage": "Applying damage", 71 + "heal": "Healing", 72 + "condition": "Updating condition", 73 + "end_initiative": "Ending initiative", 66 74 } 67 75 label = labels.get(short, short) 68 76 return f"\n[{label}...]\n" ··· 240 248 self._context_parts[f"Linked:{name}"] = entity_context 241 249 parts.append(entity_context) 242 250 loaded_names.add(name) 251 + 252 + # Initiative state (injected when active so the DM never loses track) 253 + if self._mcp.ctx.initiative.active: 254 + initiative_context = self._mcp.ctx.initiative.format_for_context() 255 + self._context_parts["Initiative"] = initiative_context 256 + parts.append(initiative_context) 243 257 244 258 # Terminal width so the DM can size display blocks 245 259 import os
+524
src/storied/initiative.py
··· 1 + """Initiative tracking for structured combat and turn-based encounters.""" 2 + 3 + from dataclasses import dataclass, field 4 + 5 + 6 + @dataclass 7 + class TrackedCondition: 8 + """A condition on a combatant with optional duration. 9 + 10 + Duration is anchored to the source's turn per 5e rules. Duration -1 11 + means the condition persists until manually removed. 12 + """ 13 + 14 + name: str 15 + source: str 16 + duration: int = -1 17 + ends_on: str = "start" 18 + 19 + 20 + @dataclass 21 + class Combatant: 22 + """A participant in initiative order.""" 23 + 24 + name: str 25 + initiative: int 26 + hp: int 27 + hp_max: int 28 + ac: int 29 + is_player: bool = False 30 + conditions: list[TrackedCondition] = field(default_factory=list) 31 + defeated: bool = False 32 + 33 + 34 + class InitiativeTracker: 35 + """Pure state machine for tracking initiative order and combat state. 36 + 37 + Starts inactive. Call begin() to enter initiative mode, end() to leave. 38 + The DM passes combatants in their desired turn order (pre-sorted). 39 + """ 40 + 41 + def __init__(self) -> None: 42 + self.active: bool = False 43 + self.combatants: list[Combatant] = [] 44 + self.current_index: int = 0 45 + self.round: int = 0 46 + 47 + @property 48 + def current_combatant(self) -> Combatant | None: 49 + if not self.active or not self.combatants: 50 + return None 51 + return self.combatants[self.current_index] 52 + 53 + def begin(self, combatants: list[Combatant]) -> str: 54 + """Enter initiative mode with the given combatants in turn order.""" 55 + self.combatants = list(combatants) 56 + self.current_index = 0 57 + self.round = 1 58 + self.active = True 59 + 60 + first = self.combatants[0] 61 + lines = [f"Initiative started — Round 1", ""] 62 + lines.append(self._format_order()) 63 + lines.append("") 64 + lines.append(f"**{first.name}** goes first.") 65 + return "\n".join(lines) 66 + 67 + def next_turn(self) -> str: 68 + """Advance to the next combatant's turn.""" 69 + if not self.active: 70 + return "Initiative is not active." 71 + 72 + # Process end-of-turn effects for the combatant we're leaving 73 + leaving = self.combatants[self.current_index] 74 + expired = self._process_effects(leaving.name, "end") 75 + 76 + # Advance to next living combatant 77 + self.current_index = self._next_living_index(self.current_index) 78 + 79 + current = self.combatants[self.current_index] 80 + started_turn = self._process_effects(current.name, "start") 81 + 82 + parts = [] 83 + if expired: 84 + parts.append(f"Expired: {', '.join(expired)}") 85 + if started_turn: 86 + parts.append(f"Expired: {', '.join(started_turn)}") 87 + 88 + parts.append( 89 + f"Round {self.round} — **{current.name}**'s turn " 90 + f"({current.hp}/{current.hp_max} HP, AC {current.ac})" 91 + ) 92 + 93 + if current.conditions: 94 + cond_str = ", ".join(self._format_condition(c) for c in current.conditions) 95 + parts.append(f"Conditions: {cond_str}") 96 + 97 + # Check if only one side remains 98 + hint = self._one_side_hint() 99 + if hint: 100 + parts.append(hint) 101 + 102 + return "\n".join(parts) 103 + 104 + def apply_damage(self, target: str, amount: int) -> str: 105 + """Deal damage to a combatant.""" 106 + combatant = self._find(target) 107 + if combatant is None: 108 + return f"Combatant '{target}' not found." 109 + 110 + old_hp = combatant.hp 111 + combatant.hp = max(0, combatant.hp - amount) 112 + result = f"{target} takes {amount} damage ({old_hp} \u2192 {combatant.hp}/{combatant.hp_max} HP)" 113 + 114 + if combatant.hp == 0: 115 + combatant.defeated = True 116 + result += " \u2014 DOWN!" 117 + elif combatant.hp <= combatant.hp_max // 2 < old_hp: 118 + result += " \u2014 Bloodied" 119 + 120 + return result 121 + 122 + def apply_heal(self, target: str, amount: int) -> str: 123 + """Heal a combatant.""" 124 + combatant = self._find(target) 125 + if combatant is None: 126 + return f"Combatant '{target}' not found." 127 + 128 + old_hp = combatant.hp 129 + combatant.hp = min(combatant.hp_max, combatant.hp + amount) 130 + actual = combatant.hp - old_hp 131 + if combatant.defeated and combatant.hp > 0: 132 + combatant.defeated = False 133 + return f"{target} heals {actual} HP ({old_hp} \u2192 {combatant.hp}/{combatant.hp_max})" 134 + 135 + def add_condition( 136 + self, 137 + target: str, 138 + condition: str, 139 + duration: int = -1, 140 + ends_on: str = "start", 141 + source: str = "", 142 + ) -> str: 143 + """Apply a condition to a combatant.""" 144 + combatant = self._find(target) 145 + if combatant is None: 146 + return f"Combatant '{target}' not found." 147 + 148 + tc = TrackedCondition( 149 + name=condition, source=source, duration=duration, ends_on=ends_on, 150 + ) 151 + combatant.conditions.append(tc) 152 + 153 + dur_str = f" ({duration} rds)" if duration > 0 else "" 154 + return f"{target} is now {condition}{dur_str}" 155 + 156 + def remove_condition(self, target: str, condition: str) -> str: 157 + """Remove a condition from a combatant.""" 158 + combatant = self._find(target) 159 + if combatant is None: 160 + return f"Combatant '{target}' not found." 161 + 162 + before = len(combatant.conditions) 163 + combatant.conditions = [c for c in combatant.conditions if c.name != condition] 164 + if len(combatant.conditions) == before: 165 + return f"{target} does not have {condition}." 166 + return f"{condition} removed from {target}." 167 + 168 + def add_combatant(self, combatant: Combatant) -> str: 169 + """Add a combatant at the correct initiative position.""" 170 + insert_idx = len(self.combatants) 171 + for i, c in enumerate(self.combatants): 172 + if combatant.initiative > c.initiative: 173 + insert_idx = i 174 + break 175 + 176 + self.combatants.insert(insert_idx, combatant) 177 + 178 + if insert_idx <= self.current_index: 179 + self.current_index += 1 180 + 181 + return f"{combatant.name} joins initiative (initiative {combatant.initiative})" 182 + 183 + def remove_combatant(self, name: str) -> str: 184 + """Remove a combatant from initiative.""" 185 + idx = None 186 + for i, c in enumerate(self.combatants): 187 + if c.name.lower() == name.lower(): 188 + idx = i 189 + break 190 + 191 + if idx is None: 192 + return f"Combatant '{name}' not found." 193 + 194 + removed = self.combatants.pop(idx) 195 + 196 + if not self.combatants: 197 + self.active = False 198 + return f"{removed.name} removed. No combatants remain — initiative ended." 199 + 200 + if idx < self.current_index: 201 + self.current_index -= 1 202 + elif idx == self.current_index: 203 + if self.current_index >= len(self.combatants): 204 + self.current_index = 0 205 + self.round += 1 206 + 207 + return f"{removed.name} removed from initiative." 208 + 209 + def end(self) -> str: 210 + """End initiative and return a summary.""" 211 + defeated = [c for c in self.combatants if c.defeated] 212 + survivors = [c for c in self.combatants if not c.defeated] 213 + rounds = self.round 214 + duration_sec = rounds * 6 215 + 216 + lines = [f"Initiative ended after {rounds} rounds ({duration_sec} seconds)."] 217 + 218 + if defeated: 219 + names = ", ".join(c.name for c in defeated) 220 + lines.append(f"Defeated: {names}") 221 + 222 + if survivors: 223 + parts = [] 224 + for c in survivors: 225 + conds = "" 226 + if c.conditions: 227 + conds = f" [{', '.join(co.name for co in c.conditions)}]" 228 + parts.append(f"{c.name} ({c.hp}/{c.hp_max} HP{conds})") 229 + lines.append(f"Survivors: {', '.join(parts)}") 230 + 231 + self.active = False 232 + self.combatants = [] 233 + self.current_index = 0 234 + self.round = 0 235 + 236 + return "\n".join(lines) 237 + 238 + def format_for_context(self) -> str: 239 + """Format full initiative state for system prompt injection.""" 240 + if not self.active: 241 + return "" 242 + 243 + current = self.combatants[self.current_index] 244 + 245 + # Find who's next (next living combatant after current) 246 + next_idx = self._peek_next_living(self.current_index) 247 + next_up = self.combatants[next_idx] if next_idx is not None else None 248 + 249 + lines = [f"## Active Initiative \u2014 Round {self.round}", ""] 250 + lines.append("| # | Combatant | Init | HP | AC | Conditions |") 251 + lines.append("|---|-----------|------|----|----|------------|") 252 + 253 + for i, c in enumerate(self.combatants): 254 + marker = " > " if i == self.current_index else " " 255 + name = f"**{c.name}**" if i == self.current_index else c.name 256 + if c.defeated: 257 + name = f"~~{c.name}~~" 258 + hp_str = f"{c.hp}/{c.hp_max}" 259 + conds = ", ".join(self._format_condition(co) for co in c.conditions) 260 + if c.defeated and not conds: 261 + conds = "Defeated" 262 + lines.append(f"|{marker}| {name} | {c.initiative} | {hp_str} | {c.ac} | {conds} |") 263 + 264 + lines.append("") 265 + 266 + cond_str = "" 267 + if current.conditions: 268 + cond_str = ", " + ", ".join(co.name for co in current.conditions) 269 + lines.append( 270 + f"**Current turn:** {current.name} " 271 + f"({current.hp}/{current.hp_max} HP, AC {current.ac}{cond_str})" 272 + ) 273 + 274 + if next_up: 275 + lines.append(f"**Up next:** {next_up.name}") 276 + 277 + lines.append(f"**Round:** {self.round}") 278 + lines.append("") 279 + lines.append( 280 + f"Resolve {current.name}'s turn, then call `next_turn` to advance." 281 + ) 282 + 283 + return "\n".join(lines) 284 + 285 + def _find(self, name: str) -> Combatant | None: 286 + for c in self.combatants: 287 + if c.name.lower() == name.lower(): 288 + return c 289 + return None 290 + 291 + def _next_living_index(self, from_index: int) -> int: 292 + """Find the next living combatant after from_index, wrapping around.""" 293 + n = len(self.combatants) 294 + for offset in range(1, n + 1): 295 + idx = (from_index + offset) % n 296 + if idx == 0 and offset > 0: 297 + self.round += 1 298 + if not self.combatants[idx].defeated: 299 + return idx 300 + return from_index # all defeated, shouldn't happen 301 + 302 + def _peek_next_living(self, from_index: int) -> int | None: 303 + """Peek at next living combatant without modifying state.""" 304 + n = len(self.combatants) 305 + for offset in range(1, n): 306 + idx = (from_index + offset) % n 307 + if not self.combatants[idx].defeated: 308 + return idx 309 + return None 310 + 311 + def _process_effects(self, source_name: str, phase: str) -> list[str]: 312 + """Process and expire effects anchored to source_name at the given phase.""" 313 + expired: list[str] = [] 314 + for c in self.combatants: 315 + remaining: list[TrackedCondition] = [] 316 + for cond in c.conditions: 317 + if cond.source.lower() != source_name.lower() or cond.ends_on != phase: 318 + remaining.append(cond) 319 + continue 320 + if cond.duration == -1: 321 + remaining.append(cond) 322 + continue 323 + cond.duration -= 1 324 + if cond.duration <= 0: 325 + expired.append(f"{cond.name} on {c.name}") 326 + else: 327 + remaining.append(cond) 328 + c.conditions = remaining 329 + return expired 330 + 331 + def _one_side_hint(self) -> str | None: 332 + """Check if only one side (player vs non-player) has living combatants.""" 333 + living = [c for c in self.combatants if not c.defeated] 334 + has_player = any(c.is_player for c in living) 335 + has_non_player = any(not c.is_player for c in living) 336 + if has_player and not has_non_player: 337 + return "Only player combatants remaining." 338 + if has_non_player and not has_player: 339 + return "Only non-player combatants remaining." 340 + return None 341 + 342 + def _format_condition(self, cond: TrackedCondition) -> str: 343 + if cond.duration == -1: 344 + return cond.name 345 + return f"{cond.name} ({cond.duration} rds)" 346 + 347 + def _format_order(self) -> str: 348 + parts = [] 349 + for c in self.combatants: 350 + parts.append(f" {c.initiative}: {c.name} ({c.hp}/{c.hp_max} HP, AC {c.ac})") 351 + return "\n".join(parts) 352 + 353 + 354 + # --- Tool functions (thin wrappers around tracker methods) --- 355 + 356 + def enter_initiative(combatants_raw: list[dict], tracker: InitiativeTracker) -> str: 357 + """Enter initiative mode with pre-sorted combatants.""" 358 + if tracker.active: 359 + return "Initiative is already active. Call end_initiative first." 360 + 361 + combatants = [ 362 + Combatant( 363 + name=c["name"], 364 + initiative=c["initiative"], 365 + hp=c["hp"], 366 + hp_max=c["hp_max"], 367 + ac=c["ac"], 368 + is_player=c.get("is_player", False), 369 + ) 370 + for c in combatants_raw 371 + ] 372 + return tracker.begin(combatants) 373 + 374 + 375 + def execute_initiative_tool( 376 + tool_name: str, tool_input: dict, tracker: InitiativeTracker, 377 + ) -> str | None: 378 + """Dispatch an initiative tool call. Returns None for unknown tools.""" 379 + if tool_name not in ALL_INITIATIVE_TOOL_NAMES: 380 + return None 381 + 382 + if tool_name == "enter_initiative": 383 + return enter_initiative(tool_input["combatants"], tracker) 384 + 385 + if not tracker.active: 386 + return "Initiative is not active. Call enter_initiative first." 387 + 388 + if tool_name == "next_turn": 389 + return tracker.next_turn() 390 + elif tool_name == "add_combatant": 391 + c = Combatant( 392 + name=tool_input["name"], 393 + initiative=tool_input["initiative"], 394 + hp=tool_input["hp"], 395 + hp_max=tool_input["hp_max"], 396 + ac=tool_input["ac"], 397 + is_player=tool_input.get("is_player", False), 398 + ) 399 + return tracker.add_combatant(c) 400 + elif tool_name == "remove_combatant": 401 + return tracker.remove_combatant(tool_input["name"]) 402 + elif tool_name == "damage": 403 + return tracker.apply_damage(tool_input["target"], tool_input["amount"]) 404 + elif tool_name == "heal": 405 + return tracker.apply_heal(tool_input["target"], tool_input["amount"]) 406 + elif tool_name == "condition": 407 + action = tool_input.get("action", "add") 408 + if action == "remove": 409 + return tracker.remove_condition( 410 + tool_input["target"], tool_input["condition"], 411 + ) 412 + return tracker.add_condition( 413 + target=tool_input["target"], 414 + condition=tool_input["condition"], 415 + duration=tool_input.get("duration", -1), 416 + ends_on=tool_input.get("ends_on", "start"), 417 + source=tool_input.get("source", ""), 418 + ) 419 + elif tool_name == "end_initiative": 420 + return tracker.end() 421 + 422 + return None 423 + 424 + 425 + # --- Tool definitions --- 426 + 427 + _COMBATANT_PROPS: dict = { 428 + "name": {"type": "string", "description": "Combatant name"}, 429 + "initiative": {"type": "integer", "description": "Initiative roll total"}, 430 + "hp": {"type": "integer", "description": "Current hit points"}, 431 + "hp_max": {"type": "integer", "description": "Maximum hit points"}, 432 + "ac": {"type": "integer", "description": "Armor class"}, 433 + "is_player": {"type": "boolean", "description": "True for the player character"}, 434 + } 435 + _COMBATANT_REQUIRED = ["name", "initiative", "hp", "hp_max", "ac"] 436 + 437 + _TARGET_AMOUNT_SCHEMA: dict = { 438 + "type": "object", 439 + "properties": { 440 + "target": {"type": "string", "description": "Combatant name"}, 441 + "amount": {"type": "integer", "description": "Amount"}, 442 + }, 443 + "required": ["target", "amount"], 444 + } 445 + 446 + _NO_INPUT: dict = {"type": "object", "properties": {}} 447 + 448 + ENTER_INITIATIVE_DEFINITION: dict = { 449 + "name": "enter_initiative", 450 + "description": ( 451 + "Enter initiative mode for combat or any turn-based encounter. " 452 + "Provide all participants in their desired turn order (you handle " 453 + "tie-breaking). Roll initiative for everyone first, then call this. " 454 + "Initiative tools become available on the next turn." 455 + ), 456 + "input_schema": { 457 + "type": "object", 458 + "properties": { 459 + "combatants": { 460 + "type": "array", 461 + "description": "Participants in initiative order (first acts first)", 462 + "items": { 463 + "type": "object", 464 + "properties": _COMBATANT_PROPS, 465 + "required": _COMBATANT_REQUIRED, 466 + }, 467 + }, 468 + }, 469 + "required": ["combatants"], 470 + }, 471 + } 472 + 473 + COMBAT_TOOL_DEFINITIONS: list[dict] = [ 474 + {"name": "next_turn", 475 + "description": "Advance to the next combatant's turn. Skips defeated. Call after resolving actions.", 476 + "input_schema": _NO_INPUT}, 477 + {"name": "add_combatant", 478 + "description": "Add a combatant (reinforcements, surprised creatures waking up).", 479 + "input_schema": { 480 + "type": "object", "properties": _COMBATANT_PROPS, 481 + "required": _COMBATANT_REQUIRED}}, 482 + {"name": "remove_combatant", 483 + "description": "Remove a combatant who fled, was banished, or is otherwise out.", 484 + "input_schema": { 485 + "type": "object", 486 + "properties": {"name": {"type": "string", "description": "Combatant name"}}, 487 + "required": ["name"]}}, 488 + {"name": "damage", 489 + "description": "Deal damage. Tracks defeat at 0 HP, reports Bloodied at half. Auto-syncs player character sheet.", 490 + "input_schema": _TARGET_AMOUNT_SCHEMA}, 491 + {"name": "heal", 492 + "description": "Heal a combatant. Clamped to max HP. Revives defeated. Auto-syncs player character sheet.", 493 + "input_schema": _TARGET_AMOUNT_SCHEMA}, 494 + {"name": "condition", 495 + "description": ( 496 + "Add or remove a condition. For timed effects, duration counts down " 497 + "on the source's turn. Duration -1 = until manually removed."), 498 + "input_schema": { 499 + "type": "object", 500 + "properties": { 501 + "target": {"type": "string", "description": "Combatant name"}, 502 + "condition": {"type": "string", "description": "Condition name (Prone, Stunned, etc)"}, 503 + "action": {"type": "string", "enum": ["add", "remove"]}, 504 + "duration": {"type": "integer", "description": "Rounds until expiry (-1 = until removed)"}, 505 + "ends_on": {"type": "string", "enum": ["start", "end"], 506 + "description": "Expires at start or end of source's turn"}, 507 + "source": {"type": "string", "description": "Who caused the condition"}, 508 + }, 509 + "required": ["target", "condition"]}}, 510 + {"name": "end_initiative", 511 + "description": ( 512 + "End initiative and return to narrative. Returns summary with rounds, " 513 + "defeated, and survivor HP. Use the duration in your set_scene call."), 514 + "input_schema": _NO_INPUT}, 515 + ] 516 + 517 + ALL_INITIATIVE_TOOL_NAMES: set[str] = ( 518 + {ENTER_INITIATIVE_DEFINITION["name"]} 519 + | {d["name"] for d in COMBAT_TOOL_DEFINITIONS} 520 + ) 521 + 522 + INITIATIVE_KEEP_NARRATIVE: frozenset[str] = frozenset({ 523 + "roll", "recall", "update_character", 524 + })
+24 -4
src/storied/mcp_server.py
··· 19 19 20 20 from storied.log import CampaignLog 21 21 from storied.search import VectorIndex 22 + from storied.initiative import ( 23 + COMBAT_TOOL_DEFINITIONS, 24 + ENTER_INITIATIVE_DEFINITION, 25 + INITIATIVE_KEEP_NARRATIVE, 26 + ) 22 27 from storied.tools import ( 23 28 EntityIndex, 24 29 PLANNER_TOOL_DEFINITIONS, ··· 31 36 ) 32 37 33 38 TOOL_SETS: dict[str, list[dict]] = { 34 - "dm": TOOL_DEFINITIONS, 35 39 "planner": PLANNER_TOOL_DEFINITIONS, 36 40 "seeder": SEEDER_TOOL_DEFINITIONS, 37 41 } ··· 41 45 "planner": planner_execute_tool, 42 46 "seeder": seeder_execute_tool, 43 47 } 48 + 49 + 50 + def _dm_tool_definitions(ctx: ToolContext) -> list[dict]: 51 + """Return DM tools based on whether initiative is active.""" 52 + if ctx.initiative.active: 53 + kept = [d for d in TOOL_DEFINITIONS if d["name"] in INITIATIVE_KEEP_NARRATIVE] 54 + return kept + COMBAT_TOOL_DEFINITIONS 55 + return TOOL_DEFINITIONS + [ENTER_INITIATIVE_DEFINITION] 44 56 45 57 46 58 def _to_mcp_tool(defn: dict) -> Tool: ··· 112 124 vector_index=VectorIndex(world_dir / "search.db", on_empty=_populate_index), 113 125 ) 114 126 115 - definitions = TOOL_SETS.get(tool_set, TOOL_DEFINITIONS) 127 + static_definitions = TOOL_SETS.get(tool_set) 116 128 executor = EXECUTORS.get(tool_set, execute_tool) 117 - mcp_tools = [_to_mcp_tool(d) for d in definitions] 118 129 119 130 mcp = Server("storied") 120 131 121 132 @mcp.list_tools() 122 133 async def list_tools() -> list[Tool]: 123 - return mcp_tools 134 + if tool_set == "dm": 135 + return [_to_mcp_tool(d) for d in _dm_tool_definitions(ctx)] 136 + return [_to_mcp_tool(d) for d in (static_definitions or TOOL_DEFINITIONS)] 124 137 125 138 @mcp.call_tool() 126 139 async def call_tool(name: str, arguments: dict) -> list[TextContent]: 140 + if tool_set == "dm": 141 + allowed = {d["name"] for d in _dm_tool_definitions(ctx)} 142 + if name not in allowed: 143 + return [TextContent( 144 + type="text", 145 + text=f"Tool '{name}' not available in current mode.", 146 + )] 127 147 result = executor(name, arguments, ctx) 128 148 return [TextContent(type="text", text=str(result))] 129 149
+23 -1
src/storied/tools.py
··· 6 6 7 7 import re 8 8 import threading 9 - from dataclasses import dataclass 9 + from dataclasses import dataclass, field 10 10 from pathlib import Path 11 11 12 12 import yaml 13 13 14 14 from storied.character import create_character as char_create 15 + from storied.initiative import ( 16 + ALL_INITIATIVE_TOOL_NAMES, 17 + InitiativeTracker, 18 + execute_initiative_tool, 19 + ) 15 20 from storied.character import update_character as char_update 16 21 from storied.dice import roll as dice_roll 17 22 from storied.log import CampaignLog ··· 79 84 campaign_log: CampaignLog 80 85 entity_index: EntityIndex 81 86 vector_index: VectorIndex 87 + initiative: InitiativeTracker = field(default_factory=InitiativeTracker) 88 + 89 + 90 + def _sync_player_hp(target: str, ctx: ToolContext, result: str) -> str: 91 + """Auto-sync player character sheet when damage/heal targets a player.""" 92 + combatant = ctx.initiative._find(target) 93 + if combatant and combatant.is_player: 94 + char_update(ctx.player_id, {"hp.current": combatant.hp}, ctx.base_path) 95 + result += f" (character sheet synced to {combatant.hp} HP)" 96 + return result 82 97 83 98 84 99 def roll(notation: str, reason: str | None = None) -> dict: ··· 979 994 980 995 def execute_tool(tool_name: str, tool_input: dict, ctx: ToolContext) -> str: 981 996 """Execute a tool by name with the given input.""" 997 + if tool_name in ALL_INITIATIVE_TOOL_NAMES: 998 + result = execute_initiative_tool(tool_name, tool_input, ctx.initiative) 999 + if result is not None: 1000 + if tool_name in ("damage", "heal"): 1001 + result = _sync_player_hp(tool_input["target"], ctx, result) 1002 + return result 1003 + 982 1004 if tool_name == "roll": 983 1005 result = roll(tool_input["notation"]) 984 1006 rolls_str = ", ".join(str(r) for r in result["rolls"])
+2
tests/test_engine.py
··· 75 75 (prompts_dir / "dm-system.md").write_text("You are a DM.") 76 76 77 77 with patch("storied.engine.start_mcp_server") as mock_mcp: 78 + from storied.initiative import InitiativeTracker 78 79 from storied.tools import EntityIndex 79 80 80 81 mock_mcp.return_value = type("Handle", (), { ··· 82 83 "ctx": type("Ctx", (), { 83 84 "entity_index": EntityIndex(world_dir), 84 85 "vector_index": None, 86 + "initiative": InitiativeTracker(), 85 87 })(), 86 88 })() 87 89 return DMEngine(
+99
tests/test_execute_tool.py
··· 1 1 """Tests for execute_tool dispatch and uncovered tool functions.""" 2 2 3 + from storied.initiative import Combatant 3 4 from storied.tools import ( 4 5 ToolContext, 5 6 _auto_mark_present, ··· 232 233 marked = _auto_mark_present(["[[Nobody]]"], "event", ctx) 233 234 234 235 assert marked == [] 236 + 237 + 238 + class TestInitiativeViaExecuteTool: 239 + """Tests that initiative tools route through execute_tool.""" 240 + 241 + def test_enter_initiative(self, ctx: ToolContext): 242 + result = execute_tool("enter_initiative", { 243 + "combatants": [ 244 + {"name": "Kira", "initiative": 18, "hp": 25, "hp_max": 25, "ac": 16, "is_player": True}, 245 + {"name": "Goblin", "initiative": 10, "hp": 7, "hp_max": 7, "ac": 15}, 246 + ], 247 + }, ctx) 248 + 249 + assert "Initiative started" in result 250 + assert ctx.initiative.active 251 + 252 + def test_damage_via_execute_tool(self, ctx: ToolContext): 253 + ctx.initiative.begin([ 254 + Combatant(name="Goblin", initiative=10, hp=7, hp_max=7, ac=15), 255 + ]) 256 + 257 + result = execute_tool("damage", {"target": "Goblin", "amount": 3}, ctx) 258 + 259 + assert "3" in result 260 + assert ctx.initiative._find("Goblin").hp == 4 261 + 262 + def test_damage_syncs_player_hp(self, ctx: ToolContext): 263 + execute_tool("create_character", { 264 + "name": "Kira", "race": "Human", "char_class": "Fighter", 265 + "level": 1, "abilities": { 266 + "strength": 16, "dexterity": 12, "constitution": 14, 267 + "intelligence": 10, "wisdom": 13, "charisma": 8, 268 + }, 269 + "hp_max": 25, "ac": 16, 270 + }, ctx) 271 + ctx.initiative.begin([ 272 + Combatant(name="Kira", initiative=18, hp=25, hp_max=25, ac=16, is_player=True), 273 + ]) 274 + 275 + result = execute_tool("damage", {"target": "Kira", "amount": 7}, ctx) 276 + 277 + assert "synced" in result 278 + from storied.character import load_character 279 + char = load_character(ctx.player_id, ctx.base_path) 280 + assert char["hp"]["current"] == 18 281 + 282 + def test_heal_syncs_player_hp(self, ctx: ToolContext): 283 + execute_tool("create_character", { 284 + "name": "Kira", "race": "Human", "char_class": "Fighter", 285 + "level": 1, "abilities": { 286 + "strength": 16, "dexterity": 12, "constitution": 14, 287 + "intelligence": 10, "wisdom": 13, "charisma": 8, 288 + }, 289 + "hp_max": 25, "ac": 16, 290 + }, ctx) 291 + ctx.initiative.begin([ 292 + Combatant(name="Kira", initiative=18, hp=20, hp_max=25, ac=16, is_player=True), 293 + ]) 294 + 295 + result = execute_tool("heal", {"target": "Kira", "amount": 3}, ctx) 296 + 297 + assert "synced" in result 298 + from storied.character import load_character 299 + char = load_character(ctx.player_id, ctx.base_path) 300 + assert char["hp"]["current"] == 23 301 + 302 + def test_damage_no_sync_for_non_player(self, ctx: ToolContext): 303 + ctx.initiative.begin([ 304 + Combatant(name="Goblin", initiative=10, hp=7, hp_max=7, ac=15), 305 + ]) 306 + 307 + result = execute_tool("damage", {"target": "Goblin", "amount": 3}, ctx) 308 + 309 + assert "synced" not in result 310 + 311 + def test_end_initiative_via_execute_tool(self, ctx: ToolContext): 312 + ctx.initiative.begin([ 313 + Combatant(name="Goblin", initiative=10, hp=7, hp_max=7, ac=15), 314 + ]) 315 + 316 + result = execute_tool("end_initiative", {}, ctx) 317 + 318 + assert "ended" in result.lower() 319 + assert not ctx.initiative.active 320 + 321 + def test_initiative_tools_not_in_planner(self, ctx: ToolContext): 322 + result = planner_execute_tool("enter_initiative", { 323 + "combatants": [], 324 + }, ctx) 325 + 326 + assert "not available" in result 327 + 328 + def test_initiative_tools_not_in_seeder(self, ctx: ToolContext): 329 + result = seeder_execute_tool("damage", { 330 + "target": "Goblin", "amount": 5, 331 + }, ctx) 332 + 333 + assert "not available" in result 235 334 236 335 237 336 class TestSeederExecuteTool:
+507
tests/test_initiative.py
··· 1 + """Tests for initiative tracking system.""" 2 + 3 + import pytest 4 + 5 + from storied.initiative import ( 6 + ALL_INITIATIVE_TOOL_NAMES, 7 + COMBAT_TOOL_DEFINITIONS, 8 + ENTER_INITIATIVE_DEFINITION, 9 + INITIATIVE_KEEP_NARRATIVE, 10 + Combatant, 11 + InitiativeTracker, 12 + TrackedCondition, 13 + execute_initiative_tool, 14 + ) 15 + 16 + 17 + @pytest.fixture 18 + def tracker() -> InitiativeTracker: 19 + return InitiativeTracker() 20 + 21 + 22 + @pytest.fixture 23 + def combatants() -> list[Combatant]: 24 + """Three combatants in initiative order (pre-sorted by DM).""" 25 + return [ 26 + Combatant(name="Kira", initiative=18, hp=25, hp_max=25, ac=16, is_player=True), 27 + Combatant(name="Goblin 1", initiative=14, hp=7, hp_max=7, ac=15), 28 + Combatant(name="Goblin 2", initiative=10, hp=7, hp_max=7, ac=15), 29 + ] 30 + 31 + 32 + class TestTrackerLifecycle: 33 + def test_starts_inactive(self, tracker: InitiativeTracker): 34 + assert not tracker.active 35 + 36 + def test_begin_activates(self, tracker: InitiativeTracker, combatants: list[Combatant]): 37 + tracker.begin(combatants) 38 + 39 + assert tracker.active 40 + assert tracker.round == 1 41 + assert tracker.current_index == 0 42 + 43 + def test_begin_preserves_list_order( 44 + self, tracker: InitiativeTracker, combatants: list[Combatant], 45 + ): 46 + tracker.begin(combatants) 47 + 48 + assert [c.name for c in tracker.combatants] == ["Kira", "Goblin 1", "Goblin 2"] 49 + 50 + def test_end_deactivates(self, tracker: InitiativeTracker, combatants: list[Combatant]): 51 + tracker.begin(combatants) 52 + summary = tracker.end() 53 + 54 + assert not tracker.active 55 + assert "1" in summary # round count 56 + 57 + def test_end_reports_defeated( 58 + self, tracker: InitiativeTracker, combatants: list[Combatant], 59 + ): 60 + tracker.begin(combatants) 61 + tracker.apply_damage("Goblin 1", 7) 62 + summary = tracker.end() 63 + 64 + assert "Goblin 1" in summary 65 + assert "defeated" in summary.lower() 66 + 67 + def test_end_reports_duration( 68 + self, tracker: InitiativeTracker, combatants: list[Combatant], 69 + ): 70 + tracker.begin(combatants) 71 + tracker.next_turn() 72 + tracker.next_turn() 73 + tracker.next_turn() # back to round 2 74 + summary = tracker.end() 75 + 76 + assert "2" in summary # round 2 77 + 78 + 79 + class TestTurnAdvancement: 80 + def test_next_turn_advances( 81 + self, tracker: InitiativeTracker, combatants: list[Combatant], 82 + ): 83 + tracker.begin(combatants) 84 + assert tracker.current_combatant.name == "Kira" 85 + 86 + result = tracker.next_turn() 87 + 88 + assert tracker.current_combatant.name == "Goblin 1" 89 + assert "Goblin 1" in result 90 + 91 + def test_round_wraps(self, tracker: InitiativeTracker, combatants: list[Combatant]): 92 + tracker.begin(combatants) 93 + tracker.next_turn() # -> Goblin 1 94 + tracker.next_turn() # -> Goblin 2 95 + result = tracker.next_turn() # -> Kira, round 2 96 + 97 + assert tracker.round == 2 98 + assert tracker.current_combatant.name == "Kira" 99 + assert "Round 2" in result 100 + 101 + def test_skips_defeated( 102 + self, tracker: InitiativeTracker, combatants: list[Combatant], 103 + ): 104 + tracker.begin(combatants) 105 + tracker.apply_damage("Goblin 1", 7) # defeat Goblin 1 106 + tracker.next_turn() # should skip Goblin 1 -> Goblin 2 107 + 108 + assert tracker.current_combatant.name == "Goblin 2" 109 + 110 + def test_hints_one_side_remaining( 111 + self, tracker: InitiativeTracker, combatants: list[Combatant], 112 + ): 113 + tracker.begin(combatants) 114 + tracker.apply_damage("Goblin 1", 7) 115 + tracker.apply_damage("Goblin 2", 7) 116 + result = tracker.next_turn() # wraps to Kira 117 + 118 + assert "only" in result.lower() or "remaining" in result.lower() 119 + 120 + 121 + class TestDamageAndHealing: 122 + def test_damage_reduces_hp( 123 + self, tracker: InitiativeTracker, combatants: list[Combatant], 124 + ): 125 + tracker.begin(combatants) 126 + result = tracker.apply_damage("Goblin 1", 3) 127 + 128 + goblin = tracker._find("Goblin 1") 129 + assert goblin.hp == 4 130 + assert "3" in result # damage amount 131 + 132 + def test_damage_defeats_at_zero( 133 + self, tracker: InitiativeTracker, combatants: list[Combatant], 134 + ): 135 + tracker.begin(combatants) 136 + result = tracker.apply_damage("Goblin 1", 7) 137 + 138 + goblin = tracker._find("Goblin 1") 139 + assert goblin.hp == 0 140 + assert goblin.defeated 141 + assert "down" in result.lower() 142 + 143 + def test_damage_clamps_to_zero( 144 + self, tracker: InitiativeTracker, combatants: list[Combatant], 145 + ): 146 + tracker.begin(combatants) 147 + tracker.apply_damage("Goblin 1", 100) 148 + 149 + assert tracker._find("Goblin 1").hp == 0 150 + 151 + def test_damage_reports_bloodied( 152 + self, tracker: InitiativeTracker, combatants: list[Combatant], 153 + ): 154 + tracker.begin(combatants) 155 + result = tracker.apply_damage("Kira", 13) # 25 -> 12, half is 12 156 + 157 + assert "bloodied" in result.lower() 158 + 159 + def test_heal_increases_hp( 160 + self, tracker: InitiativeTracker, combatants: list[Combatant], 161 + ): 162 + tracker.begin(combatants) 163 + tracker.apply_damage("Kira", 10) 164 + result = tracker.apply_heal("Kira", 5) 165 + 166 + assert tracker._find("Kira").hp == 20 167 + assert "5" in result 168 + 169 + def test_heal_clamps_to_max( 170 + self, tracker: InitiativeTracker, combatants: list[Combatant], 171 + ): 172 + tracker.begin(combatants) 173 + tracker.apply_damage("Kira", 5) 174 + tracker.apply_heal("Kira", 100) 175 + 176 + assert tracker._find("Kira").hp == 25 177 + 178 + def test_heal_revives_defeated( 179 + self, tracker: InitiativeTracker, combatants: list[Combatant], 180 + ): 181 + tracker.begin(combatants) 182 + tracker.apply_damage("Goblin 1", 7) 183 + assert tracker._find("Goblin 1").defeated 184 + 185 + tracker.apply_heal("Goblin 1", 3) 186 + 187 + goblin = tracker._find("Goblin 1") 188 + assert goblin.hp == 3 189 + assert not goblin.defeated 190 + 191 + def test_damage_unknown_target( 192 + self, tracker: InitiativeTracker, combatants: list[Combatant], 193 + ): 194 + tracker.begin(combatants) 195 + result = tracker.apply_damage("Nobody", 5) 196 + 197 + assert "not found" in result.lower() 198 + 199 + def test_heal_unknown_target( 200 + self, tracker: InitiativeTracker, combatants: list[Combatant], 201 + ): 202 + tracker.begin(combatants) 203 + result = tracker.apply_heal("Nobody", 5) 204 + 205 + assert "not found" in result.lower() 206 + 207 + 208 + class TestConditions: 209 + def test_add_condition( 210 + self, tracker: InitiativeTracker, combatants: list[Combatant], 211 + ): 212 + tracker.begin(combatants) 213 + result = tracker.add_condition("Goblin 1", "Prone", duration=-1, source="Kira") 214 + 215 + goblin = tracker._find("Goblin 1") 216 + assert len(goblin.conditions) == 1 217 + assert goblin.conditions[0].name == "Prone" 218 + assert "Prone" in result 219 + 220 + def test_remove_condition( 221 + self, tracker: InitiativeTracker, combatants: list[Combatant], 222 + ): 223 + tracker.begin(combatants) 224 + tracker.add_condition("Goblin 1", "Prone", duration=-1, source="Kira") 225 + result = tracker.remove_condition("Goblin 1", "Prone") 226 + 227 + goblin = tracker._find("Goblin 1") 228 + assert len(goblin.conditions) == 0 229 + assert "Prone" in result 230 + 231 + def test_remove_nonexistent_condition( 232 + self, tracker: InitiativeTracker, combatants: list[Combatant], 233 + ): 234 + tracker.begin(combatants) 235 + result = tracker.remove_condition("Goblin 1", "Invisible") 236 + 237 + assert "not found" in result.lower() or "no" in result.lower() 238 + 239 + def test_condition_unknown_target( 240 + self, tracker: InitiativeTracker, combatants: list[Combatant], 241 + ): 242 + tracker.begin(combatants) 243 + result = tracker.add_condition("Nobody", "Prone", duration=-1, source="Kira") 244 + 245 + assert "not found" in result.lower() 246 + 247 + def test_effect_expires_end_of_source_turn( 248 + self, tracker: InitiativeTracker, combatants: list[Combatant], 249 + ): 250 + """Effect with ends_on='end' expires when leaving source's turn.""" 251 + tracker.begin(combatants) 252 + # Kira (idx 0) applies 1-round effect on Goblin 1, ends at end of Kira's turn 253 + tracker.add_condition( 254 + "Goblin 1", "Stunned", duration=1, ends_on="end", source="Kira", 255 + ) 256 + 257 + # Advance from Kira's turn -> processes end-of-Kira effects 258 + tracker.next_turn() # Kira -> Goblin 1 259 + 260 + goblin = tracker._find("Goblin 1") 261 + assert not any(c.name == "Stunned" for c in goblin.conditions) 262 + 263 + def test_effect_expires_start_of_source_turn( 264 + self, tracker: InitiativeTracker, combatants: list[Combatant], 265 + ): 266 + """Effect with ends_on='start' expires when arriving at source's turn.""" 267 + tracker.begin(combatants) 268 + # Kira applies 1-round effect, ends at start of Kira's next turn 269 + tracker.add_condition( 270 + "Goblin 1", "Frightened", duration=1, ends_on="start", source="Kira", 271 + ) 272 + 273 + # Full round: Kira -> G1 -> G2 -> Kira (round 2, start of Kira's turn) 274 + tracker.next_turn() # -> Goblin 1 275 + tracker.next_turn() # -> Goblin 2 276 + tracker.next_turn() # -> Kira (round 2) 277 + 278 + goblin = tracker._find("Goblin 1") 279 + assert not any(c.name == "Frightened" for c in goblin.conditions) 280 + 281 + def test_indefinite_condition_persists( 282 + self, tracker: InitiativeTracker, combatants: list[Combatant], 283 + ): 284 + """Duration -1 never auto-expires.""" 285 + tracker.begin(combatants) 286 + tracker.add_condition("Goblin 1", "Grappled", duration=-1, source="Kira") 287 + 288 + # Full round 289 + for _ in range(3): 290 + tracker.next_turn() 291 + 292 + goblin = tracker._find("Goblin 1") 293 + assert any(c.name == "Grappled" for c in goblin.conditions) 294 + 295 + def test_multi_round_duration( 296 + self, tracker: InitiativeTracker, combatants: list[Combatant], 297 + ): 298 + """2-round effect lasts through 2 full rounds of the source's turns.""" 299 + tracker.begin(combatants) 300 + tracker.add_condition( 301 + "Goblin 1", "Held", duration=2, ends_on="end", source="Kira", 302 + ) 303 + 304 + # Round 1: Kira -> G1 -> G2 (end of Kira's turn, duration 2 -> 1) 305 + tracker.next_turn() # -> G1 306 + goblin = tracker._find("Goblin 1") 307 + assert any(c.name == "Held" for c in goblin.conditions) 308 + 309 + tracker.next_turn() # -> G2 310 + tracker.next_turn() # -> Kira (round 2) 311 + 312 + # Round 2: leaving Kira's turn (duration 1 -> 0, expires) 313 + tracker.next_turn() # -> G1 314 + 315 + goblin = tracker._find("Goblin 1") 316 + assert not any(c.name == "Held" for c in goblin.conditions) 317 + 318 + 319 + class TestAddRemoveCombatant: 320 + def test_add_combatant( 321 + self, tracker: InitiativeTracker, combatants: list[Combatant], 322 + ): 323 + tracker.begin(combatants) 324 + result = tracker.add_combatant( 325 + Combatant(name="Archer", initiative=12, hp=9, hp_max=9, ac=13), 326 + ) 327 + 328 + assert len(tracker.combatants) == 4 329 + assert "Archer" in result 330 + 331 + def test_add_inserts_by_initiative( 332 + self, tracker: InitiativeTracker, combatants: list[Combatant], 333 + ): 334 + tracker.begin(combatants) # Kira(18), G1(14), G2(10) 335 + tracker.add_combatant( 336 + Combatant(name="Archer", initiative=12, hp=9, hp_max=9, ac=13), 337 + ) 338 + 339 + names = [c.name for c in tracker.combatants] 340 + assert names == ["Kira", "Goblin 1", "Archer", "Goblin 2"] 341 + 342 + def test_add_before_current_adjusts_index( 343 + self, tracker: InitiativeTracker, combatants: list[Combatant], 344 + ): 345 + tracker.begin(combatants) 346 + tracker.next_turn() # -> Goblin 1 (index 1) 347 + 348 + # Add someone with higher initiative (inserted before current) 349 + tracker.add_combatant( 350 + Combatant(name="Archer", initiative=16, hp=9, hp_max=9, ac=13), 351 + ) 352 + 353 + # Current should still be Goblin 1 354 + assert tracker.current_combatant.name == "Goblin 1" 355 + 356 + def test_remove_combatant( 357 + self, tracker: InitiativeTracker, combatants: list[Combatant], 358 + ): 359 + tracker.begin(combatants) 360 + result = tracker.remove_combatant("Goblin 2") 361 + 362 + assert len(tracker.combatants) == 2 363 + assert "Goblin 2" in result 364 + 365 + def test_remove_current_advances( 366 + self, tracker: InitiativeTracker, combatants: list[Combatant], 367 + ): 368 + tracker.begin(combatants) 369 + tracker.next_turn() # -> Goblin 1 370 + tracker.remove_combatant("Goblin 1") 371 + 372 + # Should advance to Goblin 2 (or adjust so current is valid) 373 + assert tracker.current_combatant.name == "Goblin 2" 374 + 375 + def test_remove_unknown( 376 + self, tracker: InitiativeTracker, combatants: list[Combatant], 377 + ): 378 + tracker.begin(combatants) 379 + result = tracker.remove_combatant("Nobody") 380 + 381 + assert "not found" in result.lower() 382 + 383 + 384 + class TestFormatForContext: 385 + def test_includes_table( 386 + self, tracker: InitiativeTracker, combatants: list[Combatant], 387 + ): 388 + tracker.begin(combatants) 389 + context = tracker.format_for_context() 390 + 391 + assert "Kira" in context 392 + assert "Goblin 1" in context 393 + assert "25/25" in context # HP display 394 + 395 + def test_marks_current_turn( 396 + self, tracker: InitiativeTracker, combatants: list[Combatant], 397 + ): 398 + tracker.begin(combatants) 399 + context = tracker.format_for_context() 400 + 401 + assert "**Kira**" in context # current combatant is bold 402 + assert "Current turn" in context 403 + 404 + def test_shows_defeated( 405 + self, tracker: InitiativeTracker, combatants: list[Combatant], 406 + ): 407 + tracker.begin(combatants) 408 + tracker.apply_damage("Goblin 1", 7) 409 + context = tracker.format_for_context() 410 + 411 + assert "~~Goblin 1~~" in context or "Defeated" in context 412 + 413 + def test_shows_conditions( 414 + self, tracker: InitiativeTracker, combatants: list[Combatant], 415 + ): 416 + tracker.begin(combatants) 417 + tracker.add_condition("Goblin 1", "Prone", duration=-1, source="Kira") 418 + context = tracker.format_for_context() 419 + 420 + assert "Prone" in context 421 + 422 + def test_shows_up_next( 423 + self, tracker: InitiativeTracker, combatants: list[Combatant], 424 + ): 425 + tracker.begin(combatants) 426 + context = tracker.format_for_context() 427 + 428 + assert "Up next" in context 429 + assert "Goblin 1" in context.split("Up next")[1] 430 + 431 + def test_shows_round( 432 + self, tracker: InitiativeTracker, combatants: list[Combatant], 433 + ): 434 + tracker.begin(combatants) 435 + context = tracker.format_for_context() 436 + 437 + assert "Round" in context 438 + 439 + 440 + class TestDispatch: 441 + def test_all_tool_names_present(self): 442 + expected = { 443 + "enter_initiative", "next_turn", "add_combatant", 444 + "remove_combatant", "damage", "heal", "condition", 445 + "end_initiative", 446 + } 447 + assert ALL_INITIATIVE_TOOL_NAMES == expected 448 + 449 + def test_enter_initiative_definition_exists(self): 450 + assert ENTER_INITIATIVE_DEFINITION["name"] == "enter_initiative" 451 + 452 + def test_combat_definitions_exclude_enter(self): 453 + names = {d["name"] for d in COMBAT_TOOL_DEFINITIONS} 454 + assert "enter_initiative" not in names 455 + assert "next_turn" in names 456 + 457 + def test_keep_narrative_tools(self): 458 + assert INITIATIVE_KEEP_NARRATIVE == frozenset({"roll", "recall", "update_character"}) 459 + 460 + def test_dispatch_routes_enter(self, tracker: InitiativeTracker): 461 + result = execute_initiative_tool( 462 + "enter_initiative", 463 + {"combatants": [ 464 + {"name": "Kira", "initiative": 18, "hp": 25, "hp_max": 25, "ac": 16, "is_player": True}, 465 + {"name": "Goblin", "initiative": 10, "hp": 7, "hp_max": 7, "ac": 15}, 466 + ]}, 467 + tracker, 468 + ) 469 + 470 + assert tracker.active 471 + assert "Kira" in result 472 + 473 + def test_dispatch_routes_damage(self, tracker: InitiativeTracker): 474 + tracker.begin([ 475 + Combatant(name="Goblin", initiative=10, hp=7, hp_max=7, ac=15), 476 + ]) 477 + 478 + result = execute_initiative_tool( 479 + "damage", {"target": "Goblin", "amount": 3}, tracker, 480 + ) 481 + 482 + assert "3" in result 483 + 484 + def test_dispatch_unknown_tool(self, tracker: InitiativeTracker): 485 + result = execute_initiative_tool("fake_tool", {}, tracker) 486 + 487 + assert result is None 488 + 489 + def test_guard_when_inactive(self, tracker: InitiativeTracker): 490 + result = execute_initiative_tool( 491 + "next_turn", {}, tracker, 492 + ) 493 + 494 + assert "not active" in result.lower() 495 + 496 + def test_enter_errors_when_active(self, tracker: InitiativeTracker): 497 + tracker.begin([ 498 + Combatant(name="Goblin", initiative=10, hp=7, hp_max=7, ac=15), 499 + ]) 500 + 501 + result = execute_initiative_tool( 502 + "enter_initiative", 503 + {"combatants": [{"name": "X", "initiative": 1, "hp": 1, "hp_max": 1, "ac": 10}]}, 504 + tracker, 505 + ) 506 + 507 + assert "already active" in result.lower()
+84 -2
tests/test_mcp_server.py
··· 2 2 3 3 import pytest 4 4 5 - from storied.mcp_server import _to_mcp_tool 6 - from storied.tools import TOOL_DEFINITIONS 5 + from storied.initiative import ( 6 + COMBAT_TOOL_DEFINITIONS, 7 + ENTER_INITIATIVE_DEFINITION, 8 + INITIATIVE_KEEP_NARRATIVE, 9 + Combatant, 10 + ) 11 + from storied.mcp_server import _dm_tool_definitions, _to_mcp_tool 12 + from storied.tools import TOOL_DEFINITIONS, ToolContext 7 13 8 14 9 15 class TestToMcpTool: ··· 31 37 tool = _to_mcp_tool(defn) 32 38 assert tool.name == defn["name"] 33 39 assert tool.inputSchema is not None 40 + 41 + 42 + class TestDynamicDmTools: 43 + """Tests that the DM tool list changes based on initiative state.""" 44 + 45 + def test_narrative_mode_includes_enter_initiative(self, ctx: ToolContext): 46 + defs = _dm_tool_definitions(ctx) 47 + names = {d["name"] for d in defs} 48 + 49 + assert "enter_initiative" in names 50 + 51 + def test_narrative_mode_includes_all_narrative_tools(self, ctx: ToolContext): 52 + defs = _dm_tool_definitions(ctx) 53 + names = {d["name"] for d in defs} 54 + 55 + assert "set_scene" in names 56 + assert "establish" in names 57 + assert "end_session" in names 58 + 59 + def test_narrative_mode_excludes_combat_tools(self, ctx: ToolContext): 60 + defs = _dm_tool_definitions(ctx) 61 + names = {d["name"] for d in defs} 62 + 63 + assert "next_turn" not in names 64 + assert "damage" not in names 65 + assert "end_initiative" not in names 66 + 67 + def test_narrative_mode_count(self, ctx: ToolContext): 68 + defs = _dm_tool_definitions(ctx) 69 + 70 + assert len(defs) == len(TOOL_DEFINITIONS) + 1 # +1 for enter_initiative 71 + 72 + def test_initiative_mode_includes_combat_tools(self, ctx: ToolContext): 73 + ctx.initiative.begin([ 74 + Combatant(name="Kira", initiative=18, hp=25, hp_max=25, ac=16), 75 + ]) 76 + 77 + defs = _dm_tool_definitions(ctx) 78 + names = {d["name"] for d in defs} 79 + 80 + assert "next_turn" in names 81 + assert "damage" in names 82 + assert "end_initiative" in names 83 + 84 + def test_initiative_mode_keeps_narrative_subset(self, ctx: ToolContext): 85 + ctx.initiative.begin([ 86 + Combatant(name="Kira", initiative=18, hp=25, hp_max=25, ac=16), 87 + ]) 88 + 89 + defs = _dm_tool_definitions(ctx) 90 + names = {d["name"] for d in defs} 91 + 92 + for tool_name in INITIATIVE_KEEP_NARRATIVE: 93 + assert tool_name in names 94 + 95 + def test_initiative_mode_excludes_narrative_only_tools(self, ctx: ToolContext): 96 + ctx.initiative.begin([ 97 + Combatant(name="Kira", initiative=18, hp=25, hp_max=25, ac=16), 98 + ]) 99 + 100 + defs = _dm_tool_definitions(ctx) 101 + names = {d["name"] for d in defs} 102 + 103 + assert "set_scene" not in names 104 + assert "establish" not in names 105 + assert "enter_initiative" not in names 106 + 107 + def test_initiative_mode_count(self, ctx: ToolContext): 108 + ctx.initiative.begin([ 109 + Combatant(name="Kira", initiative=18, hp=25, hp_max=25, ac=16), 110 + ]) 111 + 112 + defs = _dm_tool_definitions(ctx) 113 + expected = len(INITIATIVE_KEEP_NARRATIVE) + len(COMBAT_TOOL_DEFINITIONS) 114 + 115 + assert len(defs) == expected