feat(conditions): add blinded/invisible/helpless/fascinated combat rules

This commit is contained in:
2026-09-10 18:00:41 +02:00
parent 79ac190728
commit b3bff85215
4 changed files with 379 additions and 14 deletions
+28 -6
View File
@@ -158,6 +158,9 @@ _DELAY_INITIATIVE_STEP = 0.5 # PF1e: a delay slots 0.5 below its anchor total
_TOTAL_DEFENSE_AC_BONUS = 4 # PF1e: total defense grants +4 dodge AC
_FIGHT_DEFENSIVELY_AC_BONUS = 2 # PF1e: fighting defensively grants +2 dodge AC
_FIGHT_DEFENSIVELY_ATTACK_PENALTY = -4 # PF1e: and -4 on attacks / crit confirms
_TOTAL_CONCEALMENT_PCT = 50 # PF1e: a blinded attacker's targets have total concealment
_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
_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
@@ -1806,6 +1809,13 @@ class CombatEngine:
)
if self._is_ranged_attack(weapon) and 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"):
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
fd_penalty = (
@@ -1816,8 +1826,13 @@ class CombatEngine:
+ fim_penalty + fd_penalty + attacker.aid_attack_bonus
)
# Check if target denies DEX to AC (flat-footed); a running target
# loses its DEX bonus too (U5e), reusing the same AC path.
denies_dex = self.has_condition_flag(target, "denies_dex_to_ac") or target.running
# loses its DEX bonus too (U5e), reusing the same AC path. An
# invisible attacker also denies the target's DEX bonus to AC.
denies_dex = (
self.has_condition_flag(target, "denies_dex_to_ac")
or target.running
or self._has_condition_named(attacker, "invisible")
)
full_ac_base = (
target.combatant.ac.flat_footed
if denies_dex
@@ -1918,13 +1933,20 @@ class CombatEngine:
)
crit = confirm != _NATURAL_ONE and confirm_total >= ac
concealment = self._concealment_chance(target)
if concealment > 0:
# PF1e: a blinded character's opponents count as having total
# concealment against it — the blinded attacker itself misses 50%.
attacker_blinded = self._has_condition_named(attacker, "blinded")
miss_chance = (
max(concealment, _TOTAL_CONCEALMENT_PCT) if attacker_blinded else concealment
)
if miss_chance > 0:
pct = self._rng.d100()
missed = pct <= concealment
missed = pct <= miss_chance
source = "attacker blinded" if attacker_blinded else "concealment"
self._log(
f"round {self._current_round} {attacker.combatant.id}: "
f"concealment vs {target.combatant.id}: "
f"d100({pct}) {'<=' if missed else '>'} {concealment}% "
f"{source} vs {target.combatant.id}: "
f"d100({pct}) {'<=' if missed else '>'} {miss_chance}% "
f"— attack {'misses' if missed else 'lands'}"
)
if missed:
+22
View File
@@ -217,6 +217,20 @@ 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)
HELPLESS = Condition(
name="helpless",
denies_dex_to_ac=True,
cant_act=True,
)
# Fascinated: distracted, takes no actions other than paying attention (CRB)
FASCINATED = Condition(
name="fascinated",
cant_act=True,
)
# Grappled: -4 Dex, -2 attack, no movement, no AoO, can't cast without concentration (CRB)
GRAPPLED = Condition(
name="grappled",
@@ -267,6 +281,13 @@ DISPLACED = Condition(
miss_chance=50,
)
# Invisible: total concealment against attacks targeting it; the attacker-side
# +2 on attack rolls and the target's lost Dex bonus are engine logic (CRB)
INVISIBLE = Condition(
name="invisible",
miss_chance=50,
)
# ── Registry ─────────────────────────────────────────────────────────────────
CONDITIONS_BY_NAME: dict[str, Condition] = {
c.name: c
@@ -275,5 +296,6 @@ CONDITIONS_BY_NAME: dict[str, Condition] = {
BLINDED, DEAFENED, FLAT_FOOTED, PRONE, STUNNED, PARALYZED,
NAUSEATED, DAZED, STAGGERED, FRIGHTENED, PANICKED, COWERING,
ASLEEP, GRAPPLED, PINNED, BLURRED, DISPLACED,
HELPLESS, FASCINATED, INVISIBLE,
]
}