feat(combat): effect and condition durations
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user