fix(rules): virtue is 1 temporary hit point for 1 minute
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user