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
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,
+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
_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:
+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 = 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,
]
}
+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