From 88f11de40faf1258d4ac494e6e90d9879ce3a498 Mon Sep 17 00:00:00 2001 From: Thien An Date: Mon, 17 Aug 2026 22:49:50 +0200 Subject: [PATCH] refactor(combat): replace ac_penalty with effect system --- src/pf1e_simulator/combat.py | 18 +++++++++++------- src/pf1e_simulator/effects.py | 4 ++-- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index 2dad3c5..2c41c80 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -51,8 +51,8 @@ Phase 0 documented deviations from PF1e (conventions): - Charge: a full-round action that moves in a straight line (Bresenham) up to 2x speed toward the closest square from which the charger can melee the target, then makes a single melee attack at +2. The charger takes -2 AC - until the start of their next turn (tracked in ``ac_penalty``, cleared in - ``_take_turn``). The straight line must be clear of difficult terrain, + until the start of their next turn (tracked as a StatModifier in + ``effects``, cleared in ``_take_turn``). The straight line must be clear of difficult terrain, obstacles, and creatures; minimum 2 cells (10 ft). ``default_policy`` chooses a charge when the enemy is out of reach but a valid charge path exists. @@ -76,9 +76,11 @@ Phase 0 documented deviations from PF1e (conventions): from __future__ import annotations from collections.abc import Callable -from dataclasses import asdict, dataclass +from dataclasses import asdict, dataclass, field from typing import TYPE_CHECKING +from pf1e_simulator.effects import StatModifier, resolve_modifiers + if TYPE_CHECKING: from typing import Literal @@ -156,7 +158,7 @@ class CombatantState: side: str pos: Pos hp: int - ac_penalty: int = 0 + effects: list[StatModifier] = field(default_factory=list) moved_this_turn: bool = False @property @@ -250,7 +252,7 @@ class CombatEngine: def _take_turn(self, state: CombatantState) -> bool: """Execute one combatant's full turn; return True if the battle is over.""" - state.ac_penalty = 0 + state.effects.clear() state.moved_this_turn = False actions = self._policy(self, state) before = len(self._transcript) @@ -358,7 +360,9 @@ class CombatEngine: flank = self._flanking_bonus(attacker, target) if weapon.kind == "melee" else 0 base_bonus = bonus_override if bonus_override is not None else weapon.attack_bonus total = roll + base_bonus + penalty + flank - ac = target.combatant.ac.total - target.ac_penalty + ac = target.combatant.ac.total + resolve_modifiers( + e for e in target.effects if e.target == "ac" + ) occupied = frozenset( s.pos for s in self._states if s.active and s is not attacker and s is not target ) @@ -620,7 +624,7 @@ class CombatEngine: ) if not attacker.active: return - attacker.ac_penalty = _CHARGE_AC_PENALTY + attacker.effects.append(StatModifier(target="ac", value=-_CHARGE_AC_PENALTY)) bonus = weapon.attack_bonus + _CHARGE_ATTACK_BONUS self._resolve_swing( attacker, target, weapon, label="charge", bonus_override=bonus diff --git a/src/pf1e_simulator/effects.py b/src/pf1e_simulator/effects.py index 8fa4f52..cd48e1d 100644 --- a/src/pf1e_simulator/effects.py +++ b/src/pf1e_simulator/effects.py @@ -18,7 +18,7 @@ from dataclasses import dataclass from typing import TYPE_CHECKING, Literal if TYPE_CHECKING: - from collections.abc import Sequence + from collections.abc import Iterable BonusType = Literal[ "", # untyped — stacks with everything @@ -65,7 +65,7 @@ class StatModifier: weapon_filter: str | None = None -def resolve_modifiers(modifiers: Sequence[StatModifier]) -> int: +def resolve_modifiers(modifiers: Iterable[StatModifier]) -> int: """Sum stat modifiers applying PF1e stacking rules. Stacking bonus types (dodge, racial, trait, untyped) all sum together.