diff --git a/README.md b/README.md index a576090..aee13b2 100644 --- a/README.md +++ b/README.md @@ -267,6 +267,22 @@ Règles modélisées : - 1 naturel = échec automatique ; 20 naturel = touche + menace de critique. - Critique : (dés + bonus) × multiplicateur ; la RD s'applique après 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. - 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 @@ -502,7 +518,7 @@ Règles modélisées : se branche via `CombatEngine(..., llm_policy=...)` (`LLMPolicy(observation, state) -> tuple[Action, ...]`). Schéma enrichi (E1-E5) : - 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, name, level, size, dr {amount, bypass} ou None (C1), creature_type (C3), 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 : (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 - 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, range_penalty, can_charge, can_step5_to_attack, has_line_of_effect, flanking, movement_cost_ft, target_threatens_me, enemies_threatening_me. diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index f0531ac..ae2a119 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -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 _SNEAK_ATTACK_FEATURE = "Sneak Attack" # PF1e: rogue class feature (precision dice) _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({ "fear", "cold", "electricity", "fey", "divination", "poison", "disease", "mind_affecting", @@ -335,6 +338,9 @@ class CombatantState: # Runtime weapon theft (not in the frozen data model): weapon ids taken by # steal maneuvers. 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]: return [ @@ -741,6 +747,8 @@ class CombatEngine: "hp_max": state.combatant.hp_max, "active": state.active, "dead": state.dead, + "dying": state.hp < 0 and not state.dead, + "stabilized": state.stabilized_hp is not None, "conditions": conditions, "resources_left": resources, "weapon_broken": tuple(sorted(state.weapon_broken)), @@ -1024,6 +1032,7 @@ class CombatEngine: order = self._build_round_order() for state in order: if not state.active: + self._process_dying(state) continue if self._take_turn(state): return True @@ -1044,6 +1053,45 @@ class CombatEngine: return True 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]: """Union of occupied cells of all active states except those in ``exclude``.""" return frozenset( @@ -1423,9 +1471,15 @@ class CombatEngine: Returns ``(crit, concealment_miss, damage)``. Sneak precision dice are 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 - 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_total = ( confirm + base_bonus + penalty + flank + high_ground diff --git a/tests/test_combat.py b/tests/test_combat.py index cd54660..d6ee634 100644 --- a/tests/test_combat.py +++ b/tests/test_combat.py @@ -351,6 +351,8 @@ def test_scripted_2v2_transcript() -> None: 14, 1, # R1 gob-2 hits orc-2 for 1 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 + 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 ] engine = make_engine(queue, states) @@ -369,6 +371,11 @@ def test_scripted_2v2_transcript() -> None: "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: 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 vs gob-1 d20=15+7=22 AC 16 -> HIT 9 damage (6->-3)", "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 8, # R1 orc-2 misses 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 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, 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 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 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 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 4 gob: short sword vs orc-2 d20=12+2=14 AC 13 -> HIT 6 damage (3->-3)", "round 4 gob: orc-2 down", diff --git a/tests/test_dying_and_crits.py b/tests/test_dying_and_crits.py new file mode 100644 index 0000000..f460583 --- /dev/null +++ b/tests/test_dying_and_crits.py @@ -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