A 5e storytelling engine with an LLM DM
1# pyright: reportOptionalSubscript=false, reportOptionalMemberAccess=false
2# pyright: reportCallIssue=false
3# Tests subscript load_character / ctx.initiative._find results without
4# null-narrowing — setup guarantees the values exist.
5"""Tests for tool dispatch via the FastMCP in-memory client.
6
7These tests exercise the same call path the production server uses
8(claude → MCP → tool function), but in-process via fastmcp.Client.
9"""
10
11from pathlib import Path
12
13import pytest
14from conftest import McpCall, McpCallInCombat
15
16from storied.character import load_character
17from storied.initiative import Combatant
18from storied.testing import call_tool
19from storied.tools import ToolContext
20from storied.tools.entities import note_discovery as _note_discovery
21from storied.tools.scene import end_session as _end_session
22
23# --- Dispatch through the in-memory client ----------------------------------
24
25
26class TestToolDispatch:
27 def test_roll_with_modifier(self, ctx: ToolContext, mcp_call: McpCall):
28 result = mcp_call("roll", {"notation": "1d20+5", "reason": "attack"})
29 assert "Rolled" in result
30 assert "1d20+5" in result
31
32 def test_roll_no_modifier(self, ctx: ToolContext, mcp_call: McpCall):
33 result = mcp_call("roll", {"notation": "1d6", "reason": "damage"})
34 assert "Rolled" in result
35
36 def test_recall_finds_nothing(self, ctx: ToolContext, mcp_call: McpCall):
37 result = mcp_call("recall", {"query": "nonexistent"})
38 assert "Nothing found" in result
39
40 def test_set_scene(self, ctx: ToolContext, mcp_call: McpCall):
41 result = mcp_call(
42 "set_scene", {"event": "Spoke with guards", "duration": "10 min"}
43 )
44 assert result
45
46 def test_establish(self, ctx: ToolContext, mcp_call: McpCall):
47 result = mcp_call("establish", {"entity_type": "npcs", "name": "Test NPC"})
48 assert "Established" in result
49
50 def test_mark(self, ctx: ToolContext, mcp_call: McpCall):
51 mcp_call("establish", {"entity_type": "npcs", "name": "Vera"})
52 result = mcp_call(
53 "mark",
54 {
55 "entity_type": "npcs",
56 "name": "Vera",
57 "event": "Revealed her secret",
58 },
59 )
60 assert "Marked" in result
61
62 def test_note_discovery(self, ctx: ToolContext, mcp_call: McpCall):
63 result = mcp_call(
64 "note_discovery",
65 {
66 "entity": "Vera Blackwater",
67 "content": "She used to be a smuggler",
68 },
69 )
70 assert "Noted" in result
71
72 def test_end_session(self, ctx: ToolContext, mcp_call: McpCall):
73 result = mcp_call("end_session", {"situation": "In the tavern"})
74 assert result == "SESSION_ENDED"
75
76 def test_unknown_tool_raises(self, ctx: ToolContext, mcp_call: McpCall):
77 with pytest.raises(Exception, match="nonexistent"):
78 mcp_call("nonexistent", {})
79
80
81class TestUpdateCharacter:
82 def test_landed_in_state_hp_current(
83 self, ctx: ToolContext, tmp_path: Path, mcp_call: McpCall
84 ):
85 mcp_call(
86 "create_character",
87 {
88 "name": "Test",
89 "race": "Human",
90 "char_class": "Fighter",
91 "level": 1,
92 "abilities": {
93 "strength": 16,
94 "dexterity": 12,
95 "constitution": 14,
96 "intelligence": 10,
97 "wisdom": 13,
98 "charisma": 8,
99 },
100 "hp_max": 12,
101 "ac": 16,
102 },
103 )
104 result = mcp_call("update_character", {"updates": {"state.hp.current": 8}})
105 assert "updated" in result.lower()
106
107 data = load_character(ctx.player_id)
108 assert data["state"]["hp"]["current"] == 8, (
109 "update_character should write to state.hp.current with the new schema, "
110 f"but state.hp.current is {data['state']['hp']['current']}"
111 )
112
113
114# --- Sub-tool helper coverage -----------------------------------------------
115
116
117class TestNoteDiscoveryDirect:
118 """Direct (non-MCP) calls to note_discovery exercise the wrapper itself."""
119
120 def test_creates_knowledge_file(self, ctx: ToolContext, tmp_path: Path):
121 call_tool(
122 _note_discovery,
123 entity="The Rusty Anchor",
124 content="A seedy tavern on the docks",
125 )
126 knowledge_dir = (
127 tmp_path / "players" / ctx.player_id / "worlds" / ctx.world_id / "lore"
128 )
129 assert any(knowledge_dir.iterdir())
130
131 def test_with_content_type(self, ctx: ToolContext, tmp_path: Path):
132 call_tool(
133 _note_discovery, entity="Vera", content="Tavern owner", content_type="npcs"
134 )
135 knowledge_dir = (
136 tmp_path / "players" / ctx.player_id / "worlds" / ctx.world_id / "npcs"
137 )
138 assert any(knowledge_dir.iterdir())
139
140 def test_with_tags(self, ctx: ToolContext, tmp_path: Path):
141 call_tool(
142 _note_discovery,
143 entity="Old Map",
144 content="Shows a hidden passage",
145 tags=["quest"],
146 )
147 knowledge_dir = (
148 tmp_path / "players" / ctx.player_id / "worlds" / ctx.world_id / "lore"
149 )
150 content = next(knowledge_dir.iterdir()).read_text()
151 assert "quest" in content
152
153
154class TestEndSessionDirect:
155 def test_returns_session_ended(self, ctx: ToolContext):
156 result = call_tool(_end_session, situation="In the tavern")
157 assert result == "SESSION_ENDED"
158
159 def test_with_threads(self, ctx: ToolContext):
160 result = call_tool(
161 _end_session,
162 situation="In the tavern",
163 threads=["Find the merchant", "Investigate the warehouse"],
164 )
165 assert result == "SESSION_ENDED"
166
167
168# --- Recall with indexed content --------------------------------------------
169
170
171@pytest.fixture
172def three_indexed_npcs(ctx: ToolContext) -> list[str]:
173 """Index 3 dock-loitering NPCs and return their names."""
174 names = [f"NPC {i}" for i in range(3)]
175 for i, name in enumerate(names):
176 ctx.vector_index.upsert(
177 f"world:npcs/npc{i}.md:0",
178 f"NPC number {i} who hangs around the docks",
179 {
180 "source": "world",
181 "content_type": "npcs",
182 "path": f"/fake/npc{i}.md",
183 "title": name,
184 },
185 )
186 return names
187
188
189class TestRecall:
190 def test_recall_finds_indexed_entity(
191 self, ctx: ToolContext, tmp_path: Path, mcp_call: McpCall
192 ):
193 entity_dir = tmp_path / "worlds" / ctx.world_id / "npcs"
194 entity_dir.mkdir(parents=True, exist_ok=True)
195 entity_file = entity_dir / "Vera Blackwater.md"
196 entity_file.write_text("# Vera Blackwater\n\nTavern owner.\n")
197
198 ctx.vector_index.upsert(
199 "world:npcs/Vera Blackwater.md:0",
200 "Vera Blackwater. Tavern owner, former smuggler.",
201 {
202 "source": "world",
203 "content_type": "npcs",
204 "path": str(entity_file),
205 "title": "Vera Blackwater",
206 },
207 )
208
209 result = mcp_call("recall", {"query": "Vera Blackwater"})
210 assert "Vera Blackwater" in result
211
212 def test_recall_rules_scope(self, ctx: ToolContext, mcp_call: McpCall):
213 result = mcp_call("recall", {"query": "fireball", "scope": "rules"})
214 assert "Nothing found" in result
215
216 def test_recall_world_scope(self, ctx: ToolContext, mcp_call: McpCall):
217 result = mcp_call("recall", {"query": "something", "scope": "world"})
218 assert "Nothing found" in result
219
220 @pytest.mark.usefixtures("three_indexed_npcs")
221 def test_recall_multiple_hits(self, ctx: ToolContext, mcp_call: McpCall):
222 result = mcp_call("recall", {"query": "docks NPC"})
223 assert "Found" in result or "Nothing found" in result
224
225
226# --- Combat path: damage routes through the initiative tracker --------------
227
228
229class TestDamageHealCombat:
230 def test_damage_combatant_in_initiative(
231 self, ctx: ToolContext, mcp_call_in_combat: McpCallInCombat
232 ):
233 result = mcp_call_in_combat(
234 "damage",
235 {"target": "Goblin", "amount": 3},
236 [Combatant(name="Goblin", initiative=10, hp=7, hp_max=7, ac=15)],
237 )
238 assert "3" in result
239 assert ctx.initiative._find("Goblin").hp == 4
240
241 def test_damage_syncs_player_hp(
242 self,
243 ctx: ToolContext,
244 tmp_path: Path,
245 mcp_call: McpCall,
246 mcp_call_in_combat: McpCallInCombat,
247 ):
248 mcp_call(
249 "create_character",
250 {
251 "name": "Kira",
252 "race": "Human",
253 "char_class": "Fighter",
254 "level": 1,
255 "abilities": {
256 "strength": 16,
257 "dexterity": 12,
258 "constitution": 14,
259 "intelligence": 10,
260 "wisdom": 13,
261 "charisma": 8,
262 },
263 "hp_max": 25,
264 "ac": 16,
265 },
266 )
267
268 result = mcp_call_in_combat(
269 "damage",
270 {"target": "Kira", "amount": 7},
271 [
272 Combatant(
273 name="Kira", initiative=18, hp=25, hp_max=25, ac=16, is_player=True
274 )
275 ],
276 )
277
278 assert "synced" in result
279 char = load_character(ctx.player_id)
280 assert char["state"]["hp"]["current"] == 18, (
281 "_sync_player_hp must write to state.hp.current with the new schema"
282 )
283
284 def test_heal_syncs_player_hp(
285 self,
286 ctx: ToolContext,
287 tmp_path: Path,
288 mcp_call: McpCall,
289 mcp_call_in_combat: McpCallInCombat,
290 ):
291 mcp_call(
292 "create_character",
293 {
294 "name": "Kira",
295 "race": "Human",
296 "char_class": "Fighter",
297 "level": 1,
298 "abilities": {
299 "strength": 16,
300 "dexterity": 12,
301 "constitution": 14,
302 "intelligence": 10,
303 "wisdom": 13,
304 "charisma": 8,
305 },
306 "hp_max": 25,
307 "ac": 16,
308 },
309 )
310 mcp_call("update_character", {"updates": {"state.hp.current": 20}})
311
312 result = mcp_call_in_combat(
313 "heal",
314 {"target": "Kira", "amount": 3},
315 [
316 Combatant(
317 name="Kira", initiative=18, hp=20, hp_max=25, ac=16, is_player=True
318 )
319 ],
320 )
321
322 assert "synced" in result
323 char = load_character(ctx.player_id)
324 assert char["state"]["hp"]["current"] == 23, (
325 "_sync_player_hp must write to state.hp.current with the new schema"
326 )
327
328 def test_damage_no_sync_for_non_player(
329 self, ctx: ToolContext, mcp_call_in_combat: McpCallInCombat
330 ):
331 result = mcp_call_in_combat(
332 "damage",
333 {"target": "Goblin", "amount": 3},
334 [Combatant(name="Goblin", initiative=10, hp=7, hp_max=7, ac=15)],
335 )
336 assert "synced" not in result
337
338 def test_unknown_target_returns_error(self, ctx: ToolContext, mcp_call: McpCall):
339 result = mcp_call("damage", {"target": "Nobody", "amount": 5})
340 assert "No such target" in result
341
342
343# --- Combat tool wrappers (exercised via the in-memory client) --------------
344
345
346class TestCombatTools:
347 """The combat FastMCP wrappers route through the active InitiativeTracker.
348
349 These tests start initiative on the process-global ctx via enter_initiative,
350 flip combat tools visible, then drive each wrapper via the in-memory client
351 so the wrapper bodies (not just the underlying tracker) get exercised.
352 """
353
354 def test_enter_initiative_starts_combat(
355 self, ctx: ToolContext, mcp_call_in_combat: McpCallInCombat
356 ):
357 result = mcp_call_in_combat(
358 "next_turn",
359 {},
360 [
361 Combatant(
362 name="Kira", initiative=18, hp=25, hp_max=25, ac=16, is_player=True
363 ),
364 Combatant(name="Goblin", initiative=10, hp=7, hp_max=7, ac=15),
365 ],
366 )
367 assert ctx.initiative.active
368 # next_turn from Kira should advance to Goblin
369 assert "Goblin" in result
370
371 def test_enter_initiative_via_client(
372 self, ctx: ToolContext, mcp_call_in_combat: McpCallInCombat
373 ):
374 """Drive enter_initiative through the in-memory client so the
375 parsing + tracker.begin + flip path runs end-to-end."""
376 result = mcp_call_in_combat(
377 "enter_initiative",
378 {
379 "combatants": [
380 {
381 "name": "Kira",
382 "initiative": 18,
383 "hp": 25,
384 "hp_max": 25,
385 "ac": 16,
386 "is_player": True,
387 },
388 {
389 "name": "Goblin",
390 "initiative": 10,
391 "hp": 7,
392 "hp_max": 7,
393 "ac": 15,
394 },
395 ],
396 },
397 )
398 assert "Initiative started" in result or "Round 1" in result
399 assert ctx.initiative.active
400
401 def test_enter_initiative_when_already_active_errors(
402 self, ctx: ToolContext, mcp_call_in_combat: McpCallInCombat
403 ):
404 # Start combat manually so the wrapper hits the early-return guard
405 result = mcp_call_in_combat(
406 "enter_initiative",
407 {
408 "combatants": [
409 {
410 "name": "X",
411 "initiative": 1,
412 "hp": 1,
413 "hp_max": 1,
414 "ac": 10,
415 }
416 ],
417 },
418 [Combatant(name="Goblin", initiative=10, hp=7, hp_max=7, ac=15)],
419 )
420 assert "already active" in result.lower()
421
422 def test_add_combatant_inserts_into_initiative(
423 self, ctx: ToolContext, mcp_call_in_combat: McpCallInCombat
424 ):
425 result = mcp_call_in_combat(
426 "add_combatant",
427 {"name": "Reinforcement", "initiative": 12, "hp": 5, "hp_max": 5, "ac": 14},
428 [Combatant(name="Goblin", initiative=10, hp=7, hp_max=7, ac=15)],
429 )
430 assert "Reinforcement" in result
431 assert ctx.initiative._find("Reinforcement") is not None
432
433 def test_remove_combatant_removes_from_initiative(
434 self, ctx: ToolContext, mcp_call_in_combat: McpCallInCombat
435 ):
436 result = mcp_call_in_combat(
437 "remove_combatant",
438 {"name": "Goblin"},
439 [
440 Combatant(name="Kira", initiative=18, hp=25, hp_max=25, ac=16),
441 Combatant(name="Goblin", initiative=10, hp=7, hp_max=7, ac=15),
442 ],
443 )
444 assert "removed" in result.lower()
445 assert ctx.initiative._find("Goblin") is None
446
447 def test_condition_add(self, ctx: ToolContext, mcp_call_in_combat: McpCallInCombat):
448 result = mcp_call_in_combat(
449 "condition",
450 {"target": "Goblin", "condition": "Poisoned"},
451 [Combatant(name="Goblin", initiative=10, hp=7, hp_max=7, ac=15)],
452 )
453 assert "Poisoned" in result
454 # The combatant should now have the condition tracked
455 goblin = ctx.initiative._find("Goblin")
456 assert any(c.name == "Poisoned" for c in goblin.conditions)
457
458 def test_condition_remove(
459 self, ctx: ToolContext, mcp_call_in_combat: McpCallInCombat
460 ):
461 ctx.initiative.begin(
462 [Combatant(name="Goblin", initiative=10, hp=7, hp_max=7, ac=15)]
463 )
464 ctx.initiative.add_condition(target="Goblin", condition="Stunned")
465
466 result = mcp_call_in_combat(
467 "condition",
468 {
469 "target": "Goblin",
470 "condition": "Stunned",
471 "action": "remove",
472 },
473 )
474 assert "removed" in result.lower() or "Stunned" in result
475 goblin = ctx.initiative._find("Goblin")
476 assert not any(c.name == "Stunned" for c in goblin.conditions)
477
478 def test_end_initiative_clears_combat(
479 self, ctx: ToolContext, mcp_call_in_combat: McpCallInCombat
480 ):
481 result = mcp_call_in_combat(
482 "end_initiative",
483 {},
484 [Combatant(name="Goblin", initiative=10, hp=7, hp_max=7, ac=15)],
485 )
486 assert "ended" in result.lower()
487 assert not ctx.initiative.active
488
489
490# --- Character wrapper bodies ----------------------------------------------
491
492
493@pytest.fixture
494def kira(ctx: ToolContext, mcp_call: McpCall) -> ToolContext:
495 """A minimum-viable character used by the wrapper-coverage tests."""
496 mcp_call(
497 "create_character",
498 {
499 "name": "Kira",
500 "race": "Human",
501 "char_class": "Fighter",
502 "level": 1,
503 "abilities": {
504 "strength": 16,
505 "dexterity": 12,
506 "constitution": 14,
507 "intelligence": 10,
508 "wisdom": 13,
509 "charisma": 8,
510 },
511 "hp_max": 12,
512 "ac": 16,
513 },
514 )
515 return ctx
516
517
518class TestCharacterToolWrappers:
519 """Each character.py @mcp.tool wrapper is a one-liner that delegates to
520 a char_* function. These tests exercise the wrappers through the in-memory
521 client so the wrapper bodies (and their Dependency-resolved arguments)
522 are actually executed."""
523
524 def test_damage_player_by_name(self, kira: ToolContext, mcp_call: McpCall):
525 result = mcp_call("damage", {"target": "Kira", "amount": 3})
526 assert "3" in result
527 char = load_character("default")
528 assert char["state"]["hp"]["current"] == 9
529
530 def test_damage_with_type(self, kira: ToolContext, mcp_call: McpCall):
531 result = mcp_call("damage", {"target": "Kira", "amount": 2, "type": "fire"})
532 assert "2" in result
533
534 def test_heal_player_by_name(self, kira: ToolContext, mcp_call: McpCall):
535 mcp_call("damage", {"target": "Kira", "amount": 5})
536 result = mcp_call("heal", {"target": "Kira", "amount": 3})
537 assert result
538 char = load_character("default")
539 assert char["state"]["hp"]["current"] == 10
540
541 def test_adjust_coins(self, kira: ToolContext, mcp_call: McpCall):
542 result = mcp_call("adjust_coins", {"deltas": {"gp": 10, "sp": 5}})
543 assert "10" in result
544 char = load_character("default")
545 assert char["state"]["purse"]["gp"] == 10
546 assert char["state"]["purse"]["sp"] == 5
547
548 def test_adjust_coins_drops_zero_deltas(self, kira: ToolContext, mcp_call: McpCall):
549 # Spending only gp; the zero-delta filter exercises the comprehension branch
550 mcp_call("adjust_coins", {"deltas": {"gp": 5}})
551 result = mcp_call("adjust_coins", {"deltas": {"gp": -3, "sp": 0}})
552 char = load_character("default")
553 assert char["state"]["purse"]["gp"] == 2
554 assert "silver" not in result.lower()
555
556 def test_add_effect(self, kira: ToolContext, mcp_call: McpCall):
557 result = mcp_call(
558 "add_effect",
559 {
560 "source": "Bless",
561 "description": "+1d4 attacks/saves",
562 },
563 )
564 assert result
565
566 def test_add_effect_with_expires(self, kira: ToolContext, mcp_call: McpCall):
567 result = mcp_call(
568 "add_effect",
569 {
570 "source": "Heroism",
571 "description": "+10 temp HP",
572 "expires": "d1-1430",
573 },
574 )
575 assert result
576
577 def test_remove_effect(self, kira: ToolContext, mcp_call: McpCall):
578 mcp_call("add_effect", {"source": "Bless", "description": "+1d4"})
579 result = mcp_call("remove_effect", {"source": "Bless"})
580 assert result
581
582 def test_add_and_remove_condition(self, kira: ToolContext, mcp_call: McpCall):
583 result = mcp_call("add_condition", {"name": "Poisoned"})
584 assert result
585 result = mcp_call("remove_condition", {"name": "Poisoned"})
586 assert result
587
588 def test_add_item_default_location(self, kira: ToolContext, mcp_call: McpCall):
589 result = mcp_call("add_item", {"item": "Lockpicks"})
590 assert result
591
592 def test_add_item_with_location(self, kira: ToolContext, mcp_call: McpCall):
593 result = mcp_call(
594 "add_item",
595 {
596 "item": "Spare cloak",
597 "location": "stashed_at_inn",
598 },
599 )
600 assert result
601
602 def test_remove_item(self, kira: ToolContext, mcp_call: McpCall):
603 mcp_call("add_item", {"item": "Boot knife"})
604 result = mcp_call("remove_item", {"item": "Boot knife"})
605 assert result
606
607 def test_set_item_status(self, kira: ToolContext, mcp_call: McpCall):
608 # The item must already be a known magic item entity for status tracking
609 mcp_call(
610 "establish",
611 {
612 "entity_type": "items",
613 "name": "Bracer of Defense",
614 "description": "A leather bracer with a faint silver sheen.",
615 },
616 )
617 result = mcp_call(
618 "set_item_status",
619 {
620 "item": "Bracer of Defense",
621 "status": "attuned",
622 },
623 )
624 assert result
625
626 def test_adjust_resource(self, kira: ToolContext, mcp_call: McpCall):
627 # Add a resource via update_character first
628 mcp_call(
629 "update_character",
630 {
631 "updates": {
632 "resources.hit_dice_d10": {
633 "current": 1,
634 "max": 1,
635 "refresh": "long_rest",
636 "notes": "Hit Dice (d10)",
637 },
638 },
639 },
640 )
641 result = mcp_call("adjust_resource", {"name": "hit_dice", "delta": -1})
642 assert "Used" in result
643 result = mcp_call("adjust_resource", {"name": "hit_dice", "delta": 1})
644 assert "Restored" in result
645
646 def test_rest_short(self, kira: ToolContext, mcp_call: McpCall):
647 result = mcp_call("rest", {"type": "short"})
648 assert result
649
650 def test_rest_long(self, kira: ToolContext, mcp_call: McpCall):
651 result = mcp_call("rest", {"type": "long"})
652 assert result
653
654 def test_add_note(self, kira: ToolContext, mcp_call: McpCall):
655 result = mcp_call("add_note", {"text": "Found a hidden passage"})
656 assert result
657
658
659# --- Scene/world wrappers --------------------------------------------------
660
661
662class TestSceneToolWrappers:
663 """Cover the scene.py wrapper bodies — set_scene's optional-field branches,
664 tune's file write, end_session's threads branch, notify_dm's append."""
665
666 def test_set_scene_event_only(self, ctx: ToolContext, mcp_call: McpCall):
667 result = mcp_call(
668 "set_scene",
669 {
670 "event": "Walked into the tavern",
671 "duration": "5 min",
672 },
673 )
674 assert "Logged" in result
675
676 def test_set_scene_with_situation_and_location(
677 self, ctx: ToolContext, mcp_call: McpCall
678 ):
679 result = mcp_call(
680 "set_scene",
681 {
682 "event": "Arrived at the inn",
683 "duration": "1 hour",
684 "situation": "Resting by the fire",
685 "location": "The Rusty Anchor",
686 },
687 )
688 assert "Logged" in result
689 assert "updated" in result.lower()
690
691 def test_set_scene_with_present_auto_marks(
692 self, ctx: ToolContext, mcp_call: McpCall
693 ):
694 # Establish an entity first so auto-mark has something to find
695 mcp_call(
696 "establish",
697 {
698 "entity_type": "npcs",
699 "name": "Vera",
700 "description": "Tavern owner.",
701 },
702 )
703 result = mcp_call(
704 "set_scene",
705 {
706 "event": "Spoke with Vera",
707 "duration": "10 min",
708 "present": ["[[Vera]]"],
709 },
710 )
711 assert "Auto-marked: Vera" in result
712
713 def test_auto_mark_cooldown_suppresses_near_repeats(
714 self,
715 ctx: ToolContext,
716 tmp_path: Path,
717 mcp_call: McpCall,
718 ):
719 """A second set_scene with the same present entity within the
720 cooldown window should NOT append another Was entry."""
721 mcp_call(
722 "establish",
723 {
724 "entity_type": "npcs",
725 "name": "Margit",
726 "description": "Chandler.",
727 },
728 )
729
730 mcp_call(
731 "set_scene",
732 {
733 "event": "Met Mira at the candle shop",
734 "duration": "10 min",
735 "present": ["[[Margit]]"],
736 },
737 )
738 result2 = mcp_call(
739 "set_scene",
740 {
741 "event": "Walked together to dinner",
742 "duration": "10 min",
743 "present": ["[[Margit]]"],
744 },
745 )
746
747 assert "Auto-marked" not in result2
748 content = (
749 tmp_path / "worlds" / ctx.world_id / "npcs" / "Margit.md"
750 ).read_text()
751 assert content.count("Met Mira at the candle shop") == 1
752 assert "Walked together to dinner" not in content
753
754 def test_auto_mark_cooldown_clears_after_window(
755 self, ctx: ToolContext, mcp_call: McpCall
756 ):
757 """After more than cooldown minutes have passed in-game, the next
758 auto-mark for the same entity fires again."""
759 mcp_call(
760 "establish",
761 {
762 "entity_type": "npcs",
763 "name": "Aldric",
764 "description": "Bookseller.",
765 },
766 )
767
768 mcp_call(
769 "set_scene",
770 {
771 "event": "Briefed Aldric on the investigation",
772 "duration": "30 min", # advances the clock past the cooldown
773 "present": ["[[Aldric]]"],
774 },
775 )
776 mcp_call(
777 "set_scene",
778 {
779 "event": "Walked away to get lunch",
780 "duration": "30 min",
781 },
782 )
783 result3 = mcp_call(
784 "set_scene",
785 {
786 "event": "Returned and shared a new lead with Aldric",
787 "duration": "20 min",
788 "present": ["[[Aldric]]"],
789 },
790 )
791
792 assert "Auto-marked: Aldric" in result3
793
794 def test_auto_mark_skips_operational_events(
795 self, ctx: ToolContext, tmp_path: Path, mcp_call: McpCall
796 ):
797 """Session-lifecycle events (Session resumed, etc) must never land
798 in an entity's history."""
799 mcp_call(
800 "establish",
801 {
802 "entity_type": "npcs",
803 "name": "Dortha",
804 "description": "Tanner.",
805 },
806 )
807 result = mcp_call(
808 "set_scene",
809 {
810 "event": "Session resumed. Mira at Dortha's shop.",
811 "duration": "0 min",
812 "present": ["[[Dortha]]"],
813 },
814 )
815
816 assert "Auto-marked" not in result
817 content = (
818 tmp_path / "worlds" / ctx.world_id / "npcs" / "Dortha.md"
819 ).read_text()
820 assert "Session resumed" not in content
821
822 def test_set_scene_with_threads(self, ctx: ToolContext, mcp_call: McpCall):
823 result = mcp_call(
824 "set_scene",
825 {
826 "event": "Got a lead",
827 "duration": "5 min",
828 "threads": ["Find the missing merchant"],
829 },
830 )
831 assert result
832
833 def test_set_scene_no_args_returns_no_updates(
834 self, ctx: ToolContext, mcp_call: McpCall
835 ):
836 # Both event and duration omitted, no other fields → "No updates"
837 result = mcp_call("set_scene", {})
838 assert result == "No updates"
839
840 def test_tune_writes_style_file(
841 self, ctx: ToolContext, tmp_path: Path, mcp_call: McpCall
842 ):
843 result = mcp_call("tune", {"tuning": "Lean into intrigue and slow pacing."})
844 assert "updated" in result.lower()
845 style_path = tmp_path / "worlds" / ctx.world_id / "style.md"
846 assert style_path.exists()
847 assert "intrigue" in style_path.read_text()
848
849 def test_end_session_no_threads(self, ctx: ToolContext, mcp_call: McpCall):
850 result = mcp_call("end_session", {"situation": "In the tavern"})
851 assert result == "SESSION_ENDED"
852
853 def test_end_session_with_threads(self, ctx: ToolContext, mcp_call: McpCall):
854 result = mcp_call(
855 "end_session",
856 {
857 "situation": "In the tavern",
858 "threads": ["Investigate the warehouse", "Find the merchant"],
859 },
860 )
861 assert result == "SESSION_ENDED"
862
863 def test_notify_dm_appends_to_queue(
864 self, ctx: ToolContext, tmp_path: Path, mcp_call: McpCall
865 ):
866 result = mcp_call("notify_dm", {"message": "Background world has shifted"})
867 assert "queued" in result.lower()
868 path = tmp_path / "worlds" / ctx.world_id / "dm_notifications.md"
869 assert path.exists()
870 assert "Background world has shifted" in path.read_text()
871
872
873# --- run_code wrapper -------------------------------------------------------
874
875
876class TestRunCodeWrapper:
877 def test_run_code_simple(self, ctx: ToolContext, mcp_call: McpCall):
878 result = mcp_call(
879 "run_code",
880 {
881 "description": "Two plus two",
882 "code": "2 + 2",
883 },
884 )
885 assert "4" in result
886
887 def test_run_code_with_print(self, ctx: ToolContext, mcp_call: McpCall):
888 result = mcp_call(
889 "run_code",
890 {
891 "description": "Print test",
892 "code": 'print("hello sandbox")',
893 },
894 )
895 assert "hello sandbox" in result