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
+7 -2
View File
@@ -23,7 +23,7 @@ critiques, RD, états de vie, déplacement, mêlée/distance). La couche LLM
même seed ⇒ mêmes combats. même seed ⇒ mêmes combats.
- Rapport d'équilibrage en français : taux de victoire + bande 3σ - Rapport d'équilibrage en français : taux de victoire + bande 3σ
(σ = √(p(1−p)/n)), nuls, rounds moyens, attrition moyenne par combattant (σ = √(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`. - Interface en ligne de commande `pf1e-sim`.
## Prérequis et installation ## 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 - L'attrition moyenne par combattant indique qui participe réellement : un
combattant à distance qui n'est jamais rejoint affiche des dégâts subis 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 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 - Les nuls proviennent des combats atteignant le plafond de rounds (combattants
immobiles, camps inaccessibles…). immobiles, camps inaccessibles…).
+1
View File
@@ -151,6 +151,7 @@ def format_report(report: EncounterReport, spec: EncounterSpec) -> str:
lines.extend( lines.extend(
f" {cid} : {attrition.hits:.2f} touches · {attrition.crits:.2f} critiques · " 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.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() for cid, attrition in report.attrition.items()
) )
return "\n".join(lines) return "\n".join(lines)
+14 -4
View File
@@ -651,12 +651,18 @@ def _bresenham_line(start: Pos, end: Pos) -> list[Pos]:
@dataclass(frozen=True) @dataclass(frozen=True)
class CombatantStats: 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 hits: int = 0
crits: int = 0 crits: int = 0
damage_dealt: int = 0 damage_dealt: int = 0
damage_taken: int = 0 damage_taken: int = 0
healing_done: int = 0
_PC_SOURCE_PREFIX = "fiches_personnages/" _PC_SOURCE_PREFIX = "fiches_personnages/"
@@ -1256,6 +1262,7 @@ class _LiveStats:
crits: int = 0 crits: int = 0
damage_dealt: int = 0 damage_dealt: int = 0
damage_taken: int = 0 damage_taken: int = 0
healing_done: int = 0
@dataclass @dataclass
@@ -5882,9 +5889,10 @@ class CombatEngine:
if affected_state.hp < 0: if affected_state.hp < 0:
affected_state.stabilized_hp = affected_state.hp affected_state.stabilized_hp = affected_state.hp
actual = affected_state.hp - old_hp actual = affected_state.hp - old_hp
# Healing is NOT damage dealt: the attrition counters track damage only # Healing is NOT damage dealt: the damage counters stay damage-only
# (README "degats infligés/subis"), consistent with spell healing # (README "degats infligés/subis"); restored hp are booked to the
# (HealEffect), which is counted nowhere. # healer's separate healing_done counter (mirrors spell HealEffect).
self._stats[state.combatant.id].healing_done += actual
self._stop_bleeding(affected_state) self._stop_bleeding(affected_state)
self._log( self._log(
f"round {self._current_round} {state.combatant.id}: " f"round {self._current_round} {state.combatant.id}: "
@@ -7330,6 +7338,8 @@ class CombatEngine:
if target.hp < 0: if target.hp < 0:
target.stabilized_hp = target.hp target.stabilized_hp = target.hp
actual = target.hp - old_hp actual = target.hp - old_hp
if caster_live is not None:
caster_live.healing_done += actual
self._stop_bleeding(target) self._stop_bleeding(target)
return f"+{actual} HP" return f"+{actual} HP"
+3
View File
@@ -58,6 +58,7 @@ class CombatantAttrition:
crits: float crits: float
damage_dealt: float damage_dealt: float
damage_taken: float damage_taken: float
healing_done: float
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -232,6 +233,7 @@ def run_encounter(
crits=current.crits + stats.crits, crits=current.crits + stats.crits,
damage_dealt=current.damage_dealt + stats.damage_dealt, damage_dealt=current.damage_dealt + stats.damage_dealt,
damage_taken=current.damage_taken + stats.damage_taken, damage_taken=current.damage_taken + stats.damage_taken,
healing_done=current.healing_done + stats.healing_done,
) )
attrition = { attrition = {
cid: CombatantAttrition( cid: CombatantAttrition(
@@ -239,6 +241,7 @@ def run_encounter(
crits=stats.crits / runs, crits=stats.crits / runs,
damage_dealt=stats.damage_dealt / runs, damage_dealt=stats.damage_dealt / runs,
damage_taken=stats.damage_taken / runs, damage_taken=stats.damage_taken / runs,
healing_done=stats.healing_done / runs,
) )
for cid, stats in totals.items() for cid, stats in totals.items()
} }
+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 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: 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 """Channel harm is emitted only when at least 2 undead enemies are grouped at 30ft
— living enemies are never harmed by positive energy.""" — 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 "Nuls" in out
assert "Rounds moyens" in out assert "Rounds moyens" in out
assert "Attrition moyenne" 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: 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, draws=0,
avg_rounds=1.0, avg_rounds=1.0,
attrition={ 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 side in spec.sides
for combatant in side.combatants 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" in report.attrition
assert "celestial_wolf-2" in report.attrition assert "celestial_wolf-2" in report.attrition
assert report.attrition["celestial_wolf-2"].damage_dealt >= 0.0 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