feat(combat): add dying stabilization and Improved Critical
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user