fix(rules): virtue is 1 temporary hit point for 1 minute

This commit is contained in:
2026-09-20 10:32:05 +02:00
parent 1238c40e43
commit a8a65b18cd
5 changed files with 387 additions and 18 deletions
+74 -15
View File
@@ -151,6 +151,7 @@ from pf1e_simulator.spells import (
SaveType,
SpellEffect,
SummonEffect,
TempHpEffect,
close_range_ft,
save_descriptors_for_effects,
spell_range_ft,
@@ -469,7 +470,10 @@ def _damage_effect_summary(effect: DamageEffect | PenaltyEffect, tags: list[str]
def _non_damage_effect_summary(
effect: ConditionEffect | HealEffect | BuffEffect | AreaEffect | SummonEffect | OneShotEffect
effect: (
ConditionEffect | HealEffect | BuffEffect | AreaEffect | SummonEffect
| OneShotEffect | TempHpEffect
)
) -> str:
if isinstance(effect, ConditionEffect):
return f"{effect.condition_name} {effect.duration_rounds}r"
@@ -479,8 +483,12 @@ def _non_damage_effect_summary(
return f"area {effect.radius_ft}ft"
if isinstance(effect, SummonEffect):
return f"summon {effect.creature}"
if isinstance(effect, OneShotEffect):
return f"one-shot +{effect.value} {effect.bonus_type} on {effect.track}"
if isinstance(effect, (OneShotEffect, TempHpEffect)):
return (
f"one-shot +{effect.value} {effect.bonus_type} on {effect.track}"
if isinstance(effect, OneShotEffect)
else f"temp hp +{effect.amount} ({effect.duration_rounds}r)"
)
return f"buff {effect.duration_rounds}r"
@@ -788,6 +796,11 @@ class CombatantState:
# One-shot typed bonuses (SRD spells/guidance.md) discharged by the first
# roll of their track and expiring with their duration.
one_shot_bonuses: list[ActiveOneShot] = field(default_factory=list)
# Temporary hit points (SRD spells/virtue.md): absorb damage before real
# hp (CRB "Temporary Hit Points") and expire when the granting effect's
# duration ends (temp_hp_rounds_left, decremented like buffs).
temp_hp: int = 0
temp_hp_rounds_left: int = 0
resources_left: dict[str, int] = field(default_factory=dict)
spell_slots_left: dict[int, int] = field(default_factory=dict)
moved_this_turn: bool = False
@@ -1682,6 +1695,8 @@ class CombatEngine:
}
for shot in state.one_shot_bonuses
),
"temp_hp": state.temp_hp,
"temp_hp_rounds_left": state.temp_hp_rounds_left,
"speed_land_ft": state.combatant.speed_land_ft,
"speed_fly_ft": state.combatant.speed_fly_ft,
"speed_effective_ft": _speed_ft(state),
@@ -2021,7 +2036,7 @@ class CombatEngine:
# strenuous) action costs 1 hp — the creature slips to -1 and
# is dying. Simplification: if the action itself healed the
# creature above 0 hp the cost is not applied.
state.hp -= 1
self._lose_hp(state, 1)
self._log(
f"round {self._current_round} {state.combatant.id}: "
f"disabled: strenuous action costs 1 hp (hp 0 -> -1, dying)"
@@ -2255,6 +2270,10 @@ class CombatEngine:
for o in state.one_shot_bonuses
if o.rounds_left > 1
]
if state.temp_hp_rounds_left > 0:
state.temp_hp_rounds_left -= 1
if state.temp_hp_rounds_left == 0:
state.temp_hp = 0
bleeding_expired = (
any(ac.condition.name == "bleeding" for ac in state.active_conditions)
and not any(ac.condition.name == "bleeding" for ac in kept_conditions)
@@ -2285,12 +2304,19 @@ class CombatEngine:
return self._winner_side() is not None
return None
def _lose_hp(self, state: CombatantState, amount: int) -> None:
"""Apply damage: temp HP absorbs first (CRB "Temporary Hit Points"),
then real hp. Callers keep booking the full amount to stats."""
absorbed = min(state.temp_hp, amount)
state.temp_hp -= absorbed
state.hp -= amount - absorbed
def _apply_bleed_damage(self, state: CombatantState) -> None:
"""PF1e bleeding: take ``bleed_damage`` at the beginning of the turn (CRB)."""
if state.bleed_damage <= 0:
return
old_hp = state.hp
state.hp -= state.bleed_damage
self._lose_hp(state, state.bleed_damage)
self._stats[state.combatant.id].damage_taken += state.bleed_damage
self._log(
f"round {self._current_round} {state.combatant.id}: "
@@ -2310,7 +2336,7 @@ class CombatEngine:
raw = entry.formula.roll(self._rng)
dmg = self._apply_energy_resistance(raw, entry.types, state)
old_hp = state.hp
state.hp -= dmg
self._lose_hp(state, dmg)
live.damage_taken += dmg
self._log(
f"round {self._current_round} {state.combatant.id}: "
@@ -2354,7 +2380,7 @@ class CombatEngine:
"""Confused 51-75: 1d8 + Str modifier damage to self with item in hand."""
dmg = self._rng.roll_die(8) + state.combatant.abilities.str_mod
old_hp = state.hp
state.hp -= dmg
self._lose_hp(state, dmg)
self._log(
f"round {self._current_round} {state.combatant.id}: "
f"confused: hits self for {dmg} (hp {old_hp}->{state.hp})"
@@ -3137,7 +3163,7 @@ class CombatEngine:
dist_ft=dist_ft, denies_dex=denies_dex,
)
if not concealment_miss:
target.hp -= damage
self._lose_hp(target, damage)
damage = self._consume_up_close_and_deadly(
attacker, target, weapon, dist_ft, damage, hit=hit
)
@@ -3444,7 +3470,7 @@ class CombatEngine:
):
bonus = self._rng.roll_die(6)
damage += bonus
target.hp -= bonus
self._lose_hp(target, bonus)
attacker.deed_up_close_and_deadly = False
return damage
@@ -6026,7 +6052,7 @@ class CombatEngine:
save = self.resolve_save(affected_state, "will", dc)
dealt = dmg // 2 if save.success else dmg
hp_before = affected_state.hp
affected_state.hp -= dealt
self._lose_hp(affected_state, dealt)
self._stats[state.combatant.id].damage_dealt += dealt
self._stats[affected_state.combatant.id].damage_taken += dealt
self._log(
@@ -7129,7 +7155,7 @@ class CombatEngine:
self,
effect: (
DamageEffect | ConditionEffect | HealEffect | BuffEffect
| PenaltyEffect | OneShotEffect
| PenaltyEffect | OneShotEffect | TempHpEffect
),
target: CombatantState,
caster_live: _LiveStats,
@@ -7167,8 +7193,8 @@ class CombatEngine:
effect, target, caster=caster, saved=saved,
empower=empower, maximize=maximize, spell_name=spell_name,
)
if isinstance(effect, OneShotEffect):
return self._apply_one_shot_effect(
if isinstance(effect, (OneShotEffect, TempHpEffect)):
return self._apply_grant_effect(
effect, target, saved=saved, spell_name=spell_name,
)
return self._apply_buff_effect(
@@ -7176,6 +7202,20 @@ class CombatEngine:
caster_level=caster.combatant.level,
)
def _apply_grant_effect(
self,
effect: OneShotEffect | TempHpEffect,
target: CombatantState,
*,
saved: bool,
spell_name: str,
) -> str:
if isinstance(effect, OneShotEffect):
return self._apply_one_shot_effect(
effect, target, saved=saved, spell_name=spell_name,
)
return self._apply_temp_hp_effect(effect, target, saved=saved)
def _apply_one_shot_effect(
self, effect: OneShotEffect, target: CombatantState, *, saved: bool, spell_name: str
) -> str:
@@ -7201,6 +7241,25 @@ class CombatEngine:
f"({effect.duration_rounds}r)"
)
def _apply_temp_hp_effect(
self, effect: TempHpEffect, target: CombatantState, *, saved: bool
) -> str:
"""Grant temporary hit points (SRD spells/virtue.md).
Temp HP do not stack (CRB "Temporary Hit Points"): the creature keeps
the higher amount, ties keep the longer remaining duration.
"""
if saved:
return "temp hp negated"
if target.temp_hp > effect.amount or (
target.temp_hp == effect.amount
and target.temp_hp_rounds_left >= effect.duration_rounds
):
return f"temp hp kept ({target.temp_hp})"
target.temp_hp = effect.amount
target.temp_hp_rounds_left = effect.duration_rounds
return f"temp hp +{effect.amount} ({effect.duration_rounds}r)"
def _apply_damage_effect(
self,
effect: DamageEffect,
@@ -7306,7 +7365,7 @@ class CombatEngine:
if empower:
dmg = int(dmg * 1.5)
dmg = self._apply_energy_resistance(dmg, frozenset(effect.types), target)
target.hp -= dmg
self._lose_hp(target, dmg)
return dmg
def _attach_recurring_damage(
@@ -7474,7 +7533,7 @@ class CombatEngine:
# DR, and it does not stop bleeding or stabilize. Bitter reduces
# healing, not this damage.
dmg = heal // 2 if saved else heal
target.hp -= dmg
self._lose_hp(target, dmg)
if caster_live is not None:
caster_live.damage_dealt += dmg
if target_live is not None:
+31 -1
View File
@@ -286,6 +286,23 @@ class OneShotEffect:
duration_rounds: int
@dataclass(frozen=True)
class TempHpEffect:
"""PF1e temporary hit points (SRD spells/virtue.md: "granting the subject
1 temporary hit point").
Temporary hit points absorb damage before real hit points (CRB "Temporary
Hit Points": "damage is applied to temporary hit points first") and expire
when the granting effect's duration ends. They do not stack: a creature
with temp HP that receives more keeps the higher amount (CRB: "it must
decide to keep the old ones or the new ones" — the engine keeps the
higher, ties keep the longer remaining duration).
"""
amount: int
duration_rounds: int
@dataclass(frozen=True)
class PenaltyEffect:
"""PF1e rolled ability penalty (SRD Ray of Enfeeblement).
@@ -367,7 +384,7 @@ class SummonEffect:
SpellEffect = (
DamageEffect | ConditionEffect | HealEffect | BuffEffect
| PenaltyEffect | AreaEffect | SummonEffect | OneShotEffect
| PenaltyEffect | AreaEffect | SummonEffect | OneShotEffect | TempHpEffect
)
@@ -772,6 +789,18 @@ def _parse_one_shot_effect(effect: dict[str, Json]) -> OneShotEffect:
)
def _parse_temp_hp_effect(effect: dict[str, Json]) -> TempHpEffect:
amount = _as_int(effect["amount"])
if amount < 1:
msg = f"temp_hp amount must be >= 1, got {amount}"
raise ValueError(msg)
duration = _as_int(effect["duration_rounds"])
if duration < 1:
msg = f"temp_hp duration_rounds must be >= 1, got {duration}"
raise ValueError(msg)
return TempHpEffect(amount=amount, duration_rounds=duration)
def _parse_penalty_effect(effect: dict[str, Json]) -> PenaltyEffect:
flat_raw = effect.get("scaling_flat")
scaling_flat = None if flat_raw is None else _parse_flat_scaling(flat_raw)
@@ -825,6 +854,7 @@ _EFFECT_PARSERS: dict[str, Callable[[dict[str, Json]], SpellEffect]] = {
"heal": _parse_heal_effect,
"buff": _parse_buff_effect,
"one_shot": _parse_one_shot_effect,
"temp_hp": _parse_temp_hp_effect,
"penalty": _parse_penalty_effect,
"area": _parse_area_effect,
"summon": _parse_summon_effect,