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 1387 lines 49 kB view raw
1# pyright: reportOptionalSubscript=false, reportOptionalMemberAccess=false 2# pyright: reportArgumentType=false, reportOperatorIssue=false, reportReturnType=false 3# Tests immediately subscript dicts returned by load_character() without 4# the assert-not-None dance — the test setup guarantees the file exists. 5"""Tests for the character system: data, computation, display, and operations.""" 6 7from pathlib import Path 8 9import pytest 10 11from storied.character import ( 12 ABILITIES, 13 SKILL_TO_ABILITY, 14 ability_modifier, 15 add_condition, 16 add_effect, 17 add_item, 18 add_note, 19 adjust_coins, 20 adjust_resource, 21 create_character, 22 damage, 23 effective_hp, 24 format_character_context, 25 format_sheet, 26 format_status, 27 has_expertise_in, 28 heal, 29 is_proficient_in, 30 level_up, 31 load_character, 32 load_character_prose, 33 passive_score, 34 proficiency_bonus, 35 remove_condition, 36 remove_effect, 37 remove_item, 38 rest, 39 save_character, 40 save_modifier, 41 set_item_status, 42 skill_modifier, 43 total_level, 44 update_character, 45) 46 47# --- Fixtures --- 48 49 50@pytest.fixture 51def player_dir(tmp_path: Path) -> Path: 52 (tmp_path / "players" / "test-player").mkdir(parents=True) 53 return tmp_path 54 55 56@pytest.fixture 57def mira(player_dir: Path) -> dict: 58 """A level 3 Rogue/Thief, modeled after Mira Ashvale.""" 59 create_character( 60 player_id="test-player", 61 name="Mira", 62 race="Human", 63 char_class="Rogue", 64 subclass="Thief", 65 level=3, 66 abilities={ 67 "strength": 11, 68 "dexterity": 18, 69 "constitution": 14, 70 "intelligence": 15, 71 "wisdom": 14, 72 "charisma": 18, 73 }, 74 hp_max=24, 75 ac=16, 76 background="Criminal", 77 purse={"gp": 43, "cp": 66}, 78 ) 79 # Add proficiencies and resources via update_character 80 update_character( 81 "test-player", 82 { 83 "proficiencies.saves": ["dexterity", "intelligence"], 84 "proficiencies.skills.stealth": "expertise", 85 "proficiencies.skills.sleight_of_hand": "expertise", 86 "proficiencies.skills.acrobatics": "proficient", 87 "proficiencies.skills.perception": "proficient", 88 "proficiencies.skills.deception": "proficient", 89 "proficiencies.skills.insight": "proficient", 90 }, 91 ) 92 update_character( 93 "test-player", 94 { 95 "resources.hit_dice_d8": { 96 "current": 3, 97 "max": 3, 98 "refresh": "long_rest", 99 "notes": "Hit Dice (d8)", 100 }, 101 }, 102 ) 103 return load_character("test-player") 104 105 106# --- Data layer tests --- 107 108 109class TestDataLayer: 110 def test_load_returns_none_for_missing(self, player_dir: Path): 111 assert load_character("test-player") is None 112 113 def test_create_and_load(self, player_dir: Path): 114 create_character( 115 player_id="test-player", 116 name="Conan", 117 race="Human", 118 char_class="Barbarian", 119 level=1, 120 abilities={ 121 "strength": 18, 122 "dexterity": 14, 123 "constitution": 16, 124 "intelligence": 8, 125 "wisdom": 10, 126 "charisma": 12, 127 }, 128 hp_max=15, 129 ac=14, 130 ) 131 data = load_character("test-player") 132 assert data["identity"]["name"] == "Conan" 133 assert data["identity"]["classes"][0]["class"] == "Barbarian" 134 assert data["identity"]["classes"][0]["level"] == 1 135 assert data["abilities"]["strength"] == 18 136 assert data["state"]["hp"]["max"] == 15 137 138 def test_load_fills_defaults(self, player_dir: Path): 139 # Save a sparse character 140 save_character("test-player", {"identity": {"name": "Sparse"}}) 141 data = load_character("test-player") 142 # Default schema should be merged in 143 assert "abilities" in data 144 assert "state" in data 145 assert data["abilities"]["strength"] == 10 146 147 def test_create_writes_backstory(self, player_dir: Path): 148 create_character( 149 player_id="test-player", 150 name="Storyteller", 151 race="Half-Elf", 152 char_class="Bard", 153 level=1, 154 abilities={ 155 "strength": 8, 156 "dexterity": 14, 157 "constitution": 12, 158 "intelligence": 13, 159 "wisdom": 10, 160 "charisma": 16, 161 }, 162 hp_max=9, 163 ac=12, 164 backstory="A wandering minstrel with secrets.", 165 ) 166 prose = load_character_prose("test-player") 167 assert "wandering minstrel" in prose 168 169 170class TestUpdateCharacter: 171 def test_update_simple_field(self, mira: dict, player_dir: Path): 172 update_character("test-player", {"state.ac": 17}) 173 data = load_character("test-player") 174 assert data["state"]["ac"] == 17 175 176 def test_update_nested_via_dot(self, mira: dict, player_dir: Path): 177 update_character("test-player", {"state.hp.max": 30}) 178 data = load_character("test-player") 179 assert data["state"]["hp"]["max"] == 30 180 181 def test_negative_hp_clamped_to_zero(self, mira: dict, player_dir: Path): 182 update_character("test-player", {"state.hp.current": -5}) 183 data = load_character("test-player") 184 assert data["state"]["hp"]["current"] == 0 185 186 def test_hp_clamped_to_max(self, mira: dict, player_dir: Path): 187 update_character("test-player", {"state.hp.current": 100}) 188 data = load_character("test-player") 189 assert data["state"]["hp"]["current"] == 24 190 191 def test_negative_coins_clamped(self, mira: dict, player_dir: Path): 192 update_character("test-player", {"state.purse.sp": -20}) 193 data = load_character("test-player") 194 assert data["state"]["purse"]["sp"] == 0 195 196 def test_no_character_returns_error(self, player_dir: Path): 197 result = update_character("missing", {"foo": "bar"}) 198 assert "no character" in result.lower() 199 200 201class TestSchemaValidation: 202 """update_character must reject schema-violating writes with a DM-readable 203 error and leave the on-disk character unchanged.""" 204 205 def test_resources_as_list_is_rejected(self, mira: dict, player_dir: Path): 206 before = load_character("test-player") 207 result = update_character( 208 "test-player", 209 {"resources": [{"name": "Channel Divinity", "current": 1, "max": 1}]}, 210 ) 211 assert "rejected" in result.lower() 212 assert "resources" in result 213 assert "dict" in result.lower() 214 # On-disk character is unchanged 215 after = load_character("test-player") 216 assert after["resources"] == before["resources"] 217 218 def test_equipment_as_list_is_rejected(self, mira: dict, player_dir: Path): 219 result = update_character( 220 "test-player", 221 {"equipment": ["Longsword", "Shield"]}, 222 ) 223 assert "rejected" in result.lower() 224 assert "equipment" in result 225 226 def test_state_hp_must_be_a_dict(self, mira: dict, player_dir: Path): 227 result = update_character( 228 "test-player", 229 {"state.hp": 24}, # missing required fields 230 ) 231 assert "rejected" in result.lower() 232 # Original HP block is preserved 233 data = load_character("test-player") 234 assert isinstance(data["state"]["hp"], dict) 235 assert data["state"]["hp"]["max"] == 24 236 237 def test_valid_resources_update_succeeds(self, mira: dict, player_dir: Path): 238 result = update_character( 239 "test-player", 240 { 241 "resources.channel_divinity": { 242 "current": 1, 243 "max": 1, 244 "refresh": "short_rest", 245 "notes": "Channel Divinity", 246 } 247 }, 248 ) 249 assert "rejected" not in result.lower() 250 data = load_character("test-player") 251 assert data["resources"]["channel_divinity"]["current"] == 1 252 253 def test_error_message_contains_an_example(self, mira: dict, player_dir: Path): 254 """The DM should be able to fix the call from the error message alone.""" 255 result = update_character( 256 "test-player", 257 {"resources": [{"name": "x"}]}, 258 ) 259 # Concrete example helps the LLM correct itself 260 assert "channel_divinity" in result or "{" in result 261 262 263class TestSchemaCoercion: 264 """load_character heals known mis-shapes from older sessions so existing 265 characters keep working without manual repair.""" 266 267 def test_load_coerces_resources_list_to_dict(self, player_dir: Path): 268 import yaml 269 270 # Hand-write a character with resources as a list (the bad shape) 271 path = player_dir / "players" / "test-player" / "character.yaml" 272 path.write_text( 273 yaml.dump( 274 { 275 "identity": { 276 "name": "Damaged", 277 "classes": [{"class": "Cleric", "level": 3}], 278 }, 279 "abilities": { 280 "strength": 10, 281 "dexterity": 10, 282 "constitution": 10, 283 "intelligence": 10, 284 "wisdom": 14, 285 "charisma": 10, 286 }, 287 "state": {"hp": {"max": 20, "current": 20, "temp": 0}}, 288 "resources": [ 289 { 290 "name": "Channel Divinity", 291 "current": 1, 292 "max": 1, 293 "refresh": "short_rest", 294 }, 295 { 296 "name": "Lay on Hands", 297 "current": 15, 298 "max": 15, 299 "refresh": "long_rest", 300 }, 301 ], 302 } 303 ) 304 ) 305 data = load_character("test-player") 306 assert isinstance(data["resources"], dict) 307 assert "channel_divinity" in data["resources"] 308 assert data["resources"]["channel_divinity"]["current"] == 1 309 assert data["resources"]["lay_on_hands"]["max"] == 15 310 311 def test_coerced_character_can_adjust_resource(self, player_dir: Path): 312 """End-to-end: a character with bad-shape resources on disk should 313 be usable via adjust_resource after load coercion.""" 314 import yaml 315 316 path = player_dir / "players" / "test-player" / "character.yaml" 317 path.write_text( 318 yaml.dump( 319 { 320 "identity": {"name": "Damaged"}, 321 "abilities": { 322 "strength": 10, 323 "dexterity": 10, 324 "constitution": 10, 325 "intelligence": 10, 326 "wisdom": 10, 327 "charisma": 10, 328 }, 329 "state": {"hp": {"max": 20, "current": 20, "temp": 0}}, 330 "resources": [ 331 { 332 "name": "Channel Divinity", 333 "current": 1, 334 "max": 1, 335 "refresh": "short_rest", 336 "notes": "Channel Divinity", 337 }, 338 ], 339 } 340 ) 341 ) 342 result = adjust_resource("test-player", "channel", -1) 343 assert "Used 1" in result 344 345 def test_load_coerces_equipment_list_to_dict(self, player_dir: Path): 346 import yaml 347 348 path = player_dir / "players" / "test-player" / "character.yaml" 349 path.write_text( 350 yaml.dump( 351 { 352 "identity": {"name": "Damaged"}, 353 "abilities": { 354 "strength": 10, 355 "dexterity": 10, 356 "constitution": 10, 357 "intelligence": 10, 358 "wisdom": 10, 359 "charisma": 10, 360 }, 361 "state": {"hp": {"max": 20, "current": 20, "temp": 0}}, 362 "equipment": ["Longsword", "Shield"], 363 } 364 ) 365 ) 366 data = load_character("test-player") 367 assert isinstance(data["equipment"], dict) 368 assert data["equipment"]["on_person"] == ["Longsword", "Shield"] 369 370 371# --- Computation tests --- 372 373 374class TestComputation: 375 def test_ability_modifier(self): 376 assert ability_modifier(10) == 0 377 assert ability_modifier(11) == 0 378 assert ability_modifier(12) == 1 379 assert ability_modifier(18) == 4 380 assert ability_modifier(8) == -1 381 assert ability_modifier(20) == 5 382 383 def test_total_level_single_class(self, mira: dict): 384 assert total_level(mira) == 3 385 386 def test_total_level_multiclass(self, player_dir: Path): 387 save_character( 388 "test-player", 389 { 390 "identity": { 391 "classes": [ 392 {"class": "Fighter", "level": 3}, 393 {"class": "Wizard", "level": 2}, 394 ] 395 } 396 }, 397 ) 398 data = load_character("test-player") 399 assert total_level(data) == 5 400 401 def test_proficiency_bonus_scaling(self, player_dir: Path): 402 for level, expected in [ 403 (1, 2), 404 (4, 2), 405 (5, 3), 406 (8, 3), 407 (9, 4), 408 (12, 4), 409 (13, 5), 410 (16, 5), 411 (17, 6), 412 (20, 6), 413 ]: 414 save_character( 415 "test-player", 416 {"identity": {"classes": [{"class": "Fighter", "level": level}]}}, 417 ) 418 data = load_character("test-player") 419 assert proficiency_bonus(data) == expected, f"level {level}" 420 421 def test_skill_modifier_with_expertise(self, mira: dict): 422 total, breakdown = skill_modifier(mira, "stealth") 423 # +4 dex, +4 expertise (2 prof bonus * 2) 424 assert total == 8 425 assert any("dex" in b.lower() for b in breakdown) 426 assert any("expertise" in b.lower() for b in breakdown) 427 428 def test_skill_modifier_proficient(self, mira: dict): 429 total, breakdown = skill_modifier(mira, "perception") 430 # +2 wis, +2 prof 431 assert total == 4 432 assert any("proficient" in b.lower() for b in breakdown) 433 434 def test_skill_modifier_no_proficiency(self, mira: dict): 435 total, _ = skill_modifier(mira, "athletics") 436 # +0 str, no prof 437 assert total == 0 438 439 def test_save_modifier_proficient(self, mira: dict): 440 total, breakdown = save_modifier(mira, "dexterity") 441 # +4 dex, +2 prof 442 assert total == 6 443 assert any("proficient" in b.lower() for b in breakdown) 444 445 def test_save_modifier_not_proficient(self, mira: dict): 446 total, _ = save_modifier(mira, "wisdom") 447 # +2 wis only 448 assert total == 2 449 450 def test_passive_perception(self, mira: dict): 451 # 10 + perception modifier (+4) = 14 452 assert passive_score(mira, "perception") == 14 453 454 def test_skill_modifier_ignores_exhaustion(self, mira: dict): 455 """Skill modifiers show raw ability + proficiency math. Exhaustion 456 is a rule effect the DM applies at roll time, not baked into the 457 displayed number.""" 458 mira["state"]["exhaustion"] = 2 459 total, _ = skill_modifier(mira, "stealth") 460 assert total == 8 # +4 dex + 4 expertise, exhaustion NOT folded in 461 462 def test_save_modifier_ignores_exhaustion(self, mira: dict): 463 mira["state"]["exhaustion"] = 1 464 total, _ = save_modifier(mira, "dexterity") 465 assert total == 6 # +4 dex + 2 prof, exhaustion NOT folded in 466 467 def test_passive_perception_ignores_conditions(self, mira: dict): 468 """Passive perception is the raw 10 + perception modifier. The DM 469 applies condition-based adjustments (e.g. -5 for disadvantage per 470 5e 2024) per whatever ruleset they're running.""" 471 assert passive_score(mira, "perception") == 14 472 mira["conditions"] = ["Poisoned"] 473 assert passive_score(mira, "perception") == 14 474 475 def test_effective_hp_with_temp(self, player_dir: Path): 476 save_character( 477 "test-player", {"state": {"hp": {"max": 30, "current": 20, "temp": 5}}} 478 ) 479 data = load_character("test-player") 480 hp = effective_hp(data) 481 assert hp["effective"] == 25 482 assert hp["current"] == 20 483 assert hp["temp"] == 5 484 485 def test_is_proficient_in(self, mira: dict): 486 assert is_proficient_in(mira, "stealth") 487 assert is_proficient_in(mira, "perception") 488 assert not is_proficient_in(mira, "athletics") 489 490 def test_has_expertise_in(self, mira: dict): 491 assert has_expertise_in(mira, "stealth") 492 assert not has_expertise_in(mira, "perception") 493 assert not has_expertise_in(mira, "athletics") 494 495 def test_skill_to_ability_complete(self): 496 # Every skill mapped 497 assert "acrobatics" in SKILL_TO_ABILITY 498 assert "stealth" in SKILL_TO_ABILITY 499 assert "investigation" in SKILL_TO_ABILITY 500 assert len(SKILL_TO_ABILITY) == 18 501 502 def test_abilities_constant(self): 503 assert len(ABILITIES) == 6 504 505 506# --- Display tests --- 507 508 509class TestDisplay: 510 def test_format_status_includes_name_and_class(self, mira: dict): 511 result = format_status(mira) 512 assert "Mira" in result 513 assert "Rogue" in result 514 assert "Thief" in result 515 516 def test_format_status_includes_hp(self, mira: dict): 517 result = format_status(mira) 518 assert "24/24" in result 519 520 def test_format_status_includes_purse(self, mira: dict): 521 result = format_status(mira) 522 assert "43 gp" in result 523 524 def test_format_sheet_includes_skills(self, mira: dict): 525 result = format_sheet(mira) 526 assert "Stealth" in result 527 assert "+8" in result 528 529 def test_format_sheet_shows_expertise_marker(self, mira: dict): 530 result = format_sheet(mira) 531 # Stealth has expertise, marked with ★★ 532 assert "★★" in result 533 534 def test_format_sheet_includes_passive_perception(self, mira: dict): 535 result = format_sheet(mira) 536 assert "Passive Perception" in result 537 assert "14" in result 538 539 def test_format_character_context_includes_advancement( 540 self, mira: dict, player_dir: Path 541 ): 542 update_character("test-player", {"advancement_ready": 4}) 543 data = load_character("test-player") 544 result = format_character_context(data) 545 assert "Advancement Ready" in result 546 assert "Level 4" in result 547 548 def test_format_sheet_tolerates_wrong_shaped_resources(self, mira: dict): 549 """If the LLM writes the wrong shape (a list instead of a dict-of-pools), 550 the renderer must skip the section instead of crashing the turn.""" 551 mira["resources"] = ["hit_dice_d8", "bracer_unseen_step"] 552 result = format_sheet(mira) 553 assert "Resources" not in result # section omitted, no crash 554 555 def test_format_sheet_tolerates_wrong_shaped_magic_items(self, mira: dict): 556 mira["magic_items"] = ["[[Bracer]]"] # should be a dict 557 result = format_sheet(mira) 558 assert "Magic Items" not in result # section omitted, no crash 559 560 def test_format_sheet_tolerates_wrong_shaped_equipment(self, mira: dict): 561 mira["equipment"] = ["sword", "shield"] # should be a dict-of-locations 562 result = format_sheet(mira) 563 assert "Equipment" not in result # section omitted, no crash 564 565 def test_format_sheet_renders_active_effects(self, mira: dict): 566 mira["effects"] = [ 567 {"source": "Bless", "description": "+1d4 attacks/saves"}, 568 {"source": "Heroism", "description": "+10 temp HP", "expires": "d2-1430"}, 569 ] 570 result = format_sheet(mira) 571 assert "Active Effects" in result 572 assert "Bless" in result 573 assert "Heroism" in result 574 assert "until d2-1430" in result 575 576 def test_format_sheet_renders_resources_with_die(self, mira: dict): 577 mira["resources"] = { 578 "bardic_inspiration": { 579 "current": 3, 580 "max": 3, 581 "refresh": "long_rest", 582 "notes": "Bardic Inspiration", 583 "die": "d8", 584 }, 585 } 586 result = format_sheet(mira) 587 assert "Bardic Inspiration: 3/3" in result 588 assert "d8" in result 589 590 def test_format_sheet_renders_magic_items_carried(self, mira: dict): 591 mira["magic_items"] = { 592 "attuned": ["[[Bracer]]"], 593 "equipped": ["[[Boots]]"], 594 "carried": ["[[Cloak]]", "[[Ring]]"], 595 } 596 result = format_sheet(mira) 597 assert "Magic Items" in result 598 assert "Attuned: [[Bracer]]" in result 599 assert "Equipped: [[Boots]]" in result 600 assert "Carried: [[Cloak]], [[Ring]]" in result 601 602 def test_format_sheet_renders_features(self, mira: dict): 603 mira["features"] = [ 604 {"name": "Sneak Attack", "text": "+2d6 damage", "source": "Rogue Lv1"}, 605 {"name": "Cunning Action", "text": "Bonus action: Dash/Disengage/Hide"}, 606 ] 607 result = format_sheet(mira) 608 assert "Features" in result 609 assert "Sneak Attack" in result 610 assert "Rogue Lv1" in result 611 assert "Cunning Action" in result 612 613 def test_format_sheet_renders_conditions(self, mira: dict): 614 mira["conditions"] = ["Poisoned", "Prone"] 615 result = format_sheet(mira) 616 assert "Conditions:" in result 617 assert "Poisoned" in result 618 assert "Prone" in result 619 620 def test_format_sheet_renders_defenses(self, mira: dict): 621 mira["defenses"] = { 622 "resistances": [{"damage": "fire", "source": "racial"}], 623 "vulnerabilities": [{"damage": "cold", "source": "curse"}], 624 "immunities": { 625 "damage": ["psychic"], 626 "conditions": ["charmed"], 627 }, 628 } 629 result = format_sheet(mira) 630 assert "Resistances:" in result 631 assert "fire" in result 632 assert "Vulnerabilities:" in result 633 assert "cold" in result 634 assert "Damage Immunities:" in result 635 assert "psychic" in result 636 assert "Condition Immunities:" in result 637 assert "charmed" in result 638 639 def test_format_sheet_renders_temp_hp_in_vital_line(self, mira: dict): 640 mira["state"]["hp"]["temp"] = 5 641 result = format_sheet(mira) 642 assert "+5 temp" in result 643 644 def test_format_sheet_shows_inspiration_when_available(self, mira: dict): 645 mira["state"]["inspiration"] = True 646 sheet = format_sheet(mira) 647 assert "Inspiration" in sheet 648 assert "available" in sheet 649 650 def test_format_sheet_shows_exhaustion_reminder_when_nonzero( 651 self, 652 mira: dict, 653 ): 654 """When exhaustion is set, the sheet shows a reminder that the DM 655 applies the effect — it does NOT fold a numeric penalty into the 656 displayed skill modifiers.""" 657 mira["state"]["exhaustion"] = 2 658 sheet = format_sheet(mira) 659 assert "Exhaustion 2" in sheet 660 # Stealth should still read +8 (raw), not +4 (folded) 661 assert "Stealth" in sheet 662 assert "+8" in sheet 663 664 def test_format_sheet_renders_exhaustion_in_vital_line(self, mira: dict): 665 mira["state"]["exhaustion"] = 2 666 result = format_sheet(mira) 667 assert "Exhaustion 2" in result 668 669 def test_format_status_omits_empty_purse(self, player_dir: Path): 670 create_character( 671 player_id="test-player", 672 name="Broke", 673 race="Human", 674 char_class="Fighter", 675 level=1, 676 abilities={ 677 "strength": 10, 678 "dexterity": 10, 679 "constitution": 10, 680 "intelligence": 10, 681 "wisdom": 10, 682 "charisma": 10, 683 }, 684 hp_max=10, 685 ac=10, 686 ) 687 data = load_character("test-player") 688 result = format_status(data) 689 assert "Purse" not in result 690 691 def test_format_status_truncates_equipment_over_eight_items( 692 self, 693 mira: dict, 694 player_dir: Path, 695 ): 696 # Add lots of items so the truncation branch fires 697 for i in range(12): 698 update_character( 699 "test-player", 700 {"equipment.on_person": [f"item_{i}" for i in range(12)]}, 701 ) 702 data = load_character("test-player") 703 result = format_status(data) 704 assert "and 4 more" in result # 12 items - 8 shown = 4 more 705 706 def test_format_character_display_respects_data_home( 707 self, 708 mira: dict, 709 player_dir: Path, 710 tmp_path: Path, 711 ): 712 """The /me slash command resolves the character via the 713 ``storied.paths`` module globals — sandbox sessions get the 714 sandbox character because the data home was overridden in 715 ``cmd_play`` before this function is called.""" 716 from storied.cli import _format_character_display 717 from storied.paths import using_data_home 718 719 # mira lives at player_dir/players/test-player; an unrelated 720 # other_path has no character. Pointing data_home at other_path 721 # must return None instead of silently loading mira from elsewhere. 722 other_path = tmp_path / "other" 723 (other_path / "players" / "test-player").mkdir(parents=True) 724 725 with using_data_home(other_path): 726 result = _format_character_display("test-player", full=True) 727 assert result is None, ( 728 "_format_character_display must use the configured data_home; " 729 "falling back to a stale path is what caused /me to show the " 730 "wrong character in sandbox sessions." 731 ) 732 733 # And it should return real content when data_home points at 734 # the dir mira actually lives in. 735 with using_data_home(player_dir): 736 result = _format_character_display("test-player", full=True) 737 assert result is not None 738 assert "Mira" in result 739 740 741# --- Operations tests --- 742 743 744class TestDamageHeal: 745 def test_damage_subtracts_hp(self, mira: dict, player_dir: Path): 746 damage("test-player", 5) 747 data = load_character("test-player") 748 assert data["state"]["hp"]["current"] == 19 749 750 def test_damage_temp_hp_absorbs_first(self, mira: dict, player_dir: Path): 751 update_character("test-player", {"state.hp.temp": 5}) 752 damage("test-player", 3) 753 data = load_character("test-player") 754 assert data["state"]["hp"]["temp"] == 2 755 assert data["state"]["hp"]["current"] == 24 756 757 def test_damage_temp_overflow_to_hp(self, mira: dict, player_dir: Path): 758 update_character("test-player", {"state.hp.temp": 5}) 759 damage("test-player", 8) 760 data = load_character("test-player") 761 assert data["state"]["hp"]["temp"] == 0 762 assert data["state"]["hp"]["current"] == 21 763 764 def test_damage_clamps_to_zero(self, mira: dict, player_dir: Path): 765 damage("test-player", 100) 766 data = load_character("test-player") 767 assert data["state"]["hp"]["current"] == 0 768 769 def test_damage_at_zero_mentions_death_saves(self, mira: dict, player_dir: Path): 770 result = damage("test-player", 100) 771 assert "death save" in result.lower() 772 773 def test_heal_restores_hp(self, mira: dict, player_dir: Path): 774 damage("test-player", 10) 775 heal("test-player", 5) 776 data = load_character("test-player") 777 assert data["state"]["hp"]["current"] == 19 778 779 def test_heal_clamped_to_max(self, mira: dict, player_dir: Path): 780 heal("test-player", 100) 781 data = load_character("test-player") 782 assert data["state"]["hp"]["current"] == 24 783 784 def test_damage_with_type_in_message(self, mira: dict, player_dir: Path): 785 result = damage("test-player", 3, damage_type="fire") 786 assert "fire" in result 787 788 def test_damage_ignores_resistances(self, mira: dict, player_dir: Path): 789 """Resistances are metadata for the DM's reference — the tool 790 applies raw damage and lets the DM pre-compute rule effects.""" 791 update_character( 792 "test-player", 793 {"defenses.resistances": [{"damage": "fire"}]}, 794 ) 795 damage("test-player", 10, damage_type="fire") 796 data = load_character("test-player") 797 # Raw 10, not halved 798 assert data["state"]["hp"]["current"] == 14 799 800 def test_damage_ignores_vulnerabilities(self, mira: dict, player_dir: Path): 801 update_character( 802 "test-player", 803 {"defenses.vulnerabilities": [{"damage": "radiant"}]}, 804 ) 805 damage( 806 "test-player", 807 5, 808 damage_type="radiant", 809 ) 810 data = load_character("test-player") 811 # Raw 5, not doubled 812 assert data["state"]["hp"]["current"] == 19 813 814 def test_damage_ignores_immunities(self, mira: dict, player_dir: Path): 815 update_character( 816 "test-player", 817 {"defenses.immunities": {"damage": ["poison"], "conditions": []}}, 818 ) 819 damage( 820 "test-player", 821 12, 822 damage_type="poison", 823 ) 824 data = load_character("test-player") 825 # Raw 12, not zeroed 826 assert data["state"]["hp"]["current"] == 12 827 828 829class TestLevelUp: 830 def test_level_up_increments_class_level( 831 self, 832 mira: dict, 833 player_dir: Path, 834 ): 835 result = level_up( 836 "test-player", 837 class_name="Rogue", 838 new_level=4, 839 hp_gain=6, 840 ) 841 data = load_character("test-player") 842 assert data["identity"]["classes"][0]["level"] == 4 843 assert "3 → 4" in result 844 845 def test_level_up_adds_hp_to_max_and_current( 846 self, 847 mira: dict, 848 player_dir: Path, 849 ): 850 # mira starts with 24/24 851 level_up( 852 "test-player", 853 "Rogue", 854 new_level=4, 855 hp_gain=6, 856 ) 857 data = load_character("test-player") 858 assert data["state"]["hp"]["max"] == 30 859 assert data["state"]["hp"]["current"] == 30 860 861 def test_level_up_preserves_wounded_current_relative( 862 self, 863 mira: dict, 864 player_dir: Path, 865 ): 866 # Wound the character first 867 damage("test-player", 10) 868 # HP is now 14/24 869 level_up( 870 "test-player", 871 "Rogue", 872 new_level=4, 873 hp_gain=6, 874 ) 875 data = load_character("test-player") 876 # Max goes up by 6; current also goes up by 6 (so 14+6=20, 24+6=30) 877 assert data["state"]["hp"]["max"] == 30 878 assert data["state"]["hp"]["current"] == 20 879 880 def test_level_up_sets_level_since( 881 self, 882 mira: dict, 883 player_dir: Path, 884 ): 885 level_up( 886 "test-player", 887 "Rogue", 888 new_level=4, 889 hp_gain=6, 890 time_anchor="#d12-1500", 891 ) 892 data = load_character("test-player") 893 assert data["level_since"] == "#d12-1500" 894 895 def test_level_up_clears_advancement_ready( 896 self, 897 mira: dict, 898 player_dir: Path, 899 ): 900 update_character( 901 "test-player", 902 {"advancement_ready": 4}, 903 ) 904 level_up( 905 "test-player", 906 "Rogue", 907 new_level=4, 908 hp_gain=6, 909 ) 910 data = load_character("test-player") 911 assert data.get("advancement_ready") is None 912 913 def test_level_up_replaces_features_when_provided( 914 self, 915 mira: dict, 916 player_dir: Path, 917 ): 918 new_features = [ 919 {"name": "Sneak Attack", "text": "2d6"}, 920 {"name": "Uncanny Dodge", "text": "Reaction for half damage"}, 921 ] 922 level_up( 923 "test-player", 924 "Rogue", 925 new_level=4, 926 hp_gain=6, 927 features=new_features, 928 ) 929 data = load_character("test-player") 930 assert len(data["features"]) == 2 931 assert data["features"][1]["name"] == "Uncanny Dodge" 932 933 def test_level_up_preserves_features_when_omitted( 934 self, 935 mira: dict, 936 player_dir: Path, 937 ): 938 update_character( 939 "test-player", 940 {"features": [{"name": "Sneak Attack", "text": "2d6"}]}, 941 ) 942 level_up( 943 "test-player", 944 "Rogue", 945 new_level=4, 946 hp_gain=6, 947 ) 948 data = load_character("test-player") 949 assert data["features"] == [{"name": "Sneak Attack", "text": "2d6"}] 950 951 def test_level_up_rejects_downgrade( 952 self, 953 mira: dict, 954 player_dir: Path, 955 ): 956 result = level_up( 957 "test-player", 958 "Rogue", 959 new_level=2, 960 hp_gain=0, 961 ) 962 assert "Refusing" in result 963 data = load_character("test-player") 964 assert data["identity"]["classes"][0]["level"] == 3 # unchanged 965 966 def test_level_up_rejects_unknown_class( 967 self, 968 mira: dict, 969 player_dir: Path, 970 ): 971 result = level_up( 972 "test-player", 973 "Wizard", 974 new_level=4, 975 hp_gain=4, 976 ) 977 assert "No class matching" in result 978 data = load_character("test-player") 979 assert data["identity"]["classes"][0]["level"] == 3 980 981 def test_level_up_multiclass_finds_correct_class( 982 self, 983 mira: dict, 984 player_dir: Path, 985 ): 986 # Add a Fighter level to make Mira multiclass 987 update_character( 988 "test-player", 989 { 990 "identity.classes": [ 991 {"class": "Rogue", "subclass": "Thief", "level": 3}, 992 {"class": "Fighter", "subclass": None, "level": 1}, 993 ] 994 }, 995 ) 996 level_up( 997 "test-player", 998 "Fighter", 999 new_level=2, 1000 hp_gain=7, 1001 ) 1002 data = load_character("test-player") 1003 assert data["identity"]["classes"][0]["level"] == 3 # Rogue unchanged 1004 assert data["identity"]["classes"][1]["level"] == 2 # Fighter bumped 1005 1006 1007class TestConcentration: 1008 """`concentration=True` is a metadata flag — the tool records it on 1009 the effect so the sheet can display which effect is the current 1010 concentration. It does NOT enforce uniqueness or emit save hints. 1011 The DM decides when to drop a concentration effect.""" 1012 1013 def test_add_concentration_effect_flags_it( 1014 self, 1015 mira: dict, 1016 player_dir: Path, 1017 ): 1018 result = add_effect( 1019 "test-player", 1020 "Bless", 1021 "+1d4 to attacks", 1022 concentration=True, 1023 ) 1024 assert "[Concentration]" in result 1025 data = load_character("test-player") 1026 assert data["effects"][0]["concentration"] is True 1027 1028 def test_multiple_concentration_effects_allowed( 1029 self, 1030 mira: dict, 1031 player_dir: Path, 1032 ): 1033 """No enforcement — the DM can flag two effects concentration.""" 1034 add_effect( 1035 "test-player", 1036 "Bless", 1037 "+1d4", 1038 concentration=True, 1039 ) 1040 add_effect( 1041 "test-player", 1042 "Hold Person", 1043 "paralyzed", 1044 concentration=True, 1045 ) 1046 data = load_character("test-player") 1047 sources = [e["source"] for e in data["effects"]] 1048 assert "Bless" in sources 1049 assert "Hold Person" in sources 1050 1051 def test_damage_does_not_emit_concentration_save_hint( 1052 self, 1053 mira: dict, 1054 player_dir: Path, 1055 ): 1056 """The DM issues concentration saves manually per the rules.""" 1057 add_effect( 1058 "test-player", 1059 "Bless", 1060 "+1d4", 1061 concentration=True, 1062 ) 1063 result = damage("test-player", 6) 1064 assert "Concentration save" not in result 1065 1066 1067class TestEffects: 1068 def test_add_effect_appends(self, mira: dict, player_dir: Path): 1069 add_effect("test-player", "Bless", "+1d4 to attacks") 1070 data = load_character("test-player") 1071 assert len(data["effects"]) == 1 1072 assert data["effects"][0]["source"] == "Bless" 1073 1074 def test_add_effect_with_expiry(self, mira: dict, player_dir: Path): 1075 add_effect( 1076 "test-player", 1077 "Potion", 1078 "+10 temp HP", 1079 expires="d1-1430", 1080 ) 1081 data = load_character("test-player") 1082 assert data["effects"][0]["expires"] == "d1-1430" 1083 1084 def test_remove_effect_by_source(self, mira: dict, player_dir: Path): 1085 add_effect("test-player", "Bless", "+1d4") 1086 result = remove_effect("test-player", "bless") 1087 data = load_character("test-player") 1088 assert len(data["effects"]) == 0 1089 assert "Bless" in result 1090 1091 def test_remove_effect_substring_match(self, mira: dict, player_dir: Path): 1092 add_effect("test-player", "Potion of Heroism", "+10 temp HP") 1093 remove_effect("test-player", "Heroism") 1094 data = load_character("test-player") 1095 assert len(data["effects"]) == 0 1096 1097 def test_remove_effect_not_found(self, mira: dict, player_dir: Path): 1098 result = remove_effect("test-player", "Nonexistent") 1099 assert "no effect matching" in result.lower() 1100 1101 1102class TestConditions: 1103 def test_add_condition(self, mira: dict, player_dir: Path): 1104 add_condition("test-player", "Poisoned") 1105 data = load_character("test-player") 1106 assert "Poisoned" in data["conditions"] 1107 1108 def test_add_condition_no_duplicate(self, mira: dict, player_dir: Path): 1109 add_condition("test-player", "Prone") 1110 result = add_condition("test-player", "prone") 1111 data = load_character("test-player") 1112 assert len(data["conditions"]) == 1 1113 assert "already" in result.lower() 1114 1115 def test_remove_condition(self, mira: dict, player_dir: Path): 1116 add_condition("test-player", "Frightened") 1117 remove_condition("test-player", "Frightened") 1118 data = load_character("test-player") 1119 assert "Frightened" not in data["conditions"] 1120 1121 1122class TestInventory: 1123 def test_add_item_to_default_location(self, mira: dict, player_dir: Path): 1124 add_item("test-player", "Lockpicks") 1125 data = load_character("test-player") 1126 # Should create on_person if no equipment exists 1127 all_items = [] 1128 for items in data["equipment"].values(): 1129 all_items.extend(items) 1130 assert "Lockpicks" in all_items 1131 1132 def test_add_item_to_specific_location(self, mira: dict, player_dir: Path): 1133 add_item( 1134 "test-player", 1135 "Rope (50ft)", 1136 location="backpack", 1137 ) 1138 data = load_character("test-player") 1139 assert "Rope (50ft)" in data["equipment"]["backpack"] 1140 1141 def test_add_item_substring_location_match(self, mira: dict, player_dir: Path): 1142 add_item("test-player", "First", location="on_person") 1143 add_item("test-player", "Second", location="On Person") 1144 data = load_character("test-player") 1145 # Both should land in the same location 1146 assert "First" in data["equipment"]["on_person"] 1147 assert "Second" in data["equipment"]["on_person"] 1148 1149 def test_remove_item_substring(self, mira: dict, player_dir: Path): 1150 add_item("test-player", "Boots of Elvenkind (worn)") 1151 result = remove_item("test-player", "Boots") 1152 assert "Boots of Elvenkind" in result 1153 1154 def test_remove_item_not_found(self, mira: dict, player_dir: Path): 1155 result = remove_item("test-player", "Nonexistent") 1156 assert "no item matching" in result.lower() 1157 1158 1159class TestMagicItems: 1160 def test_set_item_status_attuned(self, mira: dict, player_dir: Path): 1161 set_item_status( 1162 "test-player", 1163 "Bracer of the Unseen Step", 1164 "attuned", 1165 ) 1166 data = load_character("test-player") 1167 assert "[[Bracer of the Unseen Step]]" in data["magic_items"]["attuned"] 1168 1169 def test_set_item_status_moves_between(self, mira: dict, player_dir: Path): 1170 set_item_status("test-player", "Cloak", "carried") 1171 set_item_status("test-player", "Cloak", "equipped") 1172 data = load_character("test-player") 1173 assert "[[Cloak]]" not in data["magic_items"]["carried"] 1174 assert "[[Cloak]]" in data["magic_items"]["equipped"] 1175 1176 def test_set_item_status_invalid(self, mira: dict, player_dir: Path): 1177 result = set_item_status("test-player", "Cloak", "invalid") 1178 assert "invalid status" in result.lower() 1179 1180 1181class TestResources: 1182 def test_adjust_resource_spend_one(self, mira: dict, player_dir: Path): 1183 adjust_resource("test-player", "hit_dice", -1) 1184 data = load_character("test-player") 1185 assert data["resources"]["hit_dice_d8"]["current"] == 2 1186 1187 def test_adjust_resource_spend_multiple( 1188 self, 1189 mira: dict, 1190 player_dir: Path, 1191 ): 1192 adjust_resource("test-player", "hit_dice", -2) 1193 data = load_character("test-player") 1194 assert data["resources"]["hit_dice_d8"]["current"] == 1 1195 1196 def test_adjust_resource_clamped_to_zero( 1197 self, 1198 mira: dict, 1199 player_dir: Path, 1200 ): 1201 result = adjust_resource("test-player", "hit_dice", -10) 1202 data = load_character("test-player") 1203 assert data["resources"]["hit_dice_d8"]["current"] == 0 1204 assert "short" in result.lower() 1205 1206 def test_adjust_resource_not_found(self, mira: dict, player_dir: Path): 1207 result = adjust_resource("test-player", "nonexistent", -1) 1208 assert "no resource matching" in result.lower() 1209 1210 def test_adjust_resource_restore_clamped_to_max( 1211 self, 1212 mira: dict, 1213 player_dir: Path, 1214 ): 1215 adjust_resource("test-player", "hit_dice", -2) 1216 adjust_resource("test-player", "hit_dice", 100) 1217 data = load_character("test-player") 1218 assert data["resources"]["hit_dice_d8"]["current"] == 3 1219 1220 def test_adjust_resource_zero_delta_is_noop( 1221 self, 1222 mira: dict, 1223 player_dir: Path, 1224 ): 1225 result = adjust_resource("test-player", "hit_dice", 0) 1226 data = load_character("test-player") 1227 assert data["resources"]["hit_dice_d8"]["current"] == 3 1228 assert "no change" in result.lower() 1229 1230 1231class TestRest: 1232 def test_long_rest_refreshes_long_rest_resources( 1233 self, mira: dict, player_dir: Path 1234 ): 1235 adjust_resource("test-player", "hit_dice", -3) 1236 rest("test-player", "long") 1237 data = load_character("test-player") 1238 assert data["resources"]["hit_dice_d8"]["current"] == 3 1239 1240 def test_long_rest_restores_hp(self, mira: dict, player_dir: Path): 1241 damage("test-player", 10) 1242 rest("test-player", "long") 1243 data = load_character("test-player") 1244 assert data["state"]["hp"]["current"] == 24 1245 1246 def test_long_rest_clears_death_saves(self, mira: dict, player_dir: Path): 1247 update_character( 1248 "test-player", 1249 {"state.death_saves.successes": 2, "state.death_saves.failures": 1}, 1250 ) 1251 rest("test-player", "long") 1252 data = load_character("test-player") 1253 assert data["state"]["death_saves"]["successes"] == 0 1254 assert data["state"]["death_saves"]["failures"] == 0 1255 1256 def test_long_rest_reduces_exhaustion(self, mira: dict, player_dir: Path): 1257 update_character("test-player", {"state.exhaustion": 3}) 1258 rest("test-player", "long") 1259 data = load_character("test-player") 1260 assert data["state"]["exhaustion"] == 2 1261 1262 def test_short_rest_doesnt_refresh_long_rest_resources( 1263 self, mira: dict, player_dir: Path 1264 ): 1265 adjust_resource("test-player", "hit_dice", -2) 1266 rest("test-player", "short") 1267 data = load_character("test-player") 1268 # hit_dice has refresh: long_rest, so short rest shouldn't refresh it 1269 assert data["resources"]["hit_dice_d8"]["current"] == 1 1270 1271 def test_invalid_rest_type(self, mira: dict, player_dir: Path): 1272 result = rest("test-player", "epic") 1273 assert "invalid" in result.lower() 1274 1275 1276class TestCoins: 1277 def test_adjust_coins_spending(self, mira: dict, player_dir: Path): 1278 adjust_coins("test-player", {"gp": -5}) 1279 data = load_character("test-player") 1280 assert data["state"]["purse"]["gp"] == 38 1281 1282 def test_adjust_coins_gaining(self, mira: dict, player_dir: Path): 1283 adjust_coins("test-player", {"gp": 10, "sp": 5}) 1284 data = load_character("test-player") 1285 assert data["state"]["purse"]["gp"] == 53 1286 assert data["state"]["purse"]["sp"] == 5 1287 1288 def test_adjust_coins_rejects_underflow(self, mira: dict, player_dir: Path): 1289 before = load_character("test-player")["state"]["purse"]["gp"] 1290 result = adjust_coins("test-player", {"gp": -100}) 1291 data = load_character("test-player") 1292 # Rejected — purse is unchanged. 1293 assert data["state"]["purse"]["gp"] == before 1294 assert "cannot adjust" in result.lower() 1295 assert "insufficient" in result.lower() 1296 1297 def test_adjust_coins_rejects_partial_underflow( 1298 self, 1299 mira: dict, 1300 player_dir: Path, 1301 ): 1302 # Mira has gp but not enough cp. The mixed delta should be 1303 # rejected as a whole — neither denomination should change. 1304 before = load_character("test-player")["state"]["purse"] 1305 result = adjust_coins("test-player", {"gp": -1, "cp": -100}) 1306 data = load_character("test-player") 1307 assert data["state"]["purse"]["gp"] == before["gp"] 1308 assert data["state"]["purse"]["cp"] == before["cp"] 1309 assert "cannot adjust" in result.lower() 1310 1311 def test_adjust_coins_making_change(self, player_dir: Path): 1312 from storied.character.data import create_character 1313 1314 create_character( 1315 player_id="test-player", 1316 name="Coin Test", 1317 race="Human", 1318 char_class="Fighter", 1319 level=1, 1320 abilities={ 1321 "strength": 10, 1322 "dexterity": 10, 1323 "constitution": 10, 1324 "intelligence": 10, 1325 "wisdom": 10, 1326 "charisma": 10, 1327 }, 1328 hp_max=10, 1329 ac=10, 1330 purse={"sp": 9}, 1331 ) 1332 # Paying 5 cp from a 9 sp / 0 cp purse must work as one atomic call 1333 # with the silver going out and the change coming back together. 1334 result = adjust_coins("test-player", {"sp": -1, "cp": 5}) 1335 data = load_character("test-player") 1336 assert data["state"]["purse"]["sp"] == 8 1337 assert data["state"]["purse"]["cp"] == 5 1338 assert "cannot" not in result.lower() 1339 1340 1341class TestNotes: 1342 def test_add_note_creates_file(self, mira: dict, player_dir: Path): 1343 add_note("test-player", "Found a secret door") 1344 notes_path = player_dir / "players" / "test-player" / "notes.md" 1345 assert notes_path.exists() 1346 assert "secret door" in notes_path.read_text() 1347 1348 def test_add_note_appends(self, mira: dict, player_dir: Path): 1349 add_note("test-player", "First note") 1350 add_note("test-player", "Second note") 1351 notes_path = player_dir / "players" / "test-player" / "notes.md" 1352 content = notes_path.read_text() 1353 assert "First note" in content 1354 assert "Second note" in content 1355 1356 def test_add_note_with_anchor(self, mira: dict, player_dir: Path): 1357 add_note( 1358 "test-player", 1359 "Witnessed the heist", 1360 time_anchor="d28-1330", 1361 ) 1362 notes_path = player_dir / "players" / "test-player" / "notes.md" 1363 assert "d28-1330" in notes_path.read_text() 1364 1365 1366# --- Edge cases --- 1367 1368 1369class TestEdgeCases: 1370 def test_no_character_returns_error_for_each_op(self, player_dir: Path): 1371 for fn, args in [ 1372 (damage, (5,)), 1373 (heal, (5,)), 1374 (add_effect, ("Source", "Desc")), 1375 (remove_effect, ("Source",)), 1376 (add_condition, ("Poisoned",)), 1377 (remove_condition, ("Poisoned",)), 1378 (add_item, ("Item",)), 1379 (remove_item, ("Item",)), 1380 (set_item_status, ("Item", "attuned")), 1381 (adjust_resource, ("res", -1)), 1382 (adjust_resource, ("res", 1)), 1383 (rest, ("short",)), 1384 (adjust_coins, ({"gp": 5},)), 1385 ]: 1386 result = fn("missing-player", *args) 1387 assert "no character" in result.lower(), f"{fn.__name__} failed"