fix(rules): correct P1 PF1e conformance bugs (area saves, concentration, prone, slots, helpless, nauseated)
This commit is contained in:
+120
-34
@@ -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 ()
|
||||
|
||||
|
||||
@@ -205,12 +205,11 @@ DAZED = Condition(
|
||||
cant_act=True,
|
||||
)
|
||||
|
||||
# Asleep/Unconscious: helpless, can't act, -4 AC vs melee, no Dex to AC (CRB)
|
||||
# Asleep/Unconscious: helpless (CRB) — can't act, no Dex to AC. The old flat
|
||||
# -4 AC approximation is superseded by the engine's Dex-0 (-5) adjustment for
|
||||
# helpless targets, applied in resolve_attack (CombatEngine._is_helpless).
|
||||
ASLEEP = Condition(
|
||||
name="asleep",
|
||||
modifiers=(
|
||||
StatModifier(target="ac", value=-4),
|
||||
),
|
||||
denies_dex_to_ac=True,
|
||||
cant_act=True,
|
||||
cant_move=True,
|
||||
@@ -218,7 +217,10 @@ ASLEEP = Condition(
|
||||
|
||||
|
||||
# Helpless: helpless for all intents, treated as Dex 0 (loses Dex to AC),
|
||||
# can't act; melee attackers gain +4 (engine logic in resolve_attack) (CRB)
|
||||
# can't act; melee attackers gain +4 (engine logic in resolve_attack). The
|
||||
# engine also treats paralyzed/asleep/unconscious as helpless (CRB: "helpless"
|
||||
# = paralyzed, held, bound, sleeping, unconscious; stunned is NOT helpless) —
|
||||
# see CombatEngine._is_helpless (CRB)
|
||||
HELPLESS = Condition(
|
||||
name="helpless",
|
||||
denies_dex_to_ac=True,
|
||||
|
||||
Reference in New Issue
Block a user