fix(stats): non-attack damage now lands in damage_taken - bleed ticks, recurring spell damage (Acid Arrow) and cure-spells-damage-undead (SRD Cure Light Wounds negative-energy hit, attributed to the caster's damage_dealt) were invisible to attrition while attacks were counted; transcripts and RNG untouched
This commit is contained in:
@@ -2173,6 +2173,7 @@ class CombatEngine:
|
|||||||
return
|
return
|
||||||
old_hp = state.hp
|
old_hp = state.hp
|
||||||
state.hp -= state.bleed_damage
|
state.hp -= state.bleed_damage
|
||||||
|
self._stats[state.combatant.id].damage_taken += state.bleed_damage
|
||||||
self._log(
|
self._log(
|
||||||
f"round {self._current_round} {state.combatant.id}: "
|
f"round {self._current_round} {state.combatant.id}: "
|
||||||
f"bleed {state.bleed_damage} (hp {old_hp}->{state.hp})"
|
f"bleed {state.bleed_damage} (hp {old_hp}->{state.hp})"
|
||||||
@@ -2186,11 +2187,13 @@ class CombatEngine:
|
|||||||
if not state.recurring_damage:
|
if not state.recurring_damage:
|
||||||
return
|
return
|
||||||
remaining: list[RecurringDamage] = []
|
remaining: list[RecurringDamage] = []
|
||||||
|
live = self._stats[state.combatant.id]
|
||||||
for entry in state.recurring_damage:
|
for entry in state.recurring_damage:
|
||||||
raw = entry.formula.roll(self._rng)
|
raw = entry.formula.roll(self._rng)
|
||||||
dmg = self._apply_energy_resistance(raw, entry.types, state)
|
dmg = self._apply_energy_resistance(raw, entry.types, state)
|
||||||
old_hp = state.hp
|
old_hp = state.hp
|
||||||
state.hp -= dmg
|
state.hp -= dmg
|
||||||
|
live.damage_taken += dmg
|
||||||
self._log(
|
self._log(
|
||||||
f"round {self._current_round} {state.combatant.id}: "
|
f"round {self._current_round} {state.combatant.id}: "
|
||||||
f"{entry.source} {dmg} (hp {old_hp}->{state.hp})"
|
f"{entry.source} {dmg} (hp {old_hp}->{state.hp})"
|
||||||
@@ -6766,7 +6769,7 @@ class CombatEngine:
|
|||||||
return self._apply_heal_effect(
|
return self._apply_heal_effect(
|
||||||
effect, target, caster_level=caster.combatant.level,
|
effect, target, caster_level=caster.combatant.level,
|
||||||
empower=empower, maximize=maximize, saved=saved,
|
empower=empower, maximize=maximize, saved=saved,
|
||||||
healer=caster,
|
healer=caster, caster_live=caster_live, target_live=target_live,
|
||||||
)
|
)
|
||||||
if isinstance(effect, PenaltyEffect):
|
if isinstance(effect, PenaltyEffect):
|
||||||
return self._apply_penalty_effect(
|
return self._apply_penalty_effect(
|
||||||
@@ -7032,6 +7035,8 @@ class CombatEngine:
|
|||||||
maximize: bool,
|
maximize: bool,
|
||||||
saved: bool,
|
saved: bool,
|
||||||
healer: CombatantState | None = None,
|
healer: CombatantState | None = None,
|
||||||
|
caster_live: _LiveStats | None = None,
|
||||||
|
target_live: _LiveStats | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
formula = effect.effective_formula(caster_level)
|
formula = effect.effective_formula(caster_level)
|
||||||
heal = formula.roll(self._rng)
|
heal = formula.roll(self._rng)
|
||||||
@@ -7050,6 +7055,10 @@ class CombatEngine:
|
|||||||
# healing, not this damage.
|
# healing, not this damage.
|
||||||
dmg = heal // 2 if saved else heal
|
dmg = heal // 2 if saved else heal
|
||||||
target.hp -= dmg
|
target.hp -= dmg
|
||||||
|
if caster_live is not None:
|
||||||
|
caster_live.damage_dealt += dmg
|
||||||
|
if target_live is not None:
|
||||||
|
target_live.damage_taken += dmg
|
||||||
return f"{dmg} damage (undead)"
|
return f"{dmg} damage (undead)"
|
||||||
if healer is not None:
|
if healer is not None:
|
||||||
heal = self._bitter_reduced(healer, target, heal)
|
heal = self._bitter_reduced(healer, target, heal)
|
||||||
|
|||||||
@@ -89,6 +89,24 @@ def test_bleed_to_death_ends_battle() -> None:
|
|||||||
assert any("a: bleed 5 (hp -8->-13)" in line for line in eng._transcript)
|
assert any("a: bleed 5 (hp -8->-13)" in line for line in eng._transcript)
|
||||||
|
|
||||||
|
|
||||||
|
def test_bleed_tick_records_damage_taken() -> None:
|
||||||
|
"""Attrition (README "dégâts subis"): bleed is damage - the tick lands in
|
||||||
|
damage_taken with no attributed dealer (no attack source)."""
|
||||||
|
eng = _engine(
|
||||||
|
[_combatant("a"), _combatant("b")],
|
||||||
|
[(0, 0), (0, 1)],
|
||||||
|
["players", "monsters"],
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
a, _b = eng._states
|
||||||
|
a.bleed_damage = 2
|
||||||
|
eng._apply_bleed_damage(a)
|
||||||
|
assert a.hp == 98
|
||||||
|
assert eng._stats["a"].damage_taken == 2
|
||||||
|
assert eng._stats["a"].damage_dealt == 0
|
||||||
|
assert eng._stats["b"].damage_taken == 0
|
||||||
|
|
||||||
|
|
||||||
def test_heal_spell_clears_bleed() -> None:
|
def test_heal_spell_clears_bleed() -> None:
|
||||||
eng = _engine(
|
eng = _engine(
|
||||||
[_combatant("a"), _combatant("b")],
|
[_combatant("a"), _combatant("b")],
|
||||||
|
|||||||
@@ -78,3 +78,27 @@ class TestCureDamagesUndead:
|
|||||||
engine._cast_spell(c, t, "Cure Light Wounds")
|
engine._cast_spell(c, t, "Cure Light Wounds")
|
||||||
assert t.hp == 18
|
assert t.hp == 18
|
||||||
assert any("+13 HP" in line for line in engine._transcript)
|
assert any("+13 HP" in line for line in engine._transcript)
|
||||||
|
|
||||||
|
|
||||||
|
class TestCureUndeadAttrition:
|
||||||
|
"""Given: a cure spell delivered to an undead
|
||||||
|
When: the negative-energy damage lands
|
||||||
|
Then: it counts as damage in attrition (dealer + taker), while healing a
|
||||||
|
living target records no damage either way."""
|
||||||
|
|
||||||
|
def test_undead_damage_recorded_for_both_sides(self) -> None:
|
||||||
|
engine, c, t = _make_engine(
|
||||||
|
_caster(), _undead(), [1, 8], target_pos=(1, 0), target_side="players"
|
||||||
|
)
|
||||||
|
engine._cast_spell(c, t, "Cure Light Wounds")
|
||||||
|
assert engine._stats["skeleton"].damage_taken == 13
|
||||||
|
assert engine._stats["caster"].damage_dealt == 13
|
||||||
|
|
||||||
|
def test_living_heal_records_no_damage(self) -> None:
|
||||||
|
engine, c, t = _make_engine(
|
||||||
|
_caster(), _make_target(), [1, 8], target_pos=(1, 0), target_side="players"
|
||||||
|
)
|
||||||
|
t.hp = 5
|
||||||
|
engine._cast_spell(c, t, "Cure Light Wounds")
|
||||||
|
assert engine._stats["caster"].damage_dealt == 0
|
||||||
|
assert engine._stats["target"].damage_taken == 0
|
||||||
|
|||||||
@@ -244,3 +244,23 @@ class TestRecurringSnapshot:
|
|||||||
assert entry["recurring_damage"] == [
|
assert entry["recurring_damage"] == [
|
||||||
{"formula": "2d4", "types": ("acid",), "rounds_left": 2}
|
{"formula": "2d4", "types": ("acid",), "rounds_left": 2}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class TestRecurringTicksRecordDamageTaken:
|
||||||
|
"""Given: a target burning from Acid Arrow
|
||||||
|
When: each recurring tick lands
|
||||||
|
Then: attrition's damage_taken grows by the tick (README "dégâts subis");
|
||||||
|
the initial spell hit was already counted at cast time."""
|
||||||
|
|
||||||
|
def test_each_tick_counts_as_damage_taken(self) -> None:
|
||||||
|
caster = _caster(level=3)
|
||||||
|
victim = _victim(hp_max=200)
|
||||||
|
# attack (15) + initial 2d4 (3+3) + tick1 2d4 (2+2) + tick2 2d4 (4+4)
|
||||||
|
engine, c, v = _engine(caster, victim, [15, 3, 3, 2, 2, 4, 4])
|
||||||
|
engine._cast_spell(c, v, "Acid Arrow")
|
||||||
|
assert engine._stats["victim"].damage_taken == 6
|
||||||
|
assert engine._stats["caster"].damage_dealt == 6
|
||||||
|
engine._apply_recurring_damage(v)
|
||||||
|
assert engine._stats["victim"].damage_taken == 10
|
||||||
|
engine._apply_recurring_damage(v)
|
||||||
|
assert engine._stats["victim"].damage_taken == 18
|
||||||
|
|||||||
Reference in New Issue
Block a user