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
+31 -8
View File
@@ -592,12 +592,29 @@ Règles modélisées :
chemin de la RD. La résistance énergétique s'applique au dégât final (même étape chemin de la RD. La résistance énergétique s'applique au dégât final (même étape
que la RD), y compris aux dégâts de sorts et aux composantes énergétiques des que la RD), y compris aux dégâts de sorts et aux composantes énergétiques des
armes. armes.
- Concealment (chance de rater) : les conditions `blurred` (20 %, sort flou) et - Concealment (chance de rater) : les conditions `blurred` (20 %, sort flou),
`displaced` (50 %, concealment total) portent un champ `miss_chance` ; quand la `displaced` (50 %, concealment total) et `invisible` (50 %, concealment total)
portent un champ `miss_chance` ; quand la
cible en porte une, après la touche et la confirmation de critique, le moteur cible en porte une, après la touche et la confirmation de critique, le moteur
tire un d100 : si `d100 ≤ miss_chance`, l'attaque rate complètement (aucun tire un d100 : si `d100 ≤ miss_chance`, l'attaque rate complètement (aucun
dégât, pas de critique, deed non consommé — journalisé « concealment »). Une dégât, pas de critique, deed non consommé — journalisé « concealment »). Une
cible avec concealment ne peut pas être la victime d'une attaque sournoise. cible avec concealment ne peut pas être la victime d'une attaque sournoise.
Attaquant aveugle (V5a) : un attaquant portant `blinded` considère tous ses
adversaires comme ayant un concealment total — le même jet unique de d100 est
tiré avec `miss_chance = max(concealment de la cible, 50)` et journalisé
« attacker blinded » ; l'attaque peut donc rater même contre une cible sans
concealment.
- Attaquant invisible (V5a) : un attaquant portant `invisible` gagne **+2 à ses
jets d'attaque et de confirmation de critique** et sa cible **perd son bonus
de Dextérité à la CA** (même chemin `denies_dex_to_ac` que flat-footed/course).
L'invisibilité est symétrique : subi comme cible, la condition accorde le
concealment total de 50 % ci-dessus.
- Cible inerme (helpless, V5a) : les attaques de **mêlée** contre une cible
`helpless` gagnent **+4 au jet d'attaque et à la confirmation** (aucun bonus à
distance) ; `helpless` porte `denies_dex_to_ac` (CA flat-footed) + `cant_act`.
Simplification documentée : l'attaque sournoise automatique du roublard sur
cible inerme n'est pas modélisée (la cible, privée de Dextérité, la déclenche
déjà via le chemin `denies_dex_to_ac`).
- Attaque sournoise : un combattant avec la capacité « Sneak Attack » inflige des - Attaque sournoise : un combattant avec la capacité « Sneak Attack » inflige des
d6 de dégâts de précision ((niveau + 1) // 2 dés : 1d6 au niveau 1, 2d6 au d6 de dégâts de précision ((niveau + 1) // 2 dés : 1d6 au niveau 1, 2d6 au
niveau 3…) si la cible est flanquée (attaque de mêlée uniquement) ou privée de niveau 3…) si la cible est flanquée (attaque de mêlée uniquement) ou privée de
@@ -619,15 +636,20 @@ Règles modélisées :
- Invocation : les créatures invoquées agissent (calées sur la case d'initiative de - Invocation : les créatures invoquées agissent (calées sur la case d'initiative de
leur conjurateur via `_build_round_order`) puis expirent après leur `summon_duration` leur conjurateur via `_build_round_order`) puis expirent après leur `summon_duration`
(`_expire_summons` décrémente puis retire). (`_expire_summons` décrémente puis retire).
- Conditions effectives : 23 conditions prédéfinies (`conditions.py`). Leurs drapeaux - Conditions effectives : 26 conditions prédéfinies (`conditions.py`). Leurs drapeaux
comportementaux sont appliqués par le moteur : `cant_act` (étourdi, paralysé, dazed, comportementaux sont appliqués par le moteur : `cant_act` (étourdi, paralysé, dazed,
endormi) annule le tour ; `cant_attack` (nauséeux) n'autorise qu'une unique action endormi, inerme, fasciné) annule le tour ; `cant_attack` (nauséeux) n'autorise qu'une unique action
simple et bloque toute attaque / manœuvre ; `staggered` restreint à une action simple et bloque toute attaque / manœuvre ; `staggered` restreint à une action
simple (pas d'attaque à outrance) ; `cant_move` bloque le déplacement ; simple (pas d'attaque à outrance) ; `cant_move` bloque le déplacement ;
`denies_dex_to_ac` (à plat ventre, paralysé…) fait perdre le bonus de Dextérité à `denies_dex_to_ac` (à plat ventre, paralysé…) fait perdre le bonus de Dextérité à
la CA (CA flat-footed) ; `miss_chance` (flou 20 %, déplacement 50 %) impose la la CA (CA flat-footed) ; `miss_chance` (flou 20 %, déplacement 50 %, invisible
50 %) impose la
chance de rater du concealment. À plat ventre : −4 CA et −4 aux attaques à distance. chance de rater du concealment. À plat ventre : −4 CA et −4 aux attaques à distance.
Agrippé : −4 Dextérité, −2 attaque/CA/Réflexes, sans déplacement ni AoO. Agrippé : −4 Dextérité, −2 attaque/CA/Réflexes, sans déplacement ni AoO.
Inerme (`helpless`) : ne peut pas agir, perd sa Dextérité à la CA, +4 en mêlée
contre lui (voir « Cible inerme »). Fasciné (`fascinated`) : ne peut pas agir.
Invisible (`invisible`) : concealment total subi +2 attaquant / perte de Dex
de la cible (voir « Attaquant invisible »).
- Manœuvres de combat : trip, disarm, bull_rush, sunder, grapple, escape, - Manœuvres de combat : trip, disarm, bull_rush, sunder, grapple, escape,
overrun, dirty trick, steal, reposition et drag sont modélisées, chacune overrun, dirty trick, steal, reposition et drag sont modélisées, chacune
comme une tentative unique (1d20 + CMB contre CMD) : comme une tentative unique (1d20 + CMB contre CMD) :
@@ -759,7 +781,7 @@ Règles modélisées :
case de leur conjurateur), case de leur conjurateur),
terrain_semantics (un dict par type de terrain : type, move_cost, terrain_semantics (un dict par type de terrain : type, move_cost,
blocks_los, cover, cover_bonus), condition_definitions (C2 : tuple trié de (nom, blocks_los, cover, cover_bonus), condition_definitions (C2 : tuple trié de (nom,
{mods, flags, speed_mult, miss_chance}) — définitions complètes des 23 {mods, flags, speed_mult, miss_chance}) — définitions complètes des 26
conditions de CONDITIONS_BY_NAME pour que la LLM interprète les drapeaux, conditions de CONDITIONS_BY_NAME pour que la LLM interprète les drapeaux,
les modificateurs et les chances de rater (concealment)). les modificateurs et les chances de rater (concealment)).
Les helpers publics du moteur (L5) sont désormais embarqués EN DONNÉES dans Les helpers publics du moteur (L5) sont désormais embarqués EN DONNÉES dans
@@ -819,10 +841,11 @@ la lecture mais non appliquée dans la résolution.
enhancement…) gardent la valeur la plus élevée. Les pénalités suivent les enhancement…) gardent la valeur la plus élevée. Les pénalités suivent les
mêmes règles. Fondation pour sorts, conditions, dons et capacités de classe. mêmes règles. Fondation pour sorts, conditions, dons et capacités de classe.
- `conditions.py` — conditions PF1e : `Condition` (dataclass avec - `conditions.py` — conditions PF1e : `Condition` (dataclass avec
modificateurs + flags comportementaux + `miss_chance`) et 23 conditions prédéfinies modificateurs + flags comportementaux + `miss_chance`) et 26 conditions prédéfinies
(shaken, sickened, fatigued, exhausted, entangled, dazzled, blinded, (shaken, sickened, fatigued, exhausted, entangled, dazzled, blinded,
deafened, flat-footed, prone, stunned, paralyzed, nauseated, dazed, deafened, flat-footed, prone, stunned, paralyzed, nauseated, dazed,
staggered, frightened, panicked, cowering). Les modificateurs sont intégrés staggered, frightened, panicked, cowering, helpless, fascinated, invisible).
Les modificateurs sont intégrés
à `resolve_attack` (attaque, dégâts, CA) et `resolve_save` (jets de à `resolve_attack` (attaque, dégâts, CA) et `resolve_save` (jets de
sauvegarde) via `_stat_modifiers` qui collecte effets + conditions ; les sauvegarde) via `_stat_modifiers` qui collecte effets + conditions ; les
drapeaux comportementaux (`cant_act`, `cant_attack`, `cant_move`, staggered, drapeaux comportementaux (`cant_act`, `cant_attack`, `cant_move`, staggered,
+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 _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_AC_BONUS = 2 # PF1e: fighting defensively grants +2 dodge AC
_FIGHT_DEFENSIVELY_ATTACK_PENALTY = -4 # PF1e: and -4 on attacks / crit confirms _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_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) _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 _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"): if self._is_ranged_attack(weapon) and self._has_condition_named(attacker, "prone"):
atk_mods.append(StatModifier(target="attack", value=-4)) 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) attack_mod = resolve_modifiers(atk_mods)
pa_amt = self._power_attack_amt(attacker) if weapon.kind == "melee" else 0 pa_amt = self._power_attack_amt(attacker) if weapon.kind == "melee" else 0
fd_penalty = ( fd_penalty = (
@@ -1816,8 +1826,13 @@ class CombatEngine:
+ fim_penalty + fd_penalty + attacker.aid_attack_bonus + fim_penalty + fd_penalty + attacker.aid_attack_bonus
) )
# Check if target denies DEX to AC (flat-footed); a running target # Check if target denies DEX to AC (flat-footed); a running target
# loses its DEX bonus too (U5e), reusing the same AC path. # loses its DEX bonus too (U5e), reusing the same AC path. An
denies_dex = self.has_condition_flag(target, "denies_dex_to_ac") or target.running # 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 = ( full_ac_base = (
target.combatant.ac.flat_footed target.combatant.ac.flat_footed
if denies_dex if denies_dex
@@ -1918,13 +1933,20 @@ class CombatEngine:
) )
crit = confirm != _NATURAL_ONE and confirm_total >= ac crit = confirm != _NATURAL_ONE and confirm_total >= ac
concealment = self._concealment_chance(target) 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() pct = self._rng.d100()
missed = pct <= concealment missed = pct <= miss_chance
source = "attacker blinded" if attacker_blinded else "concealment"
self._log( self._log(
f"round {self._current_round} {attacker.combatant.id}: " f"round {self._current_round} {attacker.combatant.id}: "
f"concealment vs {target.combatant.id}: " f"{source} vs {target.combatant.id}: "
f"d100({pct}) {'<=' if missed else '>'} {concealment}% " f"d100({pct}) {'<=' if missed else '>'} {miss_chance}% "
f"— attack {'misses' if missed else 'lands'}" f"— attack {'misses' if missed else 'lands'}"
) )
if missed: 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: -4 Dex, -2 attack, no movement, no AoO, can't cast without concentration (CRB)
GRAPPLED = Condition( GRAPPLED = Condition(
name="grappled", name="grappled",
@@ -267,6 +281,13 @@ DISPLACED = Condition(
miss_chance=50, 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 ───────────────────────────────────────────────────────────────── # ── Registry ─────────────────────────────────────────────────────────────────
CONDITIONS_BY_NAME: dict[str, Condition] = { CONDITIONS_BY_NAME: dict[str, Condition] = {
c.name: c c.name: c
@@ -275,5 +296,6 @@ CONDITIONS_BY_NAME: dict[str, Condition] = {
BLINDED, DEAFENED, FLAT_FOOTED, PRONE, STUNNED, PARALYZED, BLINDED, DEAFENED, FLAT_FOOTED, PRONE, STUNNED, PARALYZED,
NAUSEATED, DAZED, STAGGERED, FRIGHTENED, PANICKED, COWERING, NAUSEATED, DAZED, STAGGERED, FRIGHTENED, PANICKED, COWERING,
ASLEEP, GRAPPLED, PINNED, BLURRED, DISPLACED, ASLEEP, GRAPPLED, PINNED, BLURRED, DISPLACED,
HELPLESS, FASCINATED, INVISIBLE,
] ]
} }
+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