A 5e storytelling engine with an LLM DM
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"