fix(rules): correct P1 PF1e conformance bugs (area saves, concentration, prone, slots, helpless, nauseated)

This commit is contained in:
2026-09-10 20:52:28 +02:00
parent 1ca5aed758
commit 5afe495c4a
15 changed files with 790 additions and 86 deletions
+120 -34
View File
@@ -101,6 +101,7 @@ from pf1e_simulator.spells import (
ConditionEffect,
DamageEffect,
HealEffect,
SaveType,
SpellEffect,
SummonEffect,
save_descriptors_for_effects,
@@ -166,6 +167,13 @@ _CONFUSED_BABBLE_MAX = 50
_CONFUSED_SELF_DAMAGE_MAX = 75
_INVISIBLE_ATTACK_BONUS = 2 # PF1e: +2 attack rolls for an invisible attacker
_HELPLESS_MELEE_BONUS = 4 # PF1e: +4 melee attack rolls (and crit confirms) vs helpless
# PF1e: a helpless creature (paralyzed, held, bound, sleeping, unconscious —
# stunned is NOT helpless) has an effective Dexterity score of 0, i.e. a -5
# modifier for a Medium creature (CRB, Helpless condition).
_DEX_ZERO_MOD = -5
_HELPLESS_CONDITION_NAMES: frozenset[str] = frozenset(
{"helpless", "paralyzed", "asleep", "unconscious"}
)
_AID_ANOTHER_DC = 10 # PF1e: aid another is an attack roll against AC 10
_AID_ANOTHER_BONUS = 2 # PF1e: a successful aid grants +2 (next attack or AC)
_RUN_SPEED_FACTOR = 4 # PF1e: running is a full-round action at 4x speed
@@ -1341,6 +1349,16 @@ class CombatEngine:
or any(cond.name == name for cond in state.conditions)
)
def _is_helpless(self, state: CombatantState) -> bool:
"""PF1e (CRB): helpless = paralyzed, held, bound, sleeping, unconscious.
Stunned is NOT helpless. A helpless creature has effective Dexterity 0
and melee attackers gain +4 (resolve_attack)."""
return any(
self._has_condition_named(state, name)
for name in _HELPLESS_CONDITION_NAMES
)
def _modifier_condition_holds(
self,
_state: CombatantState,
@@ -1805,11 +1823,9 @@ class CombatEngine:
if m.target == target
and (m.weapon_filter is None or m.weapon_filter == weapon_name)
)
if target == "ac":
if self._has_condition_named(state, "prone"):
mods.append(StatModifier(target="ac", value=-4))
if self._has_condition_named(state, "paralyzed"):
mods.append(StatModifier(target="ac", value=-4))
# PF1e: prone and helpless (paralyzed/asleep/unconscious) AC
# adjustments depend on the weapon kind and are applied in
# resolve_attack, where it is known — not unconditionally here.
return [
m
for m in mods
@@ -1901,6 +1917,29 @@ class CombatEngine:
return 0
return max(1, attacker.combatant.bab // 4 + 1)
def _attack_ac_mods(
self, target: CombatantState, weapon: AttackSpec
) -> list[StatModifier]:
"""AC modifiers for one attack — the weapon-kind-dependent ones included.
PF1e (CRB): a prone defender takes -4 AC against melee attacks but
gains +4 AC against ranged attacks; a helpless defender (paralyzed,
asleep, unconscious, helpless) has an effective Dexterity score of 0,
so its whole Dex modifier gives way to the Dex-0 penalty (-5) —
flat-footed AC already drops a positive Dex bonus, a native Dex
penalty must still be replaced."""
mods = self._stat_modifiers(target, "ac")
if self._has_condition_named(target, "prone"):
mods.append(
StatModifier(
target="ac", value=4 if self._is_ranged_attack(weapon) else -4
)
)
if self._is_helpless(target):
dex_penalty = min(0, target.combatant.abilities.dex_mod)
mods.append(StatModifier(target="ac", value=_DEX_ZERO_MOD - dex_penalty))
return mods
def resolve_attack(
self,
attacker: CombatantState,
@@ -1929,14 +1968,21 @@ class CombatEngine:
atk_mods = self._stat_modifiers(
attacker, "attack", weapon_name=weapon.name, weapon=weapon, dist_ft=dist_ft
)
if self._is_ranged_attack(weapon) and self._has_condition_named(attacker, "prone"):
# PF1e (CRB): a prone attacker takes -4 on melee attack rolls. Ranged
# weapons cannot be used while prone at all, EXCEPT crossbows, which
# stay usable at -4. The model cannot tell a crossbow from a bow, so
# every ranged weapon is treated as the crossbow-exempt case: usable,
# but at -4 (documented simplification).
if self._has_condition_named(attacker, "prone"):
atk_mods.append(StatModifier(target="attack", value=-4))
# PF1e: an invisible attacker strikes from an unknown direction (+2 on
# attack and crit confirmation, target loses its Dex bonus to AC).
if self._has_condition_named(attacker, "invisible"):
atk_mods.append(StatModifier(target="attack", value=_INVISIBLE_ATTACK_BONUS))
# PF1e: melee attacks against a helpless target gain +4 (ranged none).
if weapon.kind == "melee" and self._has_condition_named(target, "helpless"):
# PF1e (CRB): melee attacks against a helpless target gain +4; the
# helpless state includes paralyzed, asleep and unconscious (stunned
# is NOT helpless). Ranged attacks gain no bonus.
if weapon.kind == "melee" and self._is_helpless(target):
atk_mods.append(StatModifier(target="attack", value=_HELPLESS_MELEE_BONUS))
attack_mod = resolve_modifiers(atk_mods)
pa_amt = self._power_attack_amt(attacker) if weapon.kind == "melee" else 0
@@ -1976,7 +2022,8 @@ class CombatEngine:
base_ac = target.combatant.ac.touch
else:
base_ac = full_ac_base
ac = base_ac + resolve_modifiers(self._stat_modifiers(target, "ac"))
ac_mods = self._attack_ac_mods(target, weapon)
ac = base_ac + resolve_modifiers(ac_mods)
ac += target.defensive_ac_bonus
ac += target.aid_ac_bonus
occupied = frozenset(
@@ -2609,17 +2656,19 @@ class CombatEngine:
) -> bool:
"""Concentration check: DC depends on situation.
Roll = d20 + caster_level + casting ability mod (+4 Combat Casting).
PF1e (CRB): the check is d20 + caster level + the ability modifier
used to determine bonus spells (Int/Wis/Cha) — NOT Constitution.
Combat Casting's +4 applies only when casting defensively or while
grappled/pinned, never for damage taken while casting.
"""
dc = self._concentration_dc(caster, spell_level, damage_taken)
has_combat_casting = any(f.name == "Combat Casting" for f in caster.combatant.features)
bonus = 4 if has_combat_casting else 0
injured_while_casting = damage_taken > 0 and caster.grappled_with is None
has_combat_casting = self._has_feat(caster, "Combat Casting")
bonus = 0 if injured_while_casting else (4 if has_combat_casting else 0)
roll = self._rng.d20()
# Use caster level + ability mod for concentration check
caster_level = caster.combatant.level
# For concentration, the check is d20 + caster_level + ability_mod (usually Con)
con_mod = (caster.combatant.abilities.con_score - 10) // 2
total = roll + caster_level + con_mod + bonus
total = (
roll + caster.combatant.level + self._casting_ability_mod(caster) + bonus
)
return total >= dc
def can_step5_to_attack(self, state: CombatantState, target: CombatantState) -> bool:
"""True if a 5-foot step toward ``target`` would put ``state`` in weapon range."""
@@ -3334,6 +3383,14 @@ class CombatEngine:
"""Cast the carried spell; an explicit ``target_pos`` (U7b) is
validated first — an off-map point or one without line of effect
from the caster is rejected without casting."""
if self.has_condition_flag(state, "cant_attack"):
# PF1e (CRB): a nauseated creature cannot cast spells or
# concentrate; the cant_attack flag blocks the cast entirely.
self._log(
f"round {self._current_round} {state.combatant.id}: "
f"cast_spell rejected (cannot attack)"
)
return None
if target_pos is not None:
rejection = self._point_target_rejection(state, target_pos)
if rejection is not None:
@@ -4142,12 +4199,15 @@ class CombatEngine:
return
self._charge(state, target, result.weapon)
def _spell_dc(self, caster: CombatantState, spell: SpellSpec) -> int:
"""Spell save DC: 10 + spell level + casting ability modifier."""
def _casting_ability_mod(self, caster: CombatantState) -> int:
"""Modifier of the casting ability (the one used for bonus spells)."""
ability_name = caster.combatant.casting_ability.lower()
ability_score = getattr(caster.combatant.abilities, f"{ability_name}_score")
ability_mod = (ability_score - 10) // 2
return 10 + spell.level + ability_mod
return (ability_score - 10) // 2
def _spell_dc(self, caster: CombatantState, spell: SpellSpec) -> int:
"""Spell save DC: 10 + spell level + casting ability modifier."""
return 10 + spell.level + self._casting_ability_mod(caster)
def _spell_snapshot(self, state: CombatantState, spell_name: str) -> dict[str, object]:
"""One per-spell dict for the Observation, enriched from the registry."""
@@ -4208,10 +4268,18 @@ class CombatEngine:
caster, target, spell, spell_name, center_pos, has_area=has_area
):
return
# PF1e (CRB): a caster cannot cast without an available slot of the
# spell's level; cantrips (level 0) are cast freely. The slot is spent
# up-front — a later failed concentration check still burns it.
if spell.level > 0 and not caster.spend_spell_slot(spell.level):
self._log(
f"round {self._current_round} {caster.combatant.id}: "
f"cast {spell_name} -> NO SPELL SLOT (level {spell.level})"
)
return
# Check for AoO from casting in threatened square (before resolving spell)
if self._check_casting_aoo(caster, spell.level):
return
caster.spend_spell_slot(spell.level)
if (
target is not None
and not has_area
@@ -4311,7 +4379,7 @@ class CombatEngine:
caster=caster, spell_name=spell.name,
empower="Empower Spell" in metamagic_feats,
maximize="Maximize Spell" in metamagic_feats,
dc=dc, center_pos=center_pos,
dc=dc, center_pos=center_pos, save_type=spell.save,
)
save_str = _save_label(save_result)
if target is None and center_pos is not None:
@@ -4349,7 +4417,7 @@ class CombatEngine:
"""Legacy wrapper for compatibility."""
return self._apply_spell_effects_from_list(
spell.effects, target, save_result, caster_live, target_live,
caster=target, spell_name=spell.name
caster=target, spell_name=spell.name, save_type=spell.save
)
def _apply_spell_effects_from_list(
@@ -4366,6 +4434,7 @@ class CombatEngine:
maximize: bool = False,
dc: int = 15,
center_pos: tuple[int, int] | None = None,
save_type: SaveType = "none",
) -> list[str]:
saved = save_result is not None and save_result.success
return [
@@ -4373,7 +4442,7 @@ class CombatEngine:
effect, target, caster_live, target_live,
caster=caster, spell_name=spell_name,
empower=empower, maximize=maximize, saved=saved,
dc=dc, center_pos=center_pos,
dc=dc, center_pos=center_pos, save_type=save_type,
)
for effect in effects
]
@@ -4392,13 +4461,14 @@ class CombatEngine:
saved: bool,
dc: int = 15,
center_pos: tuple[int, int] | None = None,
save_type: SaveType = "none",
) -> str:
if isinstance(effect, AreaEffect):
return self._apply_area_effect(
effect, target, caster_live, target_live,
caster=caster, spell_name=spell_name, saved=saved,
empower=empower, maximize=maximize, dc=dc,
center_pos=center_pos,
center_pos=center_pos, save_type=save_type,
)
if isinstance(effect, SummonEffect):
return self._apply_summon_effect(effect, caster, caster_live, spell_name)
@@ -4535,6 +4605,7 @@ class CombatEngine:
maximize: bool = False,
dc: int = 15,
center_pos: tuple[int, int] | None = None,
save_type: SaveType = "none",
) -> str:
"""Apply an area effect to all creatures within radius.
@@ -4558,7 +4629,8 @@ class CombatEngine:
if not self._check_spell_resistance(caster, affected_state):
continue
creature_saved = self._area_save(
affected_state, target, effect, saved=saved, dc=dc
affected_state, target, effect, saved=saved, dc=dc,
save_type=save_type,
)
for sub_effect in effect.effects:
target_live = self._stats[affected_state.combatant.id]
@@ -4603,19 +4675,27 @@ class CombatEngine:
*,
saved: bool,
dc: int = 15,
save_type: SaveType = "none",
) -> bool:
"""Save result for one creature in an area effect.
PF1e (CRB): every creature in the area rolls the save the SPELL lists
(Will / Fortitude / Reflex) — "save negates" and "save for half" both
grant it; whether a success negates or halves is decided per sub-effect
(``save_negates`` / ``half_on_save``), never by skipping the roll.
The resolved primary target re-uses its already-rolled save; every
other creature in the area — and every creature of a point-targeted
burst (no primary target, U7b) — rolls its own.
"""
if not effect.save_for_half:
if save_type == "none":
return False
if affected_state is target:
return saved
save_result = self.resolve_save(
affected_state, "ref", dc, save_descriptors=save_descriptors_for_effects(effect.effects)
affected_state,
save_type,
dc,
save_descriptors=save_descriptors_for_effects(effect.effects),
)
return save_result.success
@@ -5277,10 +5357,12 @@ def _choose_spell_action(
_FULL_ROUND_KINDS: frozenset[str] = frozenset({"full_attack", "charge", "withdraw", "full_round"})
_STANDARD_KINDS: frozenset[str] = frozenset({
"attack", "cast_spell", "maneuver", "hex", "deed", "spell_combat", "channel_energy",
})
_SWIFT_BONUS_KINDS: frozenset[str] = frozenset({"deed", "inspire_courage", "swift"})
# PF1e (CRB): a nauseated creature can take only a single move action per
# turn — no standard, no full-round, no attacks, no spellcasting.
_NAUSEA_MOVE_KINDS: frozenset[str] = frozenset(
{"move", "5foot_step", "stand_up", "drop_prone"}
)
def _without_swift_bonus(actions: tuple[Action, ...]) -> tuple[Action, ...]:
@@ -5316,9 +5398,13 @@ def _staggered_filter(actions: tuple[Action, ...]) -> tuple[Action, ...]:
def _nauseated_filter(actions: tuple[Action, ...]) -> tuple[Action, ...]:
"""Nauseated: single standard action only."""
"""PF1e (CRB) nauseated: a single move action per turn, nothing else.
The sequence collapses to its first move-like action; standard actions
(attack, cast_spell, maneuver, ...) and full-round actions are dropped.
"""
for action in actions:
if action.kind in _STANDARD_KINDS:
if action.kind in _NAUSEA_MOVE_KINDS:
return (action,)
return ()