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 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