A 5e storytelling engine with an LLM DM
0
fork

Configure Feed

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

at main 895 lines 31 kB view raw
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