feat(combat): add dying stabilization and Improved Critical

This commit is contained in:
2026-09-10 10:43:02 +02:00
parent 97f4429c2b
commit 2eed752f7f
4 changed files with 395 additions and 3 deletions
+20 -2
View File
@@ -267,6 +267,22 @@ Règles modélisées :
- 1 naturel = échec automatique ; 20 naturel = touche + menace de critique. - 1 naturel = échec automatique ; 20 naturel = touche + menace de critique.
- Critique : (dés + bonus) × multiplicateur ; la RD s'applique après - Critique : (dés + bonus) × multiplicateur ; la RD s'applique après
multiplication, les types perforants annulent la RD, les dégâts plancher à 0. multiplication, les types perforants annulent la RD, les dégâts plancher à 0.
- Critique améliorée (« Improved Critical ») : le don double la plage de
menace — plage effective = `21 - 2 × (21 - crit_range)` (20 → 19-20,
19 → 17-20, 18 → 15-20). Il n'affecte que le jet de menace, jamais le jet de
confirmation. Simplification moteur : le don s'applique à toutes les armes
de l'attaquant (pas de sélection par type d'arme comme sur table).
- Mourir et stabilisation : une créature à `hp < 0` mais pas morte est
mourante — inconsciente (inactive), elle perd 1 pv par tour si elle échoue
son jet de Constitution DD 10 à son tour d'initiative : `d20 + mod de CON −
|hp|` contre 10 ; un 20 naturel réussit automatiquement. En cas d'échec, elle
perd 1 pv de plus ; en cas de réussite, elle est stabilisée (plus de perte de
pv, reste inconsciente). Une nouvelle blessure sous le seuil de stabilisation
(`hp < stabilized_hp`) fait reprendre l'agonie (nouveau jet) ; un soin
ramenant à `hp ≥ 0` réinitialise la stabilisation pour toute chute future.
La mort survient au seuil existant `hp < min(-10, -CON)` — divergence RAW
documentée : la règle du jeu meurt quand les pv négatifs atteignent le score
de CON (le moteur est donc d'un pv plus permissif) ; sémantique inchangée.
- Mort quand `hp < min(-10, -CON)` ; `hp ≤ 0` = inactif. - Mort quand `hp < min(-10, -CON)` ; `hp ≤ 0` = inactif.
- Initiative : triée sur (total, modificateur, ordre de liste), sans re-jet. - Initiative : triée sur (total, modificateur, ordre de liste), sans re-jet.
- Économie d'action : un tour normal donne une action standard + une action de - Économie d'action : un tour normal donne une action standard + une action de
@@ -502,7 +518,7 @@ Règles modélisées :
se branche via `CombatEngine(..., llm_policy=...)` (`LLMPolicy(observation, se branche via `CombatEngine(..., llm_policy=...)` (`LLMPolicy(observation,
state) -> tuple[Action, ...]`). Schéma enrichi (E1-E5) : state) -> tuple[Action, ...]`). Schéma enrichi (E1-E5) :
- Par combattant : id, side, pos, occupied_cells, hp/hp_max, active/dead, - Par combattant : id, side, pos, occupied_cells, hp/hp_max, active/dead,
conditions (noms + rounds_left), resources_left (+ `spell_slot_{level}`), dying/stabilized, conditions (noms + rounds_left), resources_left (+ `spell_slot_{level}`),
weapon_broken/weapon_stolen, grappled_with, moved_this_turn, power_attack, weapon_broken/weapon_stolen, grappled_with, moved_this_turn, power_attack,
name, level, size, dr {amount, bypass} ou None (C1), creature_type (C3), name, level, size, dr {amount, bypass} ou None (C1), creature_type (C3),
bab (E1), initiative_mod (E2), casting_ability ou None pour non-jeteur bab (E1), initiative_mod (E2), casting_ability ou None pour non-jeteur
@@ -524,7 +540,9 @@ Règles modélisées :
(tuple triée des types immunisés), sneak_dice (dés de précision disponibles : (tuple triée des types immunisés), sneak_dice (dés de précision disponibles :
(niveau + 1) // 2 avec « Sneak Attack », sinon 0), engaged_in_melee (bool — (niveau + 1) // 2 avec « Sneak Attack », sinon 0), engaged_in_melee (bool —
une cible engagée dans une mêlée, signal pour le tir dans la mêlée et une cible engagée dans une mêlée, signal pour le tir dans la mêlée et
l'attaque sournoise). l'attaque sournoise), dying (bool — `hp < 0` et pas morte : agonisante,
inconsciente), stabilized (bool — stabilisée pendant son agonie, ne perd
plus de pv tant qu'aucune nouvelle blessure ne la remet en agonie).
- Par paire combattant actif × cible : target_index, distance_ft, weapon_for, - Par paire combattant actif × cible : target_index, distance_ft, weapon_for,
range_penalty, can_charge, can_step5_to_attack, has_line_of_effect, range_penalty, can_charge, can_step5_to_attack, has_line_of_effect,
flanking, movement_cost_ft, target_threatens_me, enemies_threatening_me. flanking, movement_cost_ft, target_threatens_me, enemies_threatening_me.
+55 -1
View File
@@ -143,6 +143,9 @@ _FIRING_MELEE_REDUCED_PENALTY = -2 # PF1e: -2 when the melee is 10+ ft away
_FIRING_MELEE_SAFE_FT = 10 # PF1e: 2 cells (10 ft) from attacker = reduced penalty _FIRING_MELEE_SAFE_FT = 10 # PF1e: 2 cells (10 ft) from attacker = reduced penalty
_SNEAK_ATTACK_FEATURE = "Sneak Attack" # PF1e: rogue class feature (precision dice) _SNEAK_ATTACK_FEATURE = "Sneak Attack" # PF1e: rogue class feature (precision dice)
_PRECISE_SHOT_FEATURE = "Precise Shot" # PF1e: feat negating firing-into-melee penalty _PRECISE_SHOT_FEATURE = "Precise Shot" # PF1e: feat negating firing-into-melee penalty
_IMPROVED_CRITICAL_FEATURE = "Improved Critical" # PF1e: feat doubling the threat range
_CRIT_RANGE_BASE = 21 # threat range top is 21 - min natural die (20 -> 20-20)
_DYING_DC = 10 # PF1e: DC of the per-round stabilization Constitution check
_SAVE_DESCRIPTOR_CONDITIONS = frozenset({ _SAVE_DESCRIPTOR_CONDITIONS = frozenset({
"fear", "cold", "electricity", "fey", "divination", "poison", "disease", "fear", "cold", "electricity", "fey", "divination", "poison", "disease",
"mind_affecting", "mind_affecting",
@@ -335,6 +338,9 @@ class CombatantState:
# Runtime weapon theft (not in the frozen data model): weapon ids taken by # Runtime weapon theft (not in the frozen data model): weapon ids taken by
# steal maneuvers. # steal maneuvers.
weapon_stolen: set[str] = field(default_factory=set) weapon_stolen: set[str] = field(default_factory=set)
# Dying & stabilization (not in the frozen data model): the hp at which
# this combatant stabilized while dying; None = not stabilized.
stabilized_hp: int | None = None
def usable_attacks(self) -> list[AttackSpec]: def usable_attacks(self) -> list[AttackSpec]:
return [ return [
@@ -741,6 +747,8 @@ class CombatEngine:
"hp_max": state.combatant.hp_max, "hp_max": state.combatant.hp_max,
"active": state.active, "active": state.active,
"dead": state.dead, "dead": state.dead,
"dying": state.hp < 0 and not state.dead,
"stabilized": state.stabilized_hp is not None,
"conditions": conditions, "conditions": conditions,
"resources_left": resources, "resources_left": resources,
"weapon_broken": tuple(sorted(state.weapon_broken)), "weapon_broken": tuple(sorted(state.weapon_broken)),
@@ -1024,6 +1032,7 @@ class CombatEngine:
order = self._build_round_order() order = self._build_round_order()
for state in order: for state in order:
if not state.active: if not state.active:
self._process_dying(state)
continue continue
if self._take_turn(state): if self._take_turn(state):
return True return True
@@ -1044,6 +1053,45 @@ class CombatEngine:
return True return True
return False return False
def _process_dying(self, state: CombatantState) -> None:
"""Advance the dying condition on the creature's own initiative slot.
PF1e dying: a combatant below 0 hp but not dead is unconscious and, on
its turn each round, makes a DC 10 Constitution check
(d20 + Con modifier - negative hp total); a natural 20 auto-succeeds.
Failure bleeds 1 more hp; success stabilizes (no further loss while
stable). New damage below the stabilizing hp resumes dying; healing to
hp >= 0 resets stabilization for any future downing.
"""
if state.dead:
return
if state.hp >= 0:
state.stabilized_hp = None
return
if state.stabilized_hp is not None:
if state.hp >= state.stabilized_hp:
return
state.stabilized_hp = None
con_mod = (state.combatant.abilities.con_score - 10) // 2
penalty = -state.hp
roll = self._rng.d20()
total = roll + con_mod - penalty
check = f"d20({roll}) + CON({con_mod}) - {penalty} = {total} vs DC {_DYING_DC}"
if roll == _NATURAL_TWENTY or total >= _DYING_DC:
state.stabilized_hp = state.hp
nat20 = " — natural 20" if roll == _NATURAL_TWENTY else ""
self._log(
f"round {self._current_round} {state.combatant.id}: "
f"stabilized ({check}{nat20})"
)
else:
hp_before = state.hp
state.hp -= 1
self._log(
f"round {self._current_round} {state.combatant.id}: dying — Constitution "
f"check {check} failed, loses 1 hp (hp {hp_before} -> {state.hp})"
)
def _blocked(self, *exclude: CombatantState) -> frozenset[Pos]: def _blocked(self, *exclude: CombatantState) -> frozenset[Pos]:
"""Union of occupied cells of all active states except those in ``exclude``.""" """Union of occupied cells of all active states except those in ``exclude``."""
return frozenset( return frozenset(
@@ -1423,9 +1471,15 @@ class CombatEngine:
Returns ``(crit, concealment_miss, damage)``. Sneak precision dice are Returns ``(crit, concealment_miss, damage)``. Sneak precision dice are
added after the crit multiplier and before DR / energy resistance. added after the crit multiplier and before DR / energy resistance.
Improved Critical doubles the threat range of every weapon (engine
simplification: no per-weapon-type feat selection); it never affects
the confirmation roll.
""" """
crit = False crit = False
if roll != _NATURAL_ONE and roll >= weapon.crit_range: threat_range = weapon.crit_range
if self._has_feat(attacker, _IMPROVED_CRITICAL_FEATURE):
threat_range = _CRIT_RANGE_BASE - 2 * (_CRIT_RANGE_BASE - weapon.crit_range)
if roll != _NATURAL_ONE and roll >= threat_range:
confirm = self._rng.d20() confirm = self._rng.d20()
confirm_total = ( confirm_total = (
confirm + base_bonus + penalty + flank + high_ground confirm + base_bonus + penalty + flank + high_ground
+17
View File
@@ -351,6 +351,8 @@ def test_scripted_2v2_transcript() -> None:
14, 1, # R1 gob-2 hits orc-2 for 1 14, 1, # R1 gob-2 hits orc-2 for 1
17, 2, 3, # R1 orc-2 hits gob-2 for 2+3+4=9 17, 2, 3, # R1 orc-2 hits gob-2 for 2+3+4=9
19, 11, 2, # R2 gob-1 crits orc-1: 19 threatens, 11 confirms, 2*2=4 19, 11, 2, # R2 gob-1 crits orc-1: 19 threatens, 11 confirms, 2*2=4
14, # R2 orc-1 dying check: 14+1-1=14 >= 10 -> stabilized
5, # R2 gob-2 dying check: 5+1-3=3 < 10 -> fails, bleeds to -4
15, 1, 4, # R2 orc-2 hits gob-1 for 1+4+4=9 15, 1, 4, # R2 orc-2 hits gob-1 for 1+4+4=9
] ]
engine = make_engine(queue, states) engine = make_engine(queue, states)
@@ -369,6 +371,11 @@ def test_scripted_2v2_transcript() -> None:
"round 1 orc-2: gob-2 down", "round 1 orc-2: gob-2 down",
"round 2 gob-1: short sword vs orc-1 d20=19+2=21 AC 13 -> CRIT 4 damage (3->-1)", "round 2 gob-1: short sword vs orc-1 d20=19+2=21 AC 13 -> CRIT 4 damage (3->-1)",
"round 2 gob-1: orc-1 down", "round 2 gob-1: orc-1 down",
"round 2 orc-1: stabilized (d20(14) + CON(1) - 1 = 14 vs DC 10)",
(
"round 2 gob-2: dying — Constitution check d20(5) + CON(1) - 3 = 3 vs DC 10 "
"failed, loses 1 hp (hp -3 -> -4)"
),
"round 2 orc-2: charge (3,4)->(2,4)->(1,3)", "round 2 orc-2: charge (3,4)->(2,4)->(1,3)",
"round 2 orc-2: charge vs gob-1 d20=15+7=22 AC 16 -> HIT 9 damage (6->-3)", "round 2 orc-2: charge vs gob-1 d20=15+7=22 AC 16 -> HIT 9 damage (6->-3)",
"round 2 orc-2: gob-1 down", "round 2 orc-2: gob-1 down",
@@ -447,8 +454,10 @@ def test_nearest_enemy_targeting_and_full_rounds() -> None:
7, # R1 orc-1 misses 7, # R1 orc-1 misses
8, # R1 orc-2 misses 8, # R1 orc-2 misses
12, 6, # R2 gob downs orc-1 12, 6, # R2 gob downs orc-1
3, # R2 orc-1 dying check: 3+1-4=0 < 10 -> fails, bleeds to -5
9, # R2 orc-2 misses 9, # R2 orc-2 misses
11, 3, # R3 gob hits orc-2 for 3 11, 3, # R3 gob hits orc-2 for 3
4, # R3 orc-1 dying check: 4+1-5=0 < 10 -> fails, bleeds to -6
12, 4, # R3 orc-2 hits gob for 4 12, 4, # R3 orc-2 hits gob for 4
12, 6, # R4 gob downs orc-2 12, 6, # R4 gob downs orc-2
] ]
@@ -465,8 +474,16 @@ def test_nearest_enemy_targeting_and_full_rounds() -> None:
"round 1 orc-2: short sword vs gob d20=8+2=10 AC 13 -> MISS", "round 1 orc-2: short sword vs gob d20=8+2=10 AC 13 -> MISS",
"round 2 gob: short sword vs orc-1 d20=12+2=14 AC 13 -> HIT 6 damage (2->-4)", "round 2 gob: short sword vs orc-1 d20=12+2=14 AC 13 -> HIT 6 damage (2->-4)",
"round 2 gob: orc-1 down", "round 2 gob: orc-1 down",
(
"round 2 orc-1: dying — Constitution check d20(3) + CON(1) - 4 = 0 vs DC 10 "
"failed, loses 1 hp (hp -4 -> -5)"
),
"round 2 orc-2: short sword vs gob d20=9+2=11 AC 13 -> MISS", "round 2 orc-2: short sword vs gob d20=9+2=11 AC 13 -> MISS",
"round 3 gob: short sword vs orc-2 d20=11+2=13 AC 13 -> HIT 3 damage (6->3)", "round 3 gob: short sword vs orc-2 d20=11+2=13 AC 13 -> HIT 3 damage (6->3)",
(
"round 3 orc-1: dying — Constitution check d20(4) + CON(1) - 5 = 0 vs DC 10 "
"failed, loses 1 hp (hp -5 -> -6)"
),
"round 3 orc-2: short sword vs gob d20=12+2=14 AC 13 -> HIT 4 damage (6->2)", "round 3 orc-2: short sword vs gob d20=12+2=14 AC 13 -> HIT 4 damage (6->2)",
"round 4 gob: short sword vs orc-2 d20=12+2=14 AC 13 -> HIT 6 damage (3->-3)", "round 4 gob: short sword vs orc-2 d20=12+2=14 AC 13 -> HIT 6 damage (3->-3)",
"round 4 gob: orc-2 down", "round 4 gob: orc-2 down",
+303
View File
@@ -0,0 +1,303 @@
"""Dying & stabilization, and the Improved Critical feat (Package P3)."""
from __future__ import annotations
from pf1e_simulator.abilities import AbilitySpec
from pf1e_simulator.combat import CombatantState, CombatEngine
from pf1e_simulator.dice import parse_dice
from pf1e_simulator.models import (
AbilityScores,
ACProfile,
AttackSpec,
Combatant,
DamageComponent,
Saves,
)
from pf1e_simulator.rng import ScriptedRng
from tests.test_observation_enriched import _snapshot, make_grid, make_state
def _melee(
name: str,
attack_bonus: int = 5,
*,
crit_range: int = 20,
crit_mult: int = 2,
) -> AttackSpec:
return AttackSpec(
id=f"{name}-sword",
name="short sword",
kind="melee",
attack_bonus=attack_bonus,
damage=[DamageComponent(formula=parse_dice("1d6"), types=["slashing"])],
crit_range=crit_range,
crit_mult=crit_mult,
)
def _combatant(
id_: str,
*,
con_score: int = 10,
hp_max: int = 100,
ac: int = 10,
attacks: tuple[AttackSpec, ...] = (),
features: tuple[str, ...] = (),
) -> Combatant:
return Combatant(
id=id_,
name=id_,
level=1,
abilities=AbilityScores(
str_score=10, dex_score=10, con_score=con_score,
int_score=10, wis_score=10, cha_score=10,
),
ac=ACProfile(total=ac, touch=ac, flat_footed=ac),
bab=1,
initiative_mod=0,
speed_land_ft=30,
hp_max=hp_max,
saves=Saves(fort=0, ref=0, will=0),
attacks=list(attacks) or [_melee(id_)],
features=[AbilitySpec(name=f, category="feat") for f in features],
)
def _engine(
combatants: list[Combatant],
positions: list[tuple[int, int]],
sides: list[str],
rng: list[int],
) -> CombatEngine:
states = [
make_state(c, side, pos)
for c, side, pos in zip(combatants, sides, positions, strict=True)
]
return CombatEngine(ScriptedRng(rng), make_grid(), states)
def _solo(con_score: int = 10) -> tuple[CombatEngine, CombatantState]:
"""Engine with one combatant; the state is returned for direct manipulation."""
eng = _engine([_combatant("subj", con_score=con_score)], [(0, 0)], ["players"], [])
return eng, eng._states[0]
# ── Rule A: dying bleed, stabilization, natural 20, resume, reset, death ─────
def test_dying_failed_con_check_bleeds_one_hp() -> None:
eng, s = _solo(con_score=8) # CON modifier -1
s.hp = -3
eng._current_round = 2
eng._rng = ScriptedRng([7]) # 7 + (-1) - 3 = 3 < 10 -> fail
eng._process_dying(s)
assert s.hp == -4
assert s.stabilized_hp is None
line = eng._transcript[-1]
assert "dying — Constitution check d20(7) + CON(-1) - 3 = 3 vs DC 10 failed" in line
assert "loses 1 hp (hp -3 -> -4)" in line
def test_dying_passed_con_check_stabilizes_and_stops_bleeding() -> None:
eng, s = _solo(con_score=8)
s.hp = -3
eng._rng = ScriptedRng([14]) # 14 + (-1) - 3 = 10 >= 10 -> stabilize
eng._process_dying(s)
assert s.hp == -3
assert s.stabilized_hp == -3
assert "stabilized" in eng._transcript[-1]
# Later rounds: no roll consumed, no further hp loss while stable.
eng._rng = ScriptedRng([])
eng._process_dying(s)
eng._process_dying(s)
assert s.hp == -3
assert s.stabilized_hp == -3
def test_dying_natural_twenty_auto_stabilizes() -> None:
eng, s = _solo(con_score=26) # CON modifier +8, death threshold -26
s.hp = -20
eng._rng = ScriptedRng([20]) # 20 + 8 - 20 = 8 < 10 — only nat 20 saves
eng._process_dying(s)
assert s.hp == -20
assert s.stabilized_hp == -20
assert "stabilized" in eng._transcript[-1]
assert "natural 20" in eng._transcript[-1]
def test_stabilized_new_damage_resumes_dying() -> None:
eng, s = _solo(con_score=8)
s.hp = -3
eng._rng = ScriptedRng([14]) # stabilizes at -3
eng._process_dying(s)
assert s.stabilized_hp == -3
s.hp = -5 # new damage below the stabilizing hp
eng._rng = ScriptedRng([2]) # 2 + (-1) - 5 = -4 < 10 -> fail, bleeds again
eng._process_dying(s)
assert s.hp == -6
assert s.stabilized_hp is None
assert "dying — Constitution check" in eng._transcript[-1]
def test_stabilized_then_healed_resets_stabilization() -> None:
eng, s = _solo(con_score=8)
s.hp = -3
eng._rng = ScriptedRng([14]) # stabilizes at -3
eng._process_dying(s)
assert s.stabilized_hp == -3
s.hp = 5 # healed back to consciousness
eng._rng = ScriptedRng([]) # reset must not consume a roll
eng._process_dying(s)
assert s.stabilized_hp is None
s.hp = -2 # downed again — dying from scratch, bleeds on a failed check
eng._rng = ScriptedRng([3]) # 3 + (-1) - 2 = 0 < 10 -> fail
eng._process_dying(s)
assert s.hp == -3
assert s.stabilized_hp is None
def test_dying_bleeds_to_death_threshold() -> None:
eng, s = _solo(con_score=10) # death threshold: hp < min(-10, -10) = -10
s.hp = -9
eng._rng = ScriptedRng([1, 1]) # two failed checks: -9 -> -10 -> -11
eng._process_dying(s)
assert s.hp == -10
assert not s.dead
eng._process_dying(s)
assert s.hp == -11
assert s.dead
# Dead creatures never roll again (empty queue would raise if they did).
eng._rng = ScriptedRng([])
eng._process_dying(s)
assert s.hp == -11
def test_dying_tick_runs_on_initiative_slot_during_round() -> None:
# m1 is downed before the battle starts; both sides still have active
# members so the battle continues and m1's slot processes the dying check.
combatants = [
_combatant("p1"),
_combatant("p2"),
_combatant("m1", con_score=8),
_combatant("m2"),
]
positions = [(0, 0), (0, 4), (0, 1), (0, 5)]
sides = ["players", "players", "monsters", "monsters"]
states = [
make_state(c, side, pos)
for c, side, pos in zip(combatants, sides, positions, strict=True)
]
# initiative p1..m2, then two dying-check rolls (both fail: -3 -> -4 -> -5)
rng = ScriptedRng([1, 2, 3, 4, 5, 6])
eng = CombatEngine(
rng, make_grid(), states, round_cap=2, policy=lambda _e, _s: ()
)
eng._states[2].hp = -3 # m1 downed
result = eng.run()
m1 = eng._states[2]
assert m1.hp == -5 # bled on its slot in each of the two rounds
assert result.winner is None # both sides still have active members
lines = eng._transcript
dying1 = [i for i, ln in enumerate(lines) if "round 1 m1: dying" in ln]
assert len(dying1) == 1
m2_wait = next(i for i, ln in enumerate(lines) if "round 1 m2: wait" in ln)
p2_wait = next(i for i, ln in enumerate(lines) if "round 1 p2: wait" in ln)
assert m2_wait < dying1[0] < p2_wait # initiative slot respected
assert any("round 2 m1: dying" in ln for ln in lines)
# ── Snapshot keys ─────────────────────────────────────────────────────────────
def test_snapshot_exposes_dying_and_stabilized() -> None:
eng = _engine(
[
_combatant("ok"),
_combatant("dying"),
_combatant("stab"),
_combatant("dead"),
],
[(0, 0), (0, 1), (2, 0), (2, 1)],
["players", "monsters", "players", "monsters"],
[],
)
_ok, dying, stab, dead = eng._states
dying.hp = -2
stab.hp = -3
stab.stabilized_hp = -3
dead.hp = -11 # CON 10 -> death threshold -10 -> dead, no longer dying
obs = _snapshot(eng, eng._states[0])
by_id = {c["id"]: c for c in obs["combatants"]}
assert by_id["ok"]["dying"] is False
assert by_id["ok"]["stabilized"] is False
assert by_id["dying"]["dying"] is True
assert by_id["dying"]["stabilized"] is False
assert by_id["stab"]["dying"] is True
assert by_id["stab"]["stabilized"] is True
assert by_id["dead"]["dead"] is True
assert by_id["dead"]["dying"] is False
assert by_id["dead"]["stabilized"] is False
# ── Rule B: Improved Critical ─────────────────────────────────────────────────
def _crit_setup(*, feat: bool, crit_range: int) -> CombatEngine:
features = ("Improved Critical",) if feat else ()
return _engine(
[
_combatant(
"atk",
attacks=(_melee("atk", crit_range=crit_range),),
features=features,
),
_combatant("tgt"),
],
[(0, 0), (0, 1)],
["players", "monsters"],
[],
)
def test_improved_critical_threats_on_19_with_20_range() -> None:
eng = _crit_setup(feat=True, crit_range=20)
a, t = eng._states
# attack 19 (threatens), confirm 19 (hits AC 10), damage die 6 -> x2 = 12.
eng._rng = ScriptedRng([19, 19, 6])
result = eng.resolve_attack(a, t, a.combatant.attacks[0])
assert result.hit is True
assert result.crit is True
assert result.damage == 12
assert t.hp == 88
def test_no_feat_does_not_threat_on_19_with_20_range() -> None:
eng = _crit_setup(feat=False, crit_range=20)
a, t = eng._states
# Only two rolls scripted: a spurious confirmation roll would exhaust the
# queue and raise, proving no confirm was attempted on the natural 19.
eng._rng = ScriptedRng([19, 6])
result = eng.resolve_attack(a, t, a.combatant.attacks[0])
assert result.hit is True
assert result.crit is False
assert result.damage == 6
assert t.hp == 94
def test_improved_critical_threats_on_17_with_19_range() -> None:
eng = _crit_setup(feat=True, crit_range=19)
a, t = eng._states
# 21 - 2 * (21 - 19) = 17: the natural 17 threatens.
eng._rng = ScriptedRng([17, 17, 6])
result = eng.resolve_attack(a, t, a.combatant.attacks[0])
assert result.crit is True
assert result.damage == 12
def test_improved_critical_does_not_affect_confirmation() -> None:
eng = _crit_setup(feat=True, crit_range=20)
a, t = eng._states
# Threatens on 19 but the confirmation roll 2 fails vs AC 10 (2+5=7).
eng._rng = ScriptedRng([19, 2, 6])
result = eng.resolve_attack(a, t, a.combatant.attacks[0])
assert result.crit is False
assert result.damage == 6