feat(conditions): add blinded/invisible/helpless/fascinated combat rules
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user