From 43303cf28e28751d07e51ed0fb4a3355a5dcce82 Mon Sep 17 00:00:00 2001 From: Thien An Date: Sat, 29 Aug 2026 20:14:36 +0200 Subject: [PATCH] feat(combat): effect and condition durations --- src/pf1e_simulator/combat.py | 76 ++++++++++++++++++++++++++++------- src/pf1e_simulator/effects.py | 36 +++++++++++++++++ 2 files changed, 97 insertions(+), 15 deletions(-) diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index 29fe47a..e0eb1e5 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -80,7 +80,7 @@ from dataclasses import asdict, dataclass, field from typing import TYPE_CHECKING 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 ( ConditionEffect, DamageEffect, @@ -174,8 +174,12 @@ class CombatantState: side: str pos: Pos hp: int + # Old fields for backwards compatibility with tests effects: list[StatModifier] = 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 power_attack: bool = False @@ -283,7 +287,7 @@ class CombatEngine: def _take_turn(self, state: CombatantState) -> bool: """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.power_attack = False actions = self._policy(self, state) @@ -298,6 +302,19 @@ class CombatEngine: self._log(f"round {self._current_round} {state.combatant.id}: wait") 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: """Run the battle and return the final result and transcript.""" order = self._roll_initiative() @@ -380,22 +397,32 @@ class CombatEngine: def _stat_modifiers( self, state: CombatantState, target: str, *, weapon_name: str | None = None ) -> 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 doesn't match (e.g. Weapon Focus (Pistol) only applies to "Pistol"). 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: 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 m in feat.effects: - if m.target != target: - continue - if m.weapon_filter is not None and m.weapon_filter != weapon_name: - continue - mods.append(m) + mods.extend( + m + for m in feat.effects + if m.target == target + and (m.weapon_filter is None or m.weapon_filter == weapon_name) + ) return mods def _has_feat(self, state: CombatantState, feat_name: str) -> bool: @@ -716,9 +743,13 @@ class CombatEngine: self._log( f"round {self._current_round} {attacker.combatant.id}: charge {coords}" ) - if not attacker.active: - return - attacker.effects.append(StatModifier(target="ac", value=-_CHARGE_AC_PENALTY)) + attacker.active_buffs.append( + ActiveBuff( + modifiers=(StatModifier(target="ac", value=-_CHARGE_AC_PENALTY),), + rounds_left=1, + source="charge", + ) + ) bonus = weapon.attack_bonus + _CHARGE_ATTACK_BONUS self._resolve_swing( attacker, target, weapon, label="charge", bonus_override=bonus @@ -851,6 +882,14 @@ class CombatEngine: else: cond = CONDITIONS_BY_NAME.get(effect.condition_name) 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) parts.append(f"{effect.condition_name} ({effect.duration_rounds}r)") elif isinstance(effect, HealEffect): @@ -860,8 +899,15 @@ class CombatEngine: actual = target.hp - old_hp parts.append(f"+{actual} HP") else: - for mod in effect.modifiers: - target.effects.append(mod) + target.active_buffs.append( + 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)") return parts diff --git a/src/pf1e_simulator/effects.py b/src/pf1e_simulator/effects.py index cd48e1d..4468f57 100644 --- a/src/pf1e_simulator/effects.py +++ b/src/pf1e_simulator/effects.py @@ -20,6 +20,8 @@ from typing import TYPE_CHECKING, Literal if TYPE_CHECKING: from collections.abc import Iterable + from pf1e_simulator.conditions import Condition + BonusType = Literal[ "", # untyped — stacks with everything "dodge", # stacks with itself @@ -65,6 +67,40 @@ class StatModifier: 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: """Sum stat modifiers applying PF1e stacking rules.