fix(rules): expose healing_done in per-combatant stats and the CLI report - the channel/spell healing deliberately removed from damage_dealt (2e2873f) was invisible; CombatantStats/_LiveStats gain a healing_done counter booked with the hp actually restored (capped at the target's hp_max, overheal uncounted) by _channel_heal and by spell HealEffect on living targets, while cure-as-damage on undead stays in damage_dealt and the damage counters keep their exact semantics; the runner aggregates and averages the new field and the French report prints it as 'soins prodigués'; +4 tests (channel heal, cure spell, undead cure guard, run_encounter aggregate) +1 CLI assertion

This commit is contained in:
2026-09-19 23:23:00 +02:00
parent 5e7436b210
commit e6021a6dda
8 changed files with 180 additions and 7 deletions
+82
View File
@@ -287,6 +287,88 @@ def test_oni_channel_harm_is_counted_as_damage_dealt() -> None:
assert engine._stats["lich"].damage_taken == dealt
def test_oni_channel_heal_is_counted_as_healing_done() -> None:
"""The healing deliberately removed from damage_dealt (commit 2e2873f) is
visible in its own counter: the goblin restored 2 -> 6 books 4 hp of
healing_done on the channeler, damage counters untouched."""
oni = _combatant("oni", resources={"channel_energy": 6}, hp_max=30)
goblin = _combatant("goblin", hp_max=6)
engine, me, enemy = _engine(oni, [goblin], rng_queue=[5])
enemy[0].hp = 2
engine._execute(
me,
Action(kind="channel_energy", target_id=me.combatant.id, channel_mode="heal"),
)
assert enemy[0].hp == 6
assert engine._stats["oni"].healing_done == 4 # 1+5=6 rolled, capped at hp_max
assert engine._stats["oni"].damage_dealt == 0
assert engine._stats["goblin"].damage_taken == 0
def test_cure_spell_healing_is_counted_as_healing_done() -> None:
"""A Cure Light Wounds cast on a wounded ally books the restored hp to the
caster's healing_done (willing target: NO SAVE consumes no die; the single
scripted d8 is the heal roll)."""
cleric = _combatant(
"cleric", spells=["Cure Light Wounds"], spell_slots={1: 1}, hp_max=20
)
goblin = _combatant("goblin", hp_max=6)
hurt_ally = _state(_combatant("ally", hp_max=20), "players", (5, 4), hp=5)
me = _state(cleric, "players", (5, 5))
enemy = _state(goblin, "monsters", (5, 6))
engine = CombatEngine(
# die 1: the goblin's cast-provoked AoO (d20=3+3=6, misses AC 15);
# die 2: the heal d8.
ScriptedRng([3, 3]),
_grid(),
[me, enemy, hurt_ally],
spell_registry=load_spell_registry(SPELLS_DIR),
)
engine._execute(
me,
Action(
kind="cast_spell", target_id="ally", spell_name="Cure Light Wounds"
),
)
healed = hurt_ally.hp - 5
assert healed == 8 # 1d8=3 + level-5 scaling_flat +5
assert engine._stats["cleric"].healing_done == healed
assert engine._stats["cleric"].damage_dealt == 0
def test_cure_damage_on_undead_is_not_healing() -> None:
"""A cure cast on an undead deals damage instead (SRD cure-light-wounds):
it stays booked as damage_dealt, never as healing_done."""
cleric = _combatant("cleric", hp_max=20)
engine, me, _ = _engine(
cleric,
[_combatant("zombie", hp_max=20, creature_type="undead")],
rng_queue=[3], # the single d8 heal roll
)
target = engine.states[1]
result = engine._apply_heal_effect(
HealEffect(formula=parse_dice("1d8")),
target,
caster_level=1,
empower=False,
maximize=False,
saved=False,
healer=me,
caster_live=engine._stats["cleric"],
target_live=engine._stats["zombie"],
)
assert result == "3 damage (undead)"
assert engine._stats["cleric"].healing_done == 0
assert engine._stats["cleric"].damage_dealt == 3
assert engine._stats["zombie"].damage_taken == 3
def test_oni_emits_channel_harm_only_for_grouped_undead() -> None:
"""Channel harm is emitted only when at least 2 undead enemies are grouped at 30ft
— living enemies are never harmed by positive energy."""
+1
View File
@@ -45,6 +45,7 @@ def test_cli_prints_balance_report(capsys: pytest.CaptureFixture[str]) -> None:
assert "Nuls" in out
assert "Rounds moyens" in out
assert "Attrition moyenne" in out
assert "soins prodigués" in out
def test_cli_rejects_side_not_in_deployment(capsys: pytest.CaptureFixture[str]) -> None:
@@ -267,7 +267,7 @@ def test_cli_wires_the_flag_to_the_runner(
draws=0,
avg_rounds=1.0,
attrition={
combatant.id: CombatantAttrition(0.0, 0.0, 0.0, 0.0)
combatant.id: CombatantAttrition(0.0, 0.0, 0.0, 0.0, 0.0)
for side in spec.sides
for combatant in side.combatants
},
+71
View File
@@ -296,3 +296,74 @@ def test_run_encounter_splits_duplicate_summons() -> None:
assert "celestial_wolf" in report.attrition
assert "celestial_wolf-2" in report.attrition
assert report.attrition["celestial_wolf-2"].damage_dealt >= 0.0
def _make_runner_healer() -> Combatant:
attack = AttackSpec(
id="healer-w",
name="mace",
kind="melee",
attack_bonus=2,
damage=[DamageComponent(formula=parse_dice("1d6"), types=["bludgeoning"])],
)
return Combatant(
id="healer",
name="healer",
level=3,
size="Medium",
abilities=AbilityScores(
str_score=10, dex_score=12, con_score=14,
int_score=10, wis_score=16, cha_score=10,
),
hp_max=40,
ac=ACProfile(total=14, touch=13, flat_footed=12),
bab=2,
initiative_mod=2,
speed_land_ft=30,
saves=Saves(fort=2, ref=3, will=5),
attacks=[attack],
casting_ability="wis",
spells=["Cure Light Wounds"],
spell_slots={1: 3},
)
def _self_healing_policy(
engine: CombatEngine, state: CombatantState
) -> tuple[Action, ...]:
if (
state.combatant.id.startswith("healer")
and state.hp < state.combatant.hp_max
and state.spell_slots_left.get(1, 0) > 0
):
return (
Action(
kind="cast_spell",
target_id=state.combatant.id,
spell_name="Cure Light Wounds",
),
)
return default_policy(engine, state)
def test_run_encounter_reports_healing_done() -> None:
"""Spell healing is visible through the aggregation as the average
healing_done per combatant - the counter deliberately removed from
damage_dealt (commit 2e2873f) was invisible in the report before."""
spec = EncounterSpec(
map=make_map(),
sides=(
Side(name="players", combatants=(_make_runner_healer(),), zone="A"),
Side(
name="monsters",
combatants=(
make_combatant("gob", hp=30, attack_bonus=10, damage="1d6"),
),
zone="B",
),
),
round_cap=10,
)
report = run_encounter(spec, runs=5, seed=11, policy=_self_healing_policy)
assert report.attrition["healer"].healing_done > 0.0
assert report.attrition["healer"].damage_taken > 0.0