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 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
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user