300 lines
9.5 KiB
Python
300 lines
9.5 KiB
Python
"""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 minus
|
|
# the Dex-0 penalty (helpless target has effective Dexterity 0: -5).
|
|
assert result.total == 19
|
|
assert result.ac == 5
|
|
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 >= 5 (flat-footed 10 - 5 Dex-0) -> 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
|