From b3bff8521508accf1c8811692e16a0e4e0aa7988 Mon Sep 17 00:00:00 2001 From: ctan Date: Thu, 10 Sep 2026 18:00:41 +0200 Subject: [PATCH] feat(conditions): add blinded/invisible/helpless/fascinated combat rules --- README.md | 39 ++- src/pf1e_simulator/combat.py | 34 ++- src/pf1e_simulator/conditions.py | 22 ++ tests/test_conditions_blinded_invisible.py | 298 +++++++++++++++++++++ 4 files changed, 379 insertions(+), 14 deletions(-) create mode 100644 tests/test_conditions_blinded_invisible.py diff --git a/README.md b/README.md index 225dc7f..411e029 100644 --- a/README.md +++ b/README.md @@ -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, diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index 6064265..2a4e964 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -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: diff --git a/src/pf1e_simulator/conditions.py b/src/pf1e_simulator/conditions.py index 858bcca..f92c165 100644 --- a/src/pf1e_simulator/conditions.py +++ b/src/pf1e_simulator/conditions.py @@ -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, ] } diff --git a/tests/test_conditions_blinded_invisible.py b/tests/test_conditions_blinded_invisible.py new file mode 100644 index 0000000..f9a0543 --- /dev/null +++ b/tests/test_conditions_blinded_invisible.py @@ -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