feat(conditions): add condition system with 18 PF1e conditions
This commit is contained in:
@@ -84,6 +84,7 @@ from pf1e_simulator.effects import StatModifier, resolve_modifiers
|
||||
if TYPE_CHECKING:
|
||||
from typing import Literal
|
||||
|
||||
from pf1e_simulator.conditions import Condition
|
||||
from pf1e_simulator.grid import Grid
|
||||
from pf1e_simulator.map import Pos
|
||||
from pf1e_simulator.models import AttackSpec, Combatant, DamageReduction
|
||||
@@ -159,6 +160,7 @@ class CombatantState:
|
||||
pos: Pos
|
||||
hp: int
|
||||
effects: list[StatModifier] = field(default_factory=list)
|
||||
conditions: list[Condition] = field(default_factory=list)
|
||||
moved_this_turn: bool = False
|
||||
|
||||
@property
|
||||
@@ -355,6 +357,13 @@ class CombatEngine:
|
||||
return _FLANK_BONUS
|
||||
return 0
|
||||
|
||||
def _stat_modifiers(self, state: CombatantState, target: str) -> list[StatModifier]:
|
||||
"""Collect all StatModifiers for ``target`` from effects + conditions."""
|
||||
mods = [e for e in state.effects if e.target == target]
|
||||
for cond in state.conditions:
|
||||
mods.extend(m for m in cond.modifiers if m.target == target)
|
||||
return mods
|
||||
|
||||
def resolve_attack(
|
||||
self,
|
||||
attacker: CombatantState,
|
||||
@@ -369,10 +378,10 @@ class CombatEngine:
|
||||
penalty = self.range_penalty(weapon, dist_ft)
|
||||
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 + resolve_modifiers(
|
||||
e for e in target.effects if e.target == "ac"
|
||||
)
|
||||
atk_mods = self._stat_modifiers(attacker, "attack")
|
||||
attack_mod = resolve_modifiers(atk_mods)
|
||||
total = roll + base_bonus + penalty + flank + attack_mod
|
||||
ac = target.combatant.ac.total + resolve_modifiers(self._stat_modifiers(target, "ac"))
|
||||
occupied = frozenset(
|
||||
s.pos for s in self._states if s.active and s is not attacker and s is not target
|
||||
)
|
||||
@@ -390,9 +399,11 @@ class CombatEngine:
|
||||
if hit:
|
||||
if roll != _NATURAL_ONE and roll >= weapon.crit_range:
|
||||
confirm = self._rng.d20()
|
||||
confirm_total = confirm + base_bonus + penalty + flank
|
||||
confirm_total = confirm + base_bonus + penalty + flank + attack_mod
|
||||
crit = confirm != _NATURAL_ONE and confirm_total >= ac
|
||||
dmg_mods = self._stat_modifiers(attacker, "damage")
|
||||
damage = sum(c.formula.roll(self._rng) for c in weapon.damage) + weapon.damage_bonus
|
||||
damage += resolve_modifiers(dmg_mods)
|
||||
if crit:
|
||||
damage *= weapon.crit_mult
|
||||
damage = self._apply_dr(damage, weapon, target)
|
||||
@@ -417,12 +428,13 @@ class CombatEngine:
|
||||
"""Roll a saving throw (fort/ref/will) against ``dc``.
|
||||
|
||||
PF1e: natural 1 = automatic failure, natural 20 = automatic success.
|
||||
Effect modifiers (StatModifier with matching target) are applied via
|
||||
``resolve_modifiers`` — same bonus-type stacking rules as attacks.
|
||||
Effect modifiers and condition modifiers (StatModifier with matching
|
||||
target) are applied via ``resolve_modifiers`` — same bonus-type
|
||||
stacking rules as attacks.
|
||||
"""
|
||||
roll = self._rng.d20()
|
||||
base = getattr(state.combatant.saves, save_type)
|
||||
bonus = resolve_modifiers(e for e in state.effects if e.target == save_type)
|
||||
bonus = resolve_modifiers(self._stat_modifiers(state, save_type))
|
||||
total = roll + base + bonus
|
||||
success = roll != _NATURAL_ONE and (roll == _NATURAL_TWENTY or total >= dc)
|
||||
return SaveResult(success=success, roll=roll, total=total, dc=dc)
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
"""PF1e conditions: predefined stat modifiers and behavioral flags.
|
||||
|
||||
Each condition maps to a set of StatModifier effects (applied via
|
||||
resolve_modifiers with PF1e stacking rules) and optional behavioral flags
|
||||
that the engine checks at relevant resolution points.
|
||||
|
||||
Behavioral flags are declared here but only stat modifiers are integrated
|
||||
into resolve_attack/resolve_save in this step. Flag enforcement (cant_act,
|
||||
must_flee, etc.) will be added incrementally with spell/condition application.
|
||||
|
||||
Sources: PRPG Core Rulebook, Conditions chapter.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from pf1e_simulator.effects import StatModifier
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Condition:
|
||||
"""A PF1e condition: stat modifiers + behavioral flags.
|
||||
|
||||
``modifiers`` are collected alongside transient ``effects`` by
|
||||
``resolve_modifiers``. Behavioral flags are checked by the engine at
|
||||
relevant points (e.g. ``cant_act`` in ``_take_turn``).
|
||||
"""
|
||||
|
||||
name: str
|
||||
modifiers: tuple[StatModifier, ...] = ()
|
||||
denies_dex_to_ac: bool = False
|
||||
cant_act: bool = False
|
||||
cant_move: bool = False
|
||||
cant_attack: bool = False
|
||||
cant_charge: bool = False
|
||||
cant_aoo: bool = False
|
||||
must_flee: bool = False
|
||||
speed_mult: float = 1.0
|
||||
|
||||
|
||||
# ── Fear conditions ──────────────────────────────────────────────────────────
|
||||
# Shaken: -2 attack rolls, saving throws, skill checks, ability checks (CRB)
|
||||
SHAKEN = Condition(
|
||||
name="shaken",
|
||||
modifiers=(
|
||||
StatModifier(target="attack", value=-2),
|
||||
StatModifier(target="fort", value=-2),
|
||||
StatModifier(target="ref", value=-2),
|
||||
StatModifier(target="will", value=-2),
|
||||
),
|
||||
)
|
||||
|
||||
# Frightened: shaken penalties + must flee from source of fear (CRB)
|
||||
FRIGHTENED = Condition(
|
||||
name="frightened",
|
||||
modifiers=(
|
||||
StatModifier(target="attack", value=-2),
|
||||
StatModifier(target="fort", value=-2),
|
||||
StatModifier(target="ref", value=-2),
|
||||
StatModifier(target="will", value=-2),
|
||||
),
|
||||
must_flee=True,
|
||||
)
|
||||
|
||||
# Panicked: drop items + flee at top speed + -2 saves (CRB)
|
||||
PANICKED = Condition(
|
||||
name="panicked",
|
||||
modifiers=(
|
||||
StatModifier(target="fort", value=-2),
|
||||
StatModifier(target="ref", value=-2),
|
||||
StatModifier(target="will", value=-2),
|
||||
),
|
||||
must_flee=True,
|
||||
)
|
||||
|
||||
# Cowering: frozen in fear, can't act, -2 AC, loses Dex (CRB)
|
||||
COWERING = Condition(
|
||||
name="cowering",
|
||||
modifiers=(
|
||||
StatModifier(target="ac", value=-2),
|
||||
),
|
||||
denies_dex_to_ac=True,
|
||||
cant_act=True,
|
||||
)
|
||||
|
||||
# ── Physical conditions ─────────────────────────────────────────────────────
|
||||
# Sickened: -2 attack, weapon damage, saving throws, skill/ability checks (CRB)
|
||||
SICKENED = Condition(
|
||||
name="sickened",
|
||||
modifiers=(
|
||||
StatModifier(target="attack", value=-2),
|
||||
StatModifier(target="damage", value=-2),
|
||||
StatModifier(target="fort", value=-2),
|
||||
StatModifier(target="ref", value=-2),
|
||||
StatModifier(target="will", value=-2),
|
||||
),
|
||||
)
|
||||
|
||||
# Fatigued: -2 STR, -2 DEX; can't run or charge (CRB)
|
||||
# Approximation: -1 attack (STR/DEX), -1 damage (STR)
|
||||
FATIGUED = Condition(
|
||||
name="fatigued",
|
||||
modifiers=(
|
||||
StatModifier(target="attack", value=-1),
|
||||
StatModifier(target="damage", value=-1),
|
||||
),
|
||||
cant_charge=True,
|
||||
)
|
||||
|
||||
# Exhausted: -6 STR, -6 DEX; half speed; can't run or charge (CRB)
|
||||
# Approximation: -3 attack, -3 damage
|
||||
EXHAUSTED = Condition(
|
||||
name="exhausted",
|
||||
modifiers=(
|
||||
StatModifier(target="attack", value=-3),
|
||||
StatModifier(target="damage", value=-3),
|
||||
),
|
||||
cant_charge=True,
|
||||
speed_mult=0.5,
|
||||
)
|
||||
|
||||
# Entangled: -2 attack, -4 DEX; half speed; can't run or charge (CRB)
|
||||
# Approximation: -2 attack, -2 AC (from -4 DEX), -2 ref (from -4 DEX)
|
||||
ENTANGLED = Condition(
|
||||
name="entangled",
|
||||
modifiers=(
|
||||
StatModifier(target="attack", value=-2),
|
||||
StatModifier(target="ac", value=-2),
|
||||
StatModifier(target="ref", value=-2),
|
||||
),
|
||||
cant_charge=True,
|
||||
speed_mult=0.5,
|
||||
)
|
||||
|
||||
# ── Sensory conditions ──────────────────────────────────────────────────────
|
||||
# Dazzled: -1 attack rolls (CRB)
|
||||
DAZZLED = Condition(
|
||||
name="dazzled",
|
||||
modifiers=(
|
||||
StatModifier(target="attack", value=-1),
|
||||
),
|
||||
)
|
||||
|
||||
# Blinded: -2 AC, loses Dex, -4 STR/DEX skill checks (CRB)
|
||||
BLINDED = Condition(
|
||||
name="blinded",
|
||||
modifiers=(
|
||||
StatModifier(target="ac", value=-2),
|
||||
StatModifier(target="attack", value=-2),
|
||||
),
|
||||
denies_dex_to_ac=True,
|
||||
)
|
||||
|
||||
# Deafened: -4 initiative, 20% spell failure (verbal) (CRB)
|
||||
DEAFENED = Condition(
|
||||
name="deafened",
|
||||
modifiers=(
|
||||
StatModifier(target="initiative", value=-4),
|
||||
),
|
||||
)
|
||||
|
||||
# ── Positional conditions ────────────────────────────────────────────────────
|
||||
# Flat-footed: loses Dex to AC, can't make AoO (CRB)
|
||||
FLAT_FOOTED = Condition(
|
||||
name="flat_footed",
|
||||
denies_dex_to_ac=True,
|
||||
cant_aoo=True,
|
||||
)
|
||||
|
||||
# Prone: -4 melee attack, +4 AC vs ranged, -4 AC vs melee (CRB)
|
||||
PRONE = Condition(
|
||||
name="prone",
|
||||
)
|
||||
|
||||
# ── Incapacitating conditions ────────────────────────────────────────────────
|
||||
# Stunned: can't act, -2 AC, loses Dex (CRB)
|
||||
STUNNED = Condition(
|
||||
name="stunned",
|
||||
modifiers=(
|
||||
StatModifier(target="ac", value=-2),
|
||||
),
|
||||
denies_dex_to_ac=True,
|
||||
cant_act=True,
|
||||
)
|
||||
|
||||
# Paralyzed: can't move or act, effective STR/DEX = 0, helpless (CRB)
|
||||
PARALYZED = Condition(
|
||||
name="paralyzed",
|
||||
denies_dex_to_ac=True,
|
||||
cant_act=True,
|
||||
cant_move=True,
|
||||
)
|
||||
|
||||
# Nauseated: can't attack/cast/concentrate; only single move action (CRB)
|
||||
NAUSEATED = Condition(
|
||||
name="nauseated",
|
||||
cant_attack=True,
|
||||
)
|
||||
|
||||
# Dazed: can't act, no AC penalty (CRB)
|
||||
DAZED = Condition(
|
||||
name="dazed",
|
||||
cant_act=True,
|
||||
)
|
||||
|
||||
# Staggered: only single move or standard action, no full-round (CRB)
|
||||
STAGGERED = Condition(
|
||||
name="staggered",
|
||||
)
|
||||
Reference in New Issue
Block a user