feat(conditions): add blinded/invisible/helpless/fascinated combat rules
This commit is contained in:
@@ -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
|
||||
que la RD), y compris aux dégâts de sorts et aux composantes énergétiques des
|
||||
armes.
|
||||
- Concealment (chance de rater) : les conditions `blurred` (20 %, sort flou) et
|
||||
`displaced` (50 %, concealment total) portent un champ `miss_chance` ; quand la
|
||||
- Concealment (chance de rater) : les conditions `blurred` (20 %, sort flou),
|
||||
`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
|
||||
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
|
||||
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
|
||||
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
|
||||
@@ -619,15 +636,20 @@ Règles modélisées :
|
||||
- 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`
|
||||
(`_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,
|
||||
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 (pas d'attaque à outrance) ; `cant_move` bloque le déplacement ;
|
||||
`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.
|
||||
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,
|
||||
overrun, dirty trick, steal, reposition et drag sont modélisées, chacune
|
||||
comme une tentative unique (1d20 + CMB contre CMD) :
|
||||
@@ -759,7 +781,7 @@ Règles modélisées :
|
||||
case de leur conjurateur),
|
||||
terrain_semantics (un dict par type de terrain : type, move_cost,
|
||||
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,
|
||||
les modificateurs et les chances de rater (concealment)).
|
||||
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
|
||||
mêmes règles. Fondation pour sorts, conditions, dons et capacités de classe.
|
||||
- `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,
|
||||
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
|
||||
sauvegarde) via `_stat_modifiers` qui collecte effets + conditions ; les
|
||||
drapeaux comportementaux (`cant_act`, `cant_attack`, `cant_move`, staggered,
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user