From e6021a6ddac2d4d98d3ef08ceb916f4bbf146fb8 Mon Sep 17 00:00:00 2001 From: ctan Date: Sat, 19 Sep 2026 23:23:00 +0200 Subject: [PATCH] =?UTF-8?q?fix(rules):=20expose=20healing=5Fdone=20in=20pe?= =?UTF-8?q?r-combatant=20stats=20and=20the=20CLI=20report=20-=20the=20chan?= =?UTF-8?q?nel/spell=20healing=20deliberately=20removed=20from=20damage=5F?= =?UTF-8?q?dealt=20(2e2873f)=20was=20invisible;=20CombatantStats/=5FLiveSt?= =?UTF-8?q?ats=20gain=20a=20healing=5Fdone=20counter=20booked=20with=20the?= =?UTF-8?q?=20hp=20actually=20restored=20(capped=20at=20the=20target's=20h?= =?UTF-8?q?p=5Fmax,=20overheal=20uncounted)=20by=20=5Fchannel=5Fheal=20and?= =?UTF-8?q?=20by=20spell=20HealEffect=20on=20living=20targets,=20while=20c?= =?UTF-8?q?ure-as-damage=20on=20undead=20stays=20in=20damage=5Fdealt=20and?= =?UTF-8?q?=20the=20damage=20counters=20keep=20their=20exact=20semantics;?= =?UTF-8?q?=20the=20runner=20aggregates=20and=20averages=20the=20new=20fie?= =?UTF-8?q?ld=20and=20the=20French=20report=20prints=20it=20as=20'soins=20?= =?UTF-8?q?prodigu=C3=A9s';=20+4=20tests=20(channel=20heal,=20cure=20spell?= =?UTF-8?q?,=20undead=20cure=20guard,=20run=5Fencounter=20aggregate)=20+1?= =?UTF-8?q?=20CLI=20assertion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 9 +- src/pf1e_simulator/cli.py | 1 + src/pf1e_simulator/combat.py | 18 +++- src/pf1e_simulator/runner.py | 3 + tests/test_class_abilities.py | 82 +++++++++++++++++++ tests/test_cli.py | 1 + .../test_conformance_simple_monster_death.py | 2 +- tests/test_runner.py | 71 ++++++++++++++++ 8 files changed, 180 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 580f988..c730d06 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ critiques, RD, états de vie, déplacement, mêlée/distance). La couche LLM même seed ⇒ mêmes combats. - Rapport d'équilibrage en français : taux de victoire + bande 3σ (σ = √(p(1−p)/n)), nuls, rounds moyens, attrition moyenne par combattant - (touches, critiques, dégâts infligés/subis). + (touches, critiques, dégâts infligés/subis, soins prodigués). - Interface en ligne de commande `pf1e-sim`. ## Prérequis et installation @@ -154,7 +154,12 @@ confiance à 3σ calculée en forme fermée (modèle binomial) : - L'attrition moyenne par combattant indique qui participe réellement : un combattant à distance qui n'est jamais rejoint affiche des dégâts subis proches de zéro, un combattant qui meurt systématiquement affiche des dégâts - subis supérieurs à ses PV maximum (dégâts du coup fatal inclus). + subis supérieurs à ses PV maximum (dégâts du coup fatal inclus). La colonne + « soins prodigués » compte les PV réellement rendus par le combattant (sorts + de soin et channel energy en mode soin), plafonnés aux PV maximum des cibles + — les soins excédentaires (overheal) ne sont pas comptés ; les dégâts + infligés et les subis restent strictement réservés aux dégâts (le soin n'a + jamais été compté dans les dégâts infligés). - Les nuls proviennent des combats atteignant le plafond de rounds (combattants immobiles, camps inaccessibles…). diff --git a/src/pf1e_simulator/cli.py b/src/pf1e_simulator/cli.py index 90738aa..6b4a353 100644 --- a/src/pf1e_simulator/cli.py +++ b/src/pf1e_simulator/cli.py @@ -151,6 +151,7 @@ def format_report(report: EncounterReport, spec: EncounterSpec) -> str: lines.extend( f" {cid} : {attrition.hits:.2f} touches · {attrition.crits:.2f} critiques · " f"{attrition.damage_dealt:.2f} dégâts infligés · {attrition.damage_taken:.2f} subis" + f" · {attrition.healing_done:.2f} soins prodigués" for cid, attrition in report.attrition.items() ) return "\n".join(lines) diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index a701a52..81231a3 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -651,12 +651,18 @@ def _bresenham_line(start: Pos, end: Pos) -> list[Pos]: @dataclass(frozen=True) class CombatantStats: - """Aggregated outcomes for one combatant over a battle.""" + """Aggregated outcomes for one combatant over a battle. + + ``healing_done`` is hit points *restored* by the combatant (channel heal + and spell HealEffect, capped at the target's hp_max); healing is never + counted in ``damage_dealt``/``damage_taken`` (those stay damage-only). + """ hits: int = 0 crits: int = 0 damage_dealt: int = 0 damage_taken: int = 0 + healing_done: int = 0 _PC_SOURCE_PREFIX = "fiches_personnages/" @@ -1256,6 +1262,7 @@ class _LiveStats: crits: int = 0 damage_dealt: int = 0 damage_taken: int = 0 + healing_done: int = 0 @dataclass @@ -5882,9 +5889,10 @@ class CombatEngine: if affected_state.hp < 0: affected_state.stabilized_hp = affected_state.hp actual = affected_state.hp - old_hp - # Healing is NOT damage dealt: the attrition counters track damage only - # (README "degats infligés/subis"), consistent with spell healing - # (HealEffect), which is counted nowhere. + # Healing is NOT damage dealt: the damage counters stay damage-only + # (README "degats infligés/subis"); restored hp are booked to the + # healer's separate healing_done counter (mirrors spell HealEffect). + self._stats[state.combatant.id].healing_done += actual self._stop_bleeding(affected_state) self._log( f"round {self._current_round} {state.combatant.id}: " @@ -7330,6 +7338,8 @@ class CombatEngine: if target.hp < 0: target.stabilized_hp = target.hp actual = target.hp - old_hp + if caster_live is not None: + caster_live.healing_done += actual self._stop_bleeding(target) return f"+{actual} HP" diff --git a/src/pf1e_simulator/runner.py b/src/pf1e_simulator/runner.py index a978d60..26714ff 100644 --- a/src/pf1e_simulator/runner.py +++ b/src/pf1e_simulator/runner.py @@ -58,6 +58,7 @@ class CombatantAttrition: crits: float damage_dealt: float damage_taken: float + healing_done: float @dataclass(frozen=True) @@ -232,6 +233,7 @@ def run_encounter( crits=current.crits + stats.crits, damage_dealt=current.damage_dealt + stats.damage_dealt, damage_taken=current.damage_taken + stats.damage_taken, + healing_done=current.healing_done + stats.healing_done, ) attrition = { cid: CombatantAttrition( @@ -239,6 +241,7 @@ def run_encounter( crits=stats.crits / runs, damage_dealt=stats.damage_dealt / runs, damage_taken=stats.damage_taken / runs, + healing_done=stats.healing_done / runs, ) for cid, stats in totals.items() } diff --git a/tests/test_class_abilities.py b/tests/test_class_abilities.py index 5c15671..4f8029d 100644 --- a/tests/test_class_abilities.py +++ b/tests/test_class_abilities.py @@ -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.""" diff --git a/tests/test_cli.py b/tests/test_cli.py index 3236ee3..8384739 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -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: diff --git a/tests/test_conformance_simple_monster_death.py b/tests/test_conformance_simple_monster_death.py index 04fcc68..d308712 100644 --- a/tests/test_conformance_simple_monster_death.py +++ b/tests/test_conformance_simple_monster_death.py @@ -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 }, diff --git a/tests/test_runner.py b/tests/test_runner.py index 47dafcd..a25c0d8 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -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