feat(combat): effect and condition durations

This commit is contained in:
ctan
2026-08-29 20:14:36 +02:00
parent 2e49aa7293
commit 8b5ba12f46
2 changed files with 97 additions and 15 deletions
+61 -15
View File
@@ -80,7 +80,7 @@ from dataclasses import asdict, dataclass, field
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from pf1e_simulator.conditions import CONDITIONS_BY_NAME from pf1e_simulator.conditions import CONDITIONS_BY_NAME
from pf1e_simulator.effects import StatModifier, resolve_modifiers from pf1e_simulator.effects import ActiveBuff, ActiveCondition, StatModifier, resolve_modifiers
from pf1e_simulator.spells import ( from pf1e_simulator.spells import (
ConditionEffect, ConditionEffect,
DamageEffect, DamageEffect,
@@ -174,8 +174,12 @@ class CombatantState:
side: str side: str
pos: Pos pos: Pos
hp: int hp: int
# Old fields for backwards compatibility with tests
effects: list[StatModifier] = field(default_factory=list) effects: list[StatModifier] = field(default_factory=list)
conditions: list[Condition] = field(default_factory=list) conditions: list[Condition] = field(default_factory=list)
# New duration-tracked fields
active_buffs: list[ActiveBuff] = field(default_factory=list)
active_conditions: list[ActiveCondition] = field(default_factory=list)
moved_this_turn: bool = False moved_this_turn: bool = False
power_attack: bool = False power_attack: bool = False
@@ -283,7 +287,7 @@ class CombatEngine:
def _take_turn(self, state: CombatantState) -> bool: def _take_turn(self, state: CombatantState) -> bool:
"""Execute one combatant's full turn; return True if the battle is over.""" """Execute one combatant's full turn; return True if the battle is over."""
state.effects.clear() self._decrement_durations(state)
state.moved_this_turn = False state.moved_this_turn = False
state.power_attack = False state.power_attack = False
actions = self._policy(self, state) actions = self._policy(self, state)
@@ -298,6 +302,19 @@ class CombatEngine:
self._log(f"round {self._current_round} {state.combatant.id}: wait") self._log(f"round {self._current_round} {state.combatant.id}: wait")
return self._winner_side() is not None return self._winner_side() is not None
def _decrement_durations(self, state: CombatantState) -> None:
"""Decrement duration rounds for buffs and conditions; remove expired ones."""
state.active_buffs[:] = [
ActiveBuff(modifiers=b.modifiers, rounds_left=b.rounds_left - 1, source=b.source)
for b in state.active_buffs
if b.rounds_left > 1
]
state.active_conditions[:] = [
ActiveCondition(condition=c.condition, rounds_left=c.rounds_left - 1, source=c.source)
for c in state.active_conditions
if c.rounds_left > 1
]
def run(self) -> CombatResult: def run(self) -> CombatResult:
"""Run the battle and return the final result and transcript.""" """Run the battle and return the final result and transcript."""
order = self._roll_initiative() order = self._roll_initiative()
@@ -380,22 +397,32 @@ class CombatEngine:
def _stat_modifiers( def _stat_modifiers(
self, state: CombatantState, target: str, *, weapon_name: str | None = None self, state: CombatantState, target: str, *, weapon_name: str | None = None
) -> list[StatModifier]: ) -> list[StatModifier]:
"""Collect all StatModifiers for ``target`` from effects, conditions, and features. """Collect all StatModifiers for ``target`` from all sources.
``weapon_name`` filters out modifiers with a ``weapon_filter`` that ``weapon_name`` filters out modifiers with a ``weapon_filter`` that
doesn't match (e.g. Weapon Focus (Pistol) only applies to "Pistol"). doesn't match (e.g. Weapon Focus (Pistol) only applies to "Pistol").
Modifiers without a ``weapon_filter`` are always included. Modifiers without a ``weapon_filter`` are always included.
""" """
mods = [e for e in state.effects if e.target == target] mods: list[StatModifier] = []
# Old effects field (backwards compatibility)
mods.extend(m for m in state.effects if m.target == target)
# Old conditions field (backwards compatibility)
for cond in state.conditions: for cond in state.conditions:
mods.extend(m for m in cond.modifiers if m.target == target) mods.extend(m for m in cond.modifiers if m.target == target)
# New active buffs
for buff in state.active_buffs:
mods.extend(m for m in buff.modifiers if m.target == target)
# New active conditions
for ac in state.active_conditions:
mods.extend(m for m in ac.condition.modifiers if m.target == target)
# Features
for feat in state.combatant.features: for feat in state.combatant.features:
for m in feat.effects: mods.extend(
if m.target != target: m
continue for m in feat.effects
if m.weapon_filter is not None and m.weapon_filter != weapon_name: if m.target == target
continue and (m.weapon_filter is None or m.weapon_filter == weapon_name)
mods.append(m) )
return mods return mods
def _has_feat(self, state: CombatantState, feat_name: str) -> bool: def _has_feat(self, state: CombatantState, feat_name: str) -> bool:
@@ -716,9 +743,13 @@ class CombatEngine:
self._log( self._log(
f"round {self._current_round} {attacker.combatant.id}: charge {coords}" f"round {self._current_round} {attacker.combatant.id}: charge {coords}"
) )
if not attacker.active: attacker.active_buffs.append(
return ActiveBuff(
attacker.effects.append(StatModifier(target="ac", value=-_CHARGE_AC_PENALTY)) modifiers=(StatModifier(target="ac", value=-_CHARGE_AC_PENALTY),),
rounds_left=1,
source="charge",
)
)
bonus = weapon.attack_bonus + _CHARGE_ATTACK_BONUS bonus = weapon.attack_bonus + _CHARGE_ATTACK_BONUS
self._resolve_swing( self._resolve_swing(
attacker, target, weapon, label="charge", bonus_override=bonus attacker, target, weapon, label="charge", bonus_override=bonus
@@ -851,6 +882,14 @@ class CombatEngine:
else: else:
cond = CONDITIONS_BY_NAME.get(effect.condition_name) cond = CONDITIONS_BY_NAME.get(effect.condition_name)
if cond is not None: if cond is not None:
target.active_conditions.append(
ActiveCondition(
condition=cond,
rounds_left=effect.duration_rounds,
source=f"spell: {spell.name}",
)
)
# Also update old conditions field for backwards compatibility
target.conditions.append(cond) target.conditions.append(cond)
parts.append(f"{effect.condition_name} ({effect.duration_rounds}r)") parts.append(f"{effect.condition_name} ({effect.duration_rounds}r)")
elif isinstance(effect, HealEffect): elif isinstance(effect, HealEffect):
@@ -860,8 +899,15 @@ class CombatEngine:
actual = target.hp - old_hp actual = target.hp - old_hp
parts.append(f"+{actual} HP") parts.append(f"+{actual} HP")
else: else:
for mod in effect.modifiers: target.active_buffs.append(
target.effects.append(mod) ActiveBuff(
modifiers=effect.modifiers,
rounds_left=effect.duration_rounds,
source=f"spell: {spell.name}",
)
)
# Also update old effects field for backwards compatibility
target.effects.extend(effect.modifiers)
parts.append(f"buff {len(effect.modifiers)} mods ({effect.duration_rounds}r)") parts.append(f"buff {len(effect.modifiers)} mods ({effect.duration_rounds}r)")
return parts return parts
+36
View File
@@ -20,6 +20,8 @@ from typing import TYPE_CHECKING, Literal
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Iterable from collections.abc import Iterable
from pf1e_simulator.conditions import Condition
BonusType = Literal[ BonusType = Literal[
"", # untyped — stacks with everything "", # untyped — stacks with everything
"dodge", # stacks with itself "dodge", # stacks with itself
@@ -65,6 +67,40 @@ class StatModifier:
weapon_filter: str | None = None weapon_filter: str | None = None
@dataclass(frozen=True)
class ActiveBuff:
"""A temporary buff with a remaining duration in rounds.
- ``modifiers``: the stat modifiers this buff provides.
- ``rounds_left``: how many more rounds this buff lasts (decremented at
the start of the affected creature's turn). When it reaches 0, the buff
expires and is removed.
- ``source``: a human-readable description of what created this buff
(e.g. "spell: bull's strength", "charge", "class ability: inspire courage").
"""
modifiers: tuple[StatModifier, ...]
rounds_left: int
source: str
@dataclass(frozen=True)
class ActiveCondition:
"""A temporary condition with a remaining duration in rounds.
- ``condition``: the Condition definition (modifiers + behavioral flags).
- ``rounds_left``: how many more rounds this condition lasts (decremented at
the start of the affected creature's turn). When it reaches 0, the condition
expires and is removed.
- ``source``: a human-readable description of what created this condition
(e.g. "spell: hold person", "monster ability: fear aura").
"""
condition: Condition
rounds_left: int
source: str
def resolve_modifiers(modifiers: Iterable[StatModifier]) -> int: def resolve_modifiers(modifiers: Iterable[StatModifier]) -> int:
"""Sum stat modifiers applying PF1e stacking rules. """Sum stat modifiers applying PF1e stacking rules.