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
+298
View File
@@ -0,0 +1,298 @@
"""V5a conditions: blinded-attacker miss, invisible attacker, helpless, fascinated.
Canonical PF1e (Core Rulebook, Conditions): a blinded character's opponents
have total concealment against it (50% miss); an invisible attacker gains +2
on attack rolls and its target loses its Dexterity bonus to AC, while the
invisible creature itself has total concealment; melee attacks against a
helpless target gain +4 (ranged none); a fascinated creature cannot act.
"""
from __future__ import annotations
from pf1e_simulator.conditions import (
CONDITIONS_BY_NAME,
FASCINATED,
HELPLESS,
INVISIBLE,
)
from pf1e_simulator.models import AbilityScores, ACProfile, Combatant, Saves
from tests.test_accuracy_rules import (
_add_condition,
_bow,
_combatant,
_engine,
_melee,
)
from tests.test_observation_enriched import _snapshot
# ── Rule A: blinded attacker suffers total concealment (50% miss) ────────────
def test_blinded_attacker_misses_on_low_d100() -> None:
eng = _engine(
[_combatant("atk"), _combatant("tgt")],
[(0, 0), (0, 1)],
["players", "monsters"],
[15, 30],
)
a, t = eng._states
_add_condition(a, "blinded")
result = eng.resolve_attack(a, t, a.combatant.attacks[0])
# roll 15 + bonus 5 - 2 (blinded) = 18 beats AC 10, then d100 30 <= 50 misses.
assert result.hit is True
assert result.total == 18
assert result.damage == 0
assert t.hp == 100
assert any("attacker blinded" in line and "misses" in line for line in eng._transcript)
def test_blinded_attacker_lands_on_high_d100() -> None:
eng = _engine(
[_combatant("atk"), _combatant("tgt")],
[(0, 0), (0, 1)],
["players", "monsters"],
[15, 51, 4],
)
a, t = eng._states
_add_condition(a, "blinded")
result = eng.resolve_attack(a, t, a.combatant.attacks[0])
assert result.hit is True
assert result.damage == 4
assert t.hp == 96
assert any("attacker blinded" in line and "lands" in line for line in eng._transcript)
def test_blinded_attacker_d100_rolls_once_with_target_concealment() -> None:
eng = _engine(
[_combatant("atk"), _combatant("tgt")],
[(0, 0), (0, 1)],
["players", "monsters"],
[15, 30],
)
a, t = eng._states
_add_condition(a, "blinded")
_add_condition(t, "blurred")
result = eng.resolve_attack(a, t, a.combatant.attacks[0])
# max(20, 50) = 50; a single d100 (30) consumes the queue exactly.
assert result.hit is True
assert result.damage == 0
assert any("50%" in line for line in eng._transcript)
# ── Rule B: invisible attacker (+2 to hit, target loses Dex to AC) ───────────
def _dexterous_foe(cid: str) -> Combatant:
"""A foe whose AC profile bakes in a +3 Dexterity bonus (RAW-style totals)."""
return Combatant(
id=cid,
name=cid,
level=1,
size="Medium",
abilities=AbilityScores(
str_score=10, dex_score=16, con_score=10,
int_score=10, wis_score=10, cha_score=10,
),
hp_max=100,
ac=ACProfile(total=18, touch=15, flat_footed=15),
bab=1,
initiative_mod=0,
speed_land_ft=30,
saves=Saves(fort=0, ref=0, will=0),
attacks=[_melee(cid)],
)
def test_invisible_attacker_gains_plus2_and_denies_dex_ac() -> None:
eng = _engine(
[_combatant("atk"), _dexterous_foe("tgt")],
[(0, 0), (0, 1)],
["players", "monsters"],
[10, 4],
)
a, t = eng._states
_add_condition(a, "invisible")
result = eng.resolve_attack(a, t, a.combatant.attacks[0])
# roll 10 + bonus 5 + 2 (invisible) = 17 vs flat-footed AC 15 (Dex denied).
assert result.total == 17
assert result.ac == 15
assert result.hit is True
assert result.damage == 4
def test_visible_attacker_normal_attack_control() -> None:
eng = _engine(
[_combatant("atk"), _dexterous_foe("tgt")],
[(0, 0), (0, 1)],
["players", "monsters"],
[10],
)
a, t = eng._states
result = eng.resolve_attack(a, t, a.combatant.attacks[0])
# No invisible: total 15 < full AC 18 -> miss, no damage roll consumed.
assert result.total == 15
assert result.ac == 18
assert result.hit is False
def test_invisible_attacker_plus2_applies_to_crit_confirmation() -> None:
eng = _engine(
[_combatant("atk"), _dexterous_foe("tgt")],
[(0, 0), (0, 1)],
["players", "monsters"],
[20, 10, 4],
)
a, t = eng._states
_add_condition(a, "invisible")
result = eng.resolve_attack(a, t, a.combatant.attacks[0])
# Nat 20 threatens; confirm 10 + 5 + 2 = 17 >= 15 -> crit. Damage 4 x 2.
assert result.hit is True
assert result.crit is True
assert result.damage == 8
# ── Rule C: melee +4 vs a helpless target (ranged none) ──────────────────────
def test_melee_vs_helpless_target_gains_plus4() -> None:
eng = _engine(
[_combatant("atk"), _combatant("tgt")],
[(0, 0), (0, 1)],
["players", "monsters"],
[10, 4],
)
a, t = eng._states
_add_condition(t, "helpless")
result = eng.resolve_attack(a, t, a.combatant.attacks[0])
# roll 10 + bonus 5 + 4 (helpless melee) = 19; AC = flat-footed 10 (Dex denied).
assert result.total == 19
assert result.ac == 10
assert result.hit is True
def test_melee_vs_helpless_plus4_applies_to_crit_confirmation() -> None:
eng = _engine(
[_combatant("atk"), _combatant("tgt")],
[(0, 0), (0, 1)],
["players", "monsters"],
[20, 10, 4],
)
a, t = eng._states
_add_condition(t, "helpless")
result = eng.resolve_attack(a, t, a.combatant.attacks[0])
# Confirm 10 + 5 + 4 = 19 >= 10 -> crit.
assert result.crit is True
assert result.damage == 8
def test_ranged_vs_helpless_target_gets_no_bonus() -> None:
eng = _engine(
[_combatant("atk", attacks=(_bow("atk"),)), _combatant("tgt")],
[(0, 0), (0, 4)],
["players", "monsters"],
[10, 4],
)
a, t = eng._states
_add_condition(t, "helpless")
result = eng.resolve_attack(a, t, a.combatant.attacks[0])
# Ranged: no +4 — roll 10 + bonus 5 = 15.
assert result.total == 15
assert result.hit is True
# ── Rule D: fascinated / helpless combatants cannot act ──────────────────────
def test_fascinated_combatant_turn_is_noop() -> None:
eng = _engine(
[_combatant("a"), _combatant("b")],
[(0, 0), (0, 1)],
["players", "monsters"],
[],
)
a, _b = eng._states
_add_condition(a, "fascinated")
eng._take_turn(a)
# An empty RNG queue was not consumed: the turn made no attack roll.
assert any("a: wait (cannot act)" in line for line in eng._transcript)
assert sum("attack" in line for line in eng._transcript) == 0
def test_helpless_combatant_turn_is_noop() -> None:
eng = _engine(
[_combatant("a"), _combatant("b")],
[(0, 0), (0, 1)],
["players", "monsters"],
[],
)
a, _b = eng._states
_add_condition(a, "helpless")
eng._take_turn(a)
assert any("a: wait (cannot act)" in line for line in eng._transcript)
assert sum("attack" in line for line in eng._transcript) == 0
# ── Rule E: attacks against an invisible target have a 50% miss chance ───────
def test_attack_vs_invisible_target_misses_on_low_d100() -> None:
eng = _engine(
[_combatant("atk"), _combatant("tgt")],
[(0, 0), (0, 1)],
["players", "monsters"],
[15, 30],
)
a, t = eng._states
_add_condition(t, "invisible")
result = eng.resolve_attack(a, t, a.combatant.attacks[0])
# Hit (15 + 5 = 20 >= 10) but d100 30 <= 50 total concealment -> miss.
assert result.hit is True
assert result.damage == 0
assert t.hp == 100
assert any(
"concealment vs tgt" in line and "50%" in line and "misses" in line
for line in eng._transcript
)
def test_attack_vs_invisible_target_lands_on_high_d100() -> None:
eng = _engine(
[_combatant("atk"), _combatant("tgt")],
[(0, 0), (0, 1)],
["players", "monsters"],
[15, 51, 4],
)
a, t = eng._states
_add_condition(t, "invisible")
result = eng.resolve_attack(a, t, a.combatant.attacks[0])
assert result.hit is True
assert result.damage == 4
assert t.hp == 96
# ── Rule F: registration + snapshot exposure ─────────────────────────────────
def test_new_conditions_registered() -> None:
assert CONDITIONS_BY_NAME["helpless"] is HELPLESS
assert CONDITIONS_BY_NAME["fascinated"] is FASCINATED
assert CONDITIONS_BY_NAME["invisible"] is INVISIBLE
assert HELPLESS.cant_act is True
assert HELPLESS.denies_dex_to_ac is True
assert FASCINATED.cant_act is True
assert INVISIBLE.miss_chance == 50
def test_snapshot_exposes_new_condition_definitions() -> None:
eng = _engine(
[_combatant("a"), _combatant("b")],
[(0, 0), (0, 3)],
["players", "monsters"],
[],
)
obs = _snapshot(eng, eng._states[0])
defs = dict(obs["condition_definitions"])
assert len(defs) == len(CONDITIONS_BY_NAME)
assert defs["helpless"]["flags"] == ["denies_dex_to_ac", "cant_act"]
assert defs["fascinated"]["flags"] == ["cant_act"]
assert defs["invisible"]["miss_chance"] == 50