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:
@@ -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…).
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user