# pyright: reportOptionalSubscript=false, reportOptionalMemberAccess=false # pyright: reportArgumentType=false, reportOperatorIssue=false, reportReturnType=false # Tests immediately subscript dicts returned by load_character() without # the assert-not-None dance — the test setup guarantees the file exists. """Tests for the character system: data, computation, display, and operations.""" from pathlib import Path import pytest from storied.character import ( ABILITIES, SKILL_TO_ABILITY, ability_modifier, add_condition, add_effect, add_item, add_note, adjust_coins, adjust_resource, create_character, damage, effective_hp, format_character_context, format_sheet, format_status, has_expertise_in, heal, is_proficient_in, level_up, load_character, load_character_prose, passive_score, proficiency_bonus, remove_condition, remove_effect, remove_item, rest, save_character, save_modifier, set_item_status, skill_modifier, total_level, update_character, ) # --- Fixtures --- @pytest.fixture def player_dir(tmp_path: Path) -> Path: (tmp_path / "players" / "test-player").mkdir(parents=True) return tmp_path @pytest.fixture def mira(player_dir: Path) -> dict: """A level 3 Rogue/Thief, modeled after Mira Ashvale.""" create_character( player_id="test-player", name="Mira", race="Human", char_class="Rogue", subclass="Thief", level=3, abilities={ "strength": 11, "dexterity": 18, "constitution": 14, "intelligence": 15, "wisdom": 14, "charisma": 18, }, hp_max=24, ac=16, background="Criminal", purse={"gp": 43, "cp": 66}, ) # Add proficiencies and resources via update_character update_character( "test-player", { "proficiencies.saves": ["dexterity", "intelligence"], "proficiencies.skills.stealth": "expertise", "proficiencies.skills.sleight_of_hand": "expertise", "proficiencies.skills.acrobatics": "proficient", "proficiencies.skills.perception": "proficient", "proficiencies.skills.deception": "proficient", "proficiencies.skills.insight": "proficient", }, ) update_character( "test-player", { "resources.hit_dice_d8": { "current": 3, "max": 3, "refresh": "long_rest", "notes": "Hit Dice (d8)", }, }, ) return load_character("test-player") # --- Data layer tests --- class TestDataLayer: def test_load_returns_none_for_missing(self, player_dir: Path): assert load_character("test-player") is None def test_create_and_load(self, player_dir: Path): create_character( player_id="test-player", name="Conan", race="Human", char_class="Barbarian", level=1, abilities={ "strength": 18, "dexterity": 14, "constitution": 16, "intelligence": 8, "wisdom": 10, "charisma": 12, }, hp_max=15, ac=14, ) data = load_character("test-player") assert data["identity"]["name"] == "Conan" assert data["identity"]["classes"][0]["class"] == "Barbarian" assert data["identity"]["classes"][0]["level"] == 1 assert data["abilities"]["strength"] == 18 assert data["state"]["hp"]["max"] == 15 def test_load_fills_defaults(self, player_dir: Path): # Save a sparse character save_character("test-player", {"identity": {"name": "Sparse"}}) data = load_character("test-player") # Default schema should be merged in assert "abilities" in data assert "state" in data assert data["abilities"]["strength"] == 10 def test_create_writes_backstory(self, player_dir: Path): create_character( player_id="test-player", name="Storyteller", race="Half-Elf", char_class="Bard", level=1, abilities={ "strength": 8, "dexterity": 14, "constitution": 12, "intelligence": 13, "wisdom": 10, "charisma": 16, }, hp_max=9, ac=12, backstory="A wandering minstrel with secrets.", ) prose = load_character_prose("test-player") assert "wandering minstrel" in prose class TestUpdateCharacter: def test_update_simple_field(self, mira: dict, player_dir: Path): update_character("test-player", {"state.ac": 17}) data = load_character("test-player") assert data["state"]["ac"] == 17 def test_update_nested_via_dot(self, mira: dict, player_dir: Path): update_character("test-player", {"state.hp.max": 30}) data = load_character("test-player") assert data["state"]["hp"]["max"] == 30 def test_negative_hp_clamped_to_zero(self, mira: dict, player_dir: Path): update_character("test-player", {"state.hp.current": -5}) data = load_character("test-player") assert data["state"]["hp"]["current"] == 0 def test_hp_clamped_to_max(self, mira: dict, player_dir: Path): update_character("test-player", {"state.hp.current": 100}) data = load_character("test-player") assert data["state"]["hp"]["current"] == 24 def test_negative_coins_clamped(self, mira: dict, player_dir: Path): update_character("test-player", {"state.purse.sp": -20}) data = load_character("test-player") assert data["state"]["purse"]["sp"] == 0 def test_no_character_returns_error(self, player_dir: Path): result = update_character("missing", {"foo": "bar"}) assert "no character" in result.lower() class TestSchemaValidation: """update_character must reject schema-violating writes with a DM-readable error and leave the on-disk character unchanged.""" def test_resources_as_list_is_rejected(self, mira: dict, player_dir: Path): before = load_character("test-player") result = update_character( "test-player", {"resources": [{"name": "Channel Divinity", "current": 1, "max": 1}]}, ) assert "rejected" in result.lower() assert "resources" in result assert "dict" in result.lower() # On-disk character is unchanged after = load_character("test-player") assert after["resources"] == before["resources"] def test_equipment_as_list_is_rejected(self, mira: dict, player_dir: Path): result = update_character( "test-player", {"equipment": ["Longsword", "Shield"]}, ) assert "rejected" in result.lower() assert "equipment" in result def test_state_hp_must_be_a_dict(self, mira: dict, player_dir: Path): result = update_character( "test-player", {"state.hp": 24}, # missing required fields ) assert "rejected" in result.lower() # Original HP block is preserved data = load_character("test-player") assert isinstance(data["state"]["hp"], dict) assert data["state"]["hp"]["max"] == 24 def test_valid_resources_update_succeeds(self, mira: dict, player_dir: Path): result = update_character( "test-player", { "resources.channel_divinity": { "current": 1, "max": 1, "refresh": "short_rest", "notes": "Channel Divinity", } }, ) assert "rejected" not in result.lower() data = load_character("test-player") assert data["resources"]["channel_divinity"]["current"] == 1 def test_error_message_contains_an_example(self, mira: dict, player_dir: Path): """The DM should be able to fix the call from the error message alone.""" result = update_character( "test-player", {"resources": [{"name": "x"}]}, ) # Concrete example helps the LLM correct itself assert "channel_divinity" in result or "{" in result class TestSchemaCoercion: """load_character heals known mis-shapes from older sessions so existing characters keep working without manual repair.""" def test_load_coerces_resources_list_to_dict(self, player_dir: Path): import yaml # Hand-write a character with resources as a list (the bad shape) path = player_dir / "players" / "test-player" / "character.yaml" path.write_text( yaml.dump( { "identity": { "name": "Damaged", "classes": [{"class": "Cleric", "level": 3}], }, "abilities": { "strength": 10, "dexterity": 10, "constitution": 10, "intelligence": 10, "wisdom": 14, "charisma": 10, }, "state": {"hp": {"max": 20, "current": 20, "temp": 0}}, "resources": [ { "name": "Channel Divinity", "current": 1, "max": 1, "refresh": "short_rest", }, { "name": "Lay on Hands", "current": 15, "max": 15, "refresh": "long_rest", }, ], } ) ) data = load_character("test-player") assert isinstance(data["resources"], dict) assert "channel_divinity" in data["resources"] assert data["resources"]["channel_divinity"]["current"] == 1 assert data["resources"]["lay_on_hands"]["max"] == 15 def test_coerced_character_can_adjust_resource(self, player_dir: Path): """End-to-end: a character with bad-shape resources on disk should be usable via adjust_resource after load coercion.""" import yaml path = player_dir / "players" / "test-player" / "character.yaml" path.write_text( yaml.dump( { "identity": {"name": "Damaged"}, "abilities": { "strength": 10, "dexterity": 10, "constitution": 10, "intelligence": 10, "wisdom": 10, "charisma": 10, }, "state": {"hp": {"max": 20, "current": 20, "temp": 0}}, "resources": [ { "name": "Channel Divinity", "current": 1, "max": 1, "refresh": "short_rest", "notes": "Channel Divinity", }, ], } ) ) result = adjust_resource("test-player", "channel", -1) assert "Used 1" in result def test_load_coerces_equipment_list_to_dict(self, player_dir: Path): import yaml path = player_dir / "players" / "test-player" / "character.yaml" path.write_text( yaml.dump( { "identity": {"name": "Damaged"}, "abilities": { "strength": 10, "dexterity": 10, "constitution": 10, "intelligence": 10, "wisdom": 10, "charisma": 10, }, "state": {"hp": {"max": 20, "current": 20, "temp": 0}}, "equipment": ["Longsword", "Shield"], } ) ) data = load_character("test-player") assert isinstance(data["equipment"], dict) assert data["equipment"]["on_person"] == ["Longsword", "Shield"] # --- Computation tests --- class TestComputation: def test_ability_modifier(self): assert ability_modifier(10) == 0 assert ability_modifier(11) == 0 assert ability_modifier(12) == 1 assert ability_modifier(18) == 4 assert ability_modifier(8) == -1 assert ability_modifier(20) == 5 def test_total_level_single_class(self, mira: dict): assert total_level(mira) == 3 def test_total_level_multiclass(self, player_dir: Path): save_character( "test-player", { "identity": { "classes": [ {"class": "Fighter", "level": 3}, {"class": "Wizard", "level": 2}, ] } }, ) data = load_character("test-player") assert total_level(data) == 5 def test_proficiency_bonus_scaling(self, player_dir: Path): for level, expected in [ (1, 2), (4, 2), (5, 3), (8, 3), (9, 4), (12, 4), (13, 5), (16, 5), (17, 6), (20, 6), ]: save_character( "test-player", {"identity": {"classes": [{"class": "Fighter", "level": level}]}}, ) data = load_character("test-player") assert proficiency_bonus(data) == expected, f"level {level}" def test_skill_modifier_with_expertise(self, mira: dict): total, breakdown = skill_modifier(mira, "stealth") # +4 dex, +4 expertise (2 prof bonus * 2) assert total == 8 assert any("dex" in b.lower() for b in breakdown) assert any("expertise" in b.lower() for b in breakdown) def test_skill_modifier_proficient(self, mira: dict): total, breakdown = skill_modifier(mira, "perception") # +2 wis, +2 prof assert total == 4 assert any("proficient" in b.lower() for b in breakdown) def test_skill_modifier_no_proficiency(self, mira: dict): total, _ = skill_modifier(mira, "athletics") # +0 str, no prof assert total == 0 def test_save_modifier_proficient(self, mira: dict): total, breakdown = save_modifier(mira, "dexterity") # +4 dex, +2 prof assert total == 6 assert any("proficient" in b.lower() for b in breakdown) def test_save_modifier_not_proficient(self, mira: dict): total, _ = save_modifier(mira, "wisdom") # +2 wis only assert total == 2 def test_passive_perception(self, mira: dict): # 10 + perception modifier (+4) = 14 assert passive_score(mira, "perception") == 14 def test_skill_modifier_ignores_exhaustion(self, mira: dict): """Skill modifiers show raw ability + proficiency math. Exhaustion is a rule effect the DM applies at roll time, not baked into the displayed number.""" mira["state"]["exhaustion"] = 2 total, _ = skill_modifier(mira, "stealth") assert total == 8 # +4 dex + 4 expertise, exhaustion NOT folded in def test_save_modifier_ignores_exhaustion(self, mira: dict): mira["state"]["exhaustion"] = 1 total, _ = save_modifier(mira, "dexterity") assert total == 6 # +4 dex + 2 prof, exhaustion NOT folded in def test_passive_perception_ignores_conditions(self, mira: dict): """Passive perception is the raw 10 + perception modifier. The DM applies condition-based adjustments (e.g. -5 for disadvantage per 5e 2024) per whatever ruleset they're running.""" assert passive_score(mira, "perception") == 14 mira["conditions"] = ["Poisoned"] assert passive_score(mira, "perception") == 14 def test_effective_hp_with_temp(self, player_dir: Path): save_character( "test-player", {"state": {"hp": {"max": 30, "current": 20, "temp": 5}}} ) data = load_character("test-player") hp = effective_hp(data) assert hp["effective"] == 25 assert hp["current"] == 20 assert hp["temp"] == 5 def test_is_proficient_in(self, mira: dict): assert is_proficient_in(mira, "stealth") assert is_proficient_in(mira, "perception") assert not is_proficient_in(mira, "athletics") def test_has_expertise_in(self, mira: dict): assert has_expertise_in(mira, "stealth") assert not has_expertise_in(mira, "perception") assert not has_expertise_in(mira, "athletics") def test_skill_to_ability_complete(self): # Every skill mapped assert "acrobatics" in SKILL_TO_ABILITY assert "stealth" in SKILL_TO_ABILITY assert "investigation" in SKILL_TO_ABILITY assert len(SKILL_TO_ABILITY) == 18 def test_abilities_constant(self): assert len(ABILITIES) == 6 # --- Display tests --- class TestDisplay: def test_format_status_includes_name_and_class(self, mira: dict): result = format_status(mira) assert "Mira" in result assert "Rogue" in result assert "Thief" in result def test_format_status_includes_hp(self, mira: dict): result = format_status(mira) assert "24/24" in result def test_format_status_includes_purse(self, mira: dict): result = format_status(mira) assert "43 gp" in result def test_format_sheet_includes_skills(self, mira: dict): result = format_sheet(mira) assert "Stealth" in result assert "+8" in result def test_format_sheet_shows_expertise_marker(self, mira: dict): result = format_sheet(mira) # Stealth has expertise, marked with ★★ assert "★★" in result def test_format_sheet_includes_passive_perception(self, mira: dict): result = format_sheet(mira) assert "Passive Perception" in result assert "14" in result def test_format_character_context_includes_advancement( self, mira: dict, player_dir: Path ): update_character("test-player", {"advancement_ready": 4}) data = load_character("test-player") result = format_character_context(data) assert "Advancement Ready" in result assert "Level 4" in result def test_format_sheet_tolerates_wrong_shaped_resources(self, mira: dict): """If the LLM writes the wrong shape (a list instead of a dict-of-pools), the renderer must skip the section instead of crashing the turn.""" mira["resources"] = ["hit_dice_d8", "bracer_unseen_step"] result = format_sheet(mira) assert "Resources" not in result # section omitted, no crash def test_format_sheet_tolerates_wrong_shaped_magic_items(self, mira: dict): mira["magic_items"] = ["[[Bracer]]"] # should be a dict result = format_sheet(mira) assert "Magic Items" not in result # section omitted, no crash def test_format_sheet_tolerates_wrong_shaped_equipment(self, mira: dict): mira["equipment"] = ["sword", "shield"] # should be a dict-of-locations result = format_sheet(mira) assert "Equipment" not in result # section omitted, no crash def test_format_sheet_renders_active_effects(self, mira: dict): mira["effects"] = [ {"source": "Bless", "description": "+1d4 attacks/saves"}, {"source": "Heroism", "description": "+10 temp HP", "expires": "d2-1430"}, ] result = format_sheet(mira) assert "Active Effects" in result assert "Bless" in result assert "Heroism" in result assert "until d2-1430" in result def test_format_sheet_renders_resources_with_die(self, mira: dict): mira["resources"] = { "bardic_inspiration": { "current": 3, "max": 3, "refresh": "long_rest", "notes": "Bardic Inspiration", "die": "d8", }, } result = format_sheet(mira) assert "Bardic Inspiration: 3/3" in result assert "d8" in result def test_format_sheet_renders_magic_items_carried(self, mira: dict): mira["magic_items"] = { "attuned": ["[[Bracer]]"], "equipped": ["[[Boots]]"], "carried": ["[[Cloak]]", "[[Ring]]"], } result = format_sheet(mira) assert "Magic Items" in result assert "Attuned: [[Bracer]]" in result assert "Equipped: [[Boots]]" in result assert "Carried: [[Cloak]], [[Ring]]" in result def test_format_sheet_renders_features(self, mira: dict): mira["features"] = [ {"name": "Sneak Attack", "text": "+2d6 damage", "source": "Rogue Lv1"}, {"name": "Cunning Action", "text": "Bonus action: Dash/Disengage/Hide"}, ] result = format_sheet(mira) assert "Features" in result assert "Sneak Attack" in result assert "Rogue Lv1" in result assert "Cunning Action" in result def test_format_sheet_renders_conditions(self, mira: dict): mira["conditions"] = ["Poisoned", "Prone"] result = format_sheet(mira) assert "Conditions:" in result assert "Poisoned" in result assert "Prone" in result def test_format_sheet_renders_defenses(self, mira: dict): mira["defenses"] = { "resistances": [{"damage": "fire", "source": "racial"}], "vulnerabilities": [{"damage": "cold", "source": "curse"}], "immunities": { "damage": ["psychic"], "conditions": ["charmed"], }, } result = format_sheet(mira) assert "Resistances:" in result assert "fire" in result assert "Vulnerabilities:" in result assert "cold" in result assert "Damage Immunities:" in result assert "psychic" in result assert "Condition Immunities:" in result assert "charmed" in result def test_format_sheet_renders_temp_hp_in_vital_line(self, mira: dict): mira["state"]["hp"]["temp"] = 5 result = format_sheet(mira) assert "+5 temp" in result def test_format_sheet_shows_inspiration_when_available(self, mira: dict): mira["state"]["inspiration"] = True sheet = format_sheet(mira) assert "Inspiration" in sheet assert "available" in sheet def test_format_sheet_shows_exhaustion_reminder_when_nonzero( self, mira: dict, ): """When exhaustion is set, the sheet shows a reminder that the DM applies the effect — it does NOT fold a numeric penalty into the displayed skill modifiers.""" mira["state"]["exhaustion"] = 2 sheet = format_sheet(mira) assert "Exhaustion 2" in sheet # Stealth should still read +8 (raw), not +4 (folded) assert "Stealth" in sheet assert "+8" in sheet def test_format_sheet_renders_exhaustion_in_vital_line(self, mira: dict): mira["state"]["exhaustion"] = 2 result = format_sheet(mira) assert "Exhaustion 2" in result def test_format_status_omits_empty_purse(self, player_dir: Path): create_character( player_id="test-player", name="Broke", race="Human", char_class="Fighter", level=1, abilities={ "strength": 10, "dexterity": 10, "constitution": 10, "intelligence": 10, "wisdom": 10, "charisma": 10, }, hp_max=10, ac=10, ) data = load_character("test-player") result = format_status(data) assert "Purse" not in result def test_format_status_truncates_equipment_over_eight_items( self, mira: dict, player_dir: Path, ): # Add lots of items so the truncation branch fires for i in range(12): update_character( "test-player", {"equipment.on_person": [f"item_{i}" for i in range(12)]}, ) data = load_character("test-player") result = format_status(data) assert "and 4 more" in result # 12 items - 8 shown = 4 more def test_format_character_display_respects_data_home( self, mira: dict, player_dir: Path, tmp_path: Path, ): """The /me slash command resolves the character via the ``storied.paths`` module globals — sandbox sessions get the sandbox character because the data home was overridden in ``cmd_play`` before this function is called.""" from storied.cli import _format_character_display from storied.paths import using_data_home # mira lives at player_dir/players/test-player; an unrelated # other_path has no character. Pointing data_home at other_path # must return None instead of silently loading mira from elsewhere. other_path = tmp_path / "other" (other_path / "players" / "test-player").mkdir(parents=True) with using_data_home(other_path): result = _format_character_display("test-player", full=True) assert result is None, ( "_format_character_display must use the configured data_home; " "falling back to a stale path is what caused /me to show the " "wrong character in sandbox sessions." ) # And it should return real content when data_home points at # the dir mira actually lives in. with using_data_home(player_dir): result = _format_character_display("test-player", full=True) assert result is not None assert "Mira" in result # --- Operations tests --- class TestDamageHeal: def test_damage_subtracts_hp(self, mira: dict, player_dir: Path): damage("test-player", 5) data = load_character("test-player") assert data["state"]["hp"]["current"] == 19 def test_damage_temp_hp_absorbs_first(self, mira: dict, player_dir: Path): update_character("test-player", {"state.hp.temp": 5}) damage("test-player", 3) data = load_character("test-player") assert data["state"]["hp"]["temp"] == 2 assert data["state"]["hp"]["current"] == 24 def test_damage_temp_overflow_to_hp(self, mira: dict, player_dir: Path): update_character("test-player", {"state.hp.temp": 5}) damage("test-player", 8) data = load_character("test-player") assert data["state"]["hp"]["temp"] == 0 assert data["state"]["hp"]["current"] == 21 def test_damage_clamps_to_zero(self, mira: dict, player_dir: Path): damage("test-player", 100) data = load_character("test-player") assert data["state"]["hp"]["current"] == 0 def test_damage_at_zero_mentions_death_saves(self, mira: dict, player_dir: Path): result = damage("test-player", 100) assert "death save" in result.lower() def test_heal_restores_hp(self, mira: dict, player_dir: Path): damage("test-player", 10) heal("test-player", 5) data = load_character("test-player") assert data["state"]["hp"]["current"] == 19 def test_heal_clamped_to_max(self, mira: dict, player_dir: Path): heal("test-player", 100) data = load_character("test-player") assert data["state"]["hp"]["current"] == 24 def test_damage_with_type_in_message(self, mira: dict, player_dir: Path): result = damage("test-player", 3, damage_type="fire") assert "fire" in result def test_damage_ignores_resistances(self, mira: dict, player_dir: Path): """Resistances are metadata for the DM's reference — the tool applies raw damage and lets the DM pre-compute rule effects.""" update_character( "test-player", {"defenses.resistances": [{"damage": "fire"}]}, ) damage("test-player", 10, damage_type="fire") data = load_character("test-player") # Raw 10, not halved assert data["state"]["hp"]["current"] == 14 def test_damage_ignores_vulnerabilities(self, mira: dict, player_dir: Path): update_character( "test-player", {"defenses.vulnerabilities": [{"damage": "radiant"}]}, ) damage( "test-player", 5, damage_type="radiant", ) data = load_character("test-player") # Raw 5, not doubled assert data["state"]["hp"]["current"] == 19 def test_damage_ignores_immunities(self, mira: dict, player_dir: Path): update_character( "test-player", {"defenses.immunities": {"damage": ["poison"], "conditions": []}}, ) damage( "test-player", 12, damage_type="poison", ) data = load_character("test-player") # Raw 12, not zeroed assert data["state"]["hp"]["current"] == 12 class TestLevelUp: def test_level_up_increments_class_level( self, mira: dict, player_dir: Path, ): result = level_up( "test-player", class_name="Rogue", new_level=4, hp_gain=6, ) data = load_character("test-player") assert data["identity"]["classes"][0]["level"] == 4 assert "3 → 4" in result def test_level_up_adds_hp_to_max_and_current( self, mira: dict, player_dir: Path, ): # mira starts with 24/24 level_up( "test-player", "Rogue", new_level=4, hp_gain=6, ) data = load_character("test-player") assert data["state"]["hp"]["max"] == 30 assert data["state"]["hp"]["current"] == 30 def test_level_up_preserves_wounded_current_relative( self, mira: dict, player_dir: Path, ): # Wound the character first damage("test-player", 10) # HP is now 14/24 level_up( "test-player", "Rogue", new_level=4, hp_gain=6, ) data = load_character("test-player") # Max goes up by 6; current also goes up by 6 (so 14+6=20, 24+6=30) assert data["state"]["hp"]["max"] == 30 assert data["state"]["hp"]["current"] == 20 def test_level_up_sets_level_since( self, mira: dict, player_dir: Path, ): level_up( "test-player", "Rogue", new_level=4, hp_gain=6, time_anchor="#d12-1500", ) data = load_character("test-player") assert data["level_since"] == "#d12-1500" def test_level_up_clears_advancement_ready( self, mira: dict, player_dir: Path, ): update_character( "test-player", {"advancement_ready": 4}, ) level_up( "test-player", "Rogue", new_level=4, hp_gain=6, ) data = load_character("test-player") assert data.get("advancement_ready") is None def test_level_up_replaces_features_when_provided( self, mira: dict, player_dir: Path, ): new_features = [ {"name": "Sneak Attack", "text": "2d6"}, {"name": "Uncanny Dodge", "text": "Reaction for half damage"}, ] level_up( "test-player", "Rogue", new_level=4, hp_gain=6, features=new_features, ) data = load_character("test-player") assert len(data["features"]) == 2 assert data["features"][1]["name"] == "Uncanny Dodge" def test_level_up_preserves_features_when_omitted( self, mira: dict, player_dir: Path, ): update_character( "test-player", {"features": [{"name": "Sneak Attack", "text": "2d6"}]}, ) level_up( "test-player", "Rogue", new_level=4, hp_gain=6, ) data = load_character("test-player") assert data["features"] == [{"name": "Sneak Attack", "text": "2d6"}] def test_level_up_rejects_downgrade( self, mira: dict, player_dir: Path, ): result = level_up( "test-player", "Rogue", new_level=2, hp_gain=0, ) assert "Refusing" in result data = load_character("test-player") assert data["identity"]["classes"][0]["level"] == 3 # unchanged def test_level_up_rejects_unknown_class( self, mira: dict, player_dir: Path, ): result = level_up( "test-player", "Wizard", new_level=4, hp_gain=4, ) assert "No class matching" in result data = load_character("test-player") assert data["identity"]["classes"][0]["level"] == 3 def test_level_up_multiclass_finds_correct_class( self, mira: dict, player_dir: Path, ): # Add a Fighter level to make Mira multiclass update_character( "test-player", { "identity.classes": [ {"class": "Rogue", "subclass": "Thief", "level": 3}, {"class": "Fighter", "subclass": None, "level": 1}, ] }, ) level_up( "test-player", "Fighter", new_level=2, hp_gain=7, ) data = load_character("test-player") assert data["identity"]["classes"][0]["level"] == 3 # Rogue unchanged assert data["identity"]["classes"][1]["level"] == 2 # Fighter bumped class TestConcentration: """`concentration=True` is a metadata flag — the tool records it on the effect so the sheet can display which effect is the current concentration. It does NOT enforce uniqueness or emit save hints. The DM decides when to drop a concentration effect.""" def test_add_concentration_effect_flags_it( self, mira: dict, player_dir: Path, ): result = add_effect( "test-player", "Bless", "+1d4 to attacks", concentration=True, ) assert "[Concentration]" in result data = load_character("test-player") assert data["effects"][0]["concentration"] is True def test_multiple_concentration_effects_allowed( self, mira: dict, player_dir: Path, ): """No enforcement — the DM can flag two effects concentration.""" add_effect( "test-player", "Bless", "+1d4", concentration=True, ) add_effect( "test-player", "Hold Person", "paralyzed", concentration=True, ) data = load_character("test-player") sources = [e["source"] for e in data["effects"]] assert "Bless" in sources assert "Hold Person" in sources def test_damage_does_not_emit_concentration_save_hint( self, mira: dict, player_dir: Path, ): """The DM issues concentration saves manually per the rules.""" add_effect( "test-player", "Bless", "+1d4", concentration=True, ) result = damage("test-player", 6) assert "Concentration save" not in result class TestEffects: def test_add_effect_appends(self, mira: dict, player_dir: Path): add_effect("test-player", "Bless", "+1d4 to attacks") data = load_character("test-player") assert len(data["effects"]) == 1 assert data["effects"][0]["source"] == "Bless" def test_add_effect_with_expiry(self, mira: dict, player_dir: Path): add_effect( "test-player", "Potion", "+10 temp HP", expires="d1-1430", ) data = load_character("test-player") assert data["effects"][0]["expires"] == "d1-1430" def test_remove_effect_by_source(self, mira: dict, player_dir: Path): add_effect("test-player", "Bless", "+1d4") result = remove_effect("test-player", "bless") data = load_character("test-player") assert len(data["effects"]) == 0 assert "Bless" in result def test_remove_effect_substring_match(self, mira: dict, player_dir: Path): add_effect("test-player", "Potion of Heroism", "+10 temp HP") remove_effect("test-player", "Heroism") data = load_character("test-player") assert len(data["effects"]) == 0 def test_remove_effect_not_found(self, mira: dict, player_dir: Path): result = remove_effect("test-player", "Nonexistent") assert "no effect matching" in result.lower() class TestConditions: def test_add_condition(self, mira: dict, player_dir: Path): add_condition("test-player", "Poisoned") data = load_character("test-player") assert "Poisoned" in data["conditions"] def test_add_condition_no_duplicate(self, mira: dict, player_dir: Path): add_condition("test-player", "Prone") result = add_condition("test-player", "prone") data = load_character("test-player") assert len(data["conditions"]) == 1 assert "already" in result.lower() def test_remove_condition(self, mira: dict, player_dir: Path): add_condition("test-player", "Frightened") remove_condition("test-player", "Frightened") data = load_character("test-player") assert "Frightened" not in data["conditions"] class TestInventory: def test_add_item_to_default_location(self, mira: dict, player_dir: Path): add_item("test-player", "Lockpicks") data = load_character("test-player") # Should create on_person if no equipment exists all_items = [] for items in data["equipment"].values(): all_items.extend(items) assert "Lockpicks" in all_items def test_add_item_to_specific_location(self, mira: dict, player_dir: Path): add_item( "test-player", "Rope (50ft)", location="backpack", ) data = load_character("test-player") assert "Rope (50ft)" in data["equipment"]["backpack"] def test_add_item_substring_location_match(self, mira: dict, player_dir: Path): add_item("test-player", "First", location="on_person") add_item("test-player", "Second", location="On Person") data = load_character("test-player") # Both should land in the same location assert "First" in data["equipment"]["on_person"] assert "Second" in data["equipment"]["on_person"] def test_remove_item_substring(self, mira: dict, player_dir: Path): add_item("test-player", "Boots of Elvenkind (worn)") result = remove_item("test-player", "Boots") assert "Boots of Elvenkind" in result def test_remove_item_not_found(self, mira: dict, player_dir: Path): result = remove_item("test-player", "Nonexistent") assert "no item matching" in result.lower() class TestMagicItems: def test_set_item_status_attuned(self, mira: dict, player_dir: Path): set_item_status( "test-player", "Bracer of the Unseen Step", "attuned", ) data = load_character("test-player") assert "[[Bracer of the Unseen Step]]" in data["magic_items"]["attuned"] def test_set_item_status_moves_between(self, mira: dict, player_dir: Path): set_item_status("test-player", "Cloak", "carried") set_item_status("test-player", "Cloak", "equipped") data = load_character("test-player") assert "[[Cloak]]" not in data["magic_items"]["carried"] assert "[[Cloak]]" in data["magic_items"]["equipped"] def test_set_item_status_invalid(self, mira: dict, player_dir: Path): result = set_item_status("test-player", "Cloak", "invalid") assert "invalid status" in result.lower() class TestResources: def test_adjust_resource_spend_one(self, mira: dict, player_dir: Path): adjust_resource("test-player", "hit_dice", -1) data = load_character("test-player") assert data["resources"]["hit_dice_d8"]["current"] == 2 def test_adjust_resource_spend_multiple( self, mira: dict, player_dir: Path, ): adjust_resource("test-player", "hit_dice", -2) data = load_character("test-player") assert data["resources"]["hit_dice_d8"]["current"] == 1 def test_adjust_resource_clamped_to_zero( self, mira: dict, player_dir: Path, ): result = adjust_resource("test-player", "hit_dice", -10) data = load_character("test-player") assert data["resources"]["hit_dice_d8"]["current"] == 0 assert "short" in result.lower() def test_adjust_resource_not_found(self, mira: dict, player_dir: Path): result = adjust_resource("test-player", "nonexistent", -1) assert "no resource matching" in result.lower() def test_adjust_resource_restore_clamped_to_max( self, mira: dict, player_dir: Path, ): adjust_resource("test-player", "hit_dice", -2) adjust_resource("test-player", "hit_dice", 100) data = load_character("test-player") assert data["resources"]["hit_dice_d8"]["current"] == 3 def test_adjust_resource_zero_delta_is_noop( self, mira: dict, player_dir: Path, ): result = adjust_resource("test-player", "hit_dice", 0) data = load_character("test-player") assert data["resources"]["hit_dice_d8"]["current"] == 3 assert "no change" in result.lower() class TestRest: def test_long_rest_refreshes_long_rest_resources( self, mira: dict, player_dir: Path ): adjust_resource("test-player", "hit_dice", -3) rest("test-player", "long") data = load_character("test-player") assert data["resources"]["hit_dice_d8"]["current"] == 3 def test_long_rest_restores_hp(self, mira: dict, player_dir: Path): damage("test-player", 10) rest("test-player", "long") data = load_character("test-player") assert data["state"]["hp"]["current"] == 24 def test_long_rest_clears_death_saves(self, mira: dict, player_dir: Path): update_character( "test-player", {"state.death_saves.successes": 2, "state.death_saves.failures": 1}, ) rest("test-player", "long") data = load_character("test-player") assert data["state"]["death_saves"]["successes"] == 0 assert data["state"]["death_saves"]["failures"] == 0 def test_long_rest_reduces_exhaustion(self, mira: dict, player_dir: Path): update_character("test-player", {"state.exhaustion": 3}) rest("test-player", "long") data = load_character("test-player") assert data["state"]["exhaustion"] == 2 def test_short_rest_doesnt_refresh_long_rest_resources( self, mira: dict, player_dir: Path ): adjust_resource("test-player", "hit_dice", -2) rest("test-player", "short") data = load_character("test-player") # hit_dice has refresh: long_rest, so short rest shouldn't refresh it assert data["resources"]["hit_dice_d8"]["current"] == 1 def test_invalid_rest_type(self, mira: dict, player_dir: Path): result = rest("test-player", "epic") assert "invalid" in result.lower() class TestCoins: def test_adjust_coins_spending(self, mira: dict, player_dir: Path): adjust_coins("test-player", {"gp": -5}) data = load_character("test-player") assert data["state"]["purse"]["gp"] == 38 def test_adjust_coins_gaining(self, mira: dict, player_dir: Path): adjust_coins("test-player", {"gp": 10, "sp": 5}) data = load_character("test-player") assert data["state"]["purse"]["gp"] == 53 assert data["state"]["purse"]["sp"] == 5 def test_adjust_coins_rejects_underflow(self, mira: dict, player_dir: Path): before = load_character("test-player")["state"]["purse"]["gp"] result = adjust_coins("test-player", {"gp": -100}) data = load_character("test-player") # Rejected — purse is unchanged. assert data["state"]["purse"]["gp"] == before assert "cannot adjust" in result.lower() assert "insufficient" in result.lower() def test_adjust_coins_rejects_partial_underflow( self, mira: dict, player_dir: Path, ): # Mira has gp but not enough cp. The mixed delta should be # rejected as a whole — neither denomination should change. before = load_character("test-player")["state"]["purse"] result = adjust_coins("test-player", {"gp": -1, "cp": -100}) data = load_character("test-player") assert data["state"]["purse"]["gp"] == before["gp"] assert data["state"]["purse"]["cp"] == before["cp"] assert "cannot adjust" in result.lower() def test_adjust_coins_making_change(self, player_dir: Path): from storied.character.data import create_character create_character( player_id="test-player", name="Coin Test", race="Human", char_class="Fighter", level=1, abilities={ "strength": 10, "dexterity": 10, "constitution": 10, "intelligence": 10, "wisdom": 10, "charisma": 10, }, hp_max=10, ac=10, purse={"sp": 9}, ) # Paying 5 cp from a 9 sp / 0 cp purse must work as one atomic call # with the silver going out and the change coming back together. result = adjust_coins("test-player", {"sp": -1, "cp": 5}) data = load_character("test-player") assert data["state"]["purse"]["sp"] == 8 assert data["state"]["purse"]["cp"] == 5 assert "cannot" not in result.lower() class TestNotes: def test_add_note_creates_file(self, mira: dict, player_dir: Path): add_note("test-player", "Found a secret door") notes_path = player_dir / "players" / "test-player" / "notes.md" assert notes_path.exists() assert "secret door" in notes_path.read_text() def test_add_note_appends(self, mira: dict, player_dir: Path): add_note("test-player", "First note") add_note("test-player", "Second note") notes_path = player_dir / "players" / "test-player" / "notes.md" content = notes_path.read_text() assert "First note" in content assert "Second note" in content def test_add_note_with_anchor(self, mira: dict, player_dir: Path): add_note( "test-player", "Witnessed the heist", time_anchor="d28-1330", ) notes_path = player_dir / "players" / "test-player" / "notes.md" assert "d28-1330" in notes_path.read_text() # --- Edge cases --- class TestEdgeCases: def test_no_character_returns_error_for_each_op(self, player_dir: Path): for fn, args in [ (damage, (5,)), (heal, (5,)), (add_effect, ("Source", "Desc")), (remove_effect, ("Source",)), (add_condition, ("Poisoned",)), (remove_condition, ("Poisoned",)), (add_item, ("Item",)), (remove_item, ("Item",)), (set_item_status, ("Item", "attuned")), (adjust_resource, ("res", -1)), (adjust_resource, ("res", 1)), (rest, ("short",)), (adjust_coins, ({"gp": 5},)), ]: result = fn("missing-player", *args) assert "no character" in result.lower(), f"{fn.__name__} failed"