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
|
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,
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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,
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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