"""Conformance X5: optional simple monster death (monsters die at hp <= 0). PF1e death is ``hp <= -CON`` (CRB, Effects of Death) with a disabled band at exactly 0 hp and a dying band below 0. The opt-in ``monsters_die_at_zero`` engine option (CLI ``--simple-monster-death``) raises the death threshold to 0 for MONSTER combatants only: a monster at hp <= 0 is dead outright (no dying, no disabled band). Player characters are NEVER affected — they are identified by the loader convention that a PC's ``source`` records its Foundry sheet path under ``fiches_personnages/`` (README, "Fiches de personnages"); monsters carry bestiary/summon sources. With the option off, behavior is bit-identical to the PF1e rule (goldens frozen). """ from __future__ import annotations from pathlib import Path from typing import TYPE_CHECKING import pytest if TYPE_CHECKING: from pf1e_simulator.models import Combatant from pf1e_simulator import cli from pf1e_simulator.combat import CombatantState, CombatEngine, Policy from pf1e_simulator.grid import Grid from pf1e_simulator.loaders.combatant import load_combatant from pf1e_simulator.map import MapSpec, TerrainType, load_map from pf1e_simulator.rng import RngExhaustedError, ScriptedRng from pf1e_simulator.runner import ( CombatantAttrition, EncounterReport, EncounterSpec, Side, build_states, run_encounter, ) from pf1e_simulator.spells import SpellSpec, load_spell_registry from tests.test_combat import make_combatant from tests.test_pc_combat_golden import _build_queue ROOT = Path(__file__).resolve().parents[1] DATA_DIR = ROOT / "data" PCS_DIR = DATA_DIR / "pcs" MAP = DATA_DIR / "maps" / "sample_arena.yaml" GOBLIN = DATA_DIR / "monsters" / "goblin.json" ORC = DATA_DIR / "monsters" / "orc.json" _MONSTER_SOURCE = "Bestiary > Goblin" _PC_SOURCE = "fiches_personnages/tammara_sheet.json" def _monster(cid: str = "goblin", con: int = 12) -> Combatant: return make_combatant(cid, con=con).model_copy(update={"source": _MONSTER_SOURCE}) def _pc(cid: str = "hero", con: int = 12) -> Combatant: return make_combatant(cid, con=con).model_copy(update={"source": _PC_SOURCE}) def _state(combatant: Combatant, side: str, hp: int) -> CombatantState: return CombatantState(combatant=combatant, side=side, pos=(1, 1), hp=hp) def _grid(width: int = 8) -> Grid: legend = {".": TerrainType(type="floor", move_cost=1)} spec = MapSpec(name="test", terrain=tuple(["." * width] * width), legend=legend) return Grid.from_spec(spec) def _engine( states: list[CombatantState], *, monsters_die_at_zero: bool = False ) -> CombatEngine: return CombatEngine( ScriptedRng([1] * 50), _grid(), states, monsters_die_at_zero=monsters_die_at_zero, ) # ── (a) monster at hp 0: dead with the flag, disabled band without ────────── def test_monster_at_zero_hp_is_dead_with_flag_on() -> None: st = _state(_monster(), "monsters", hp=0) engine = _engine([st], monsters_die_at_zero=True) assert engine.states[0].dies_at_zero is True assert st.dead is True assert st.active is False def test_monster_at_zero_hp_keeps_disabled_band_with_flag_off() -> None: st = _state(_monster(), "monsters", hp=0) _engine([st]) assert st.dies_at_zero is False assert st.dead is False assert st.active is True # CRB "Effects of Death": disabled, still acting # ── (b) monster at/below -CON: dead in both modes; band only dies with flag ─ def test_monster_dead_at_minus_con_in_both_modes() -> None: down = _state(_monster(con=12), "monsters", hp=-12) up = _state(_monster(con=12), "monsters", hp=-12) _engine([down]) _engine([up], monsters_die_at_zero=True) assert down.dead is True assert up.dead is True def test_monster_dying_band_is_dead_only_with_flag_on() -> None: off = _state(_monster(con=12), "monsters", hp=-1) on = _state(_monster(con=12), "monsters", hp=-1) _engine([off]) _engine([on], monsters_die_at_zero=True) assert off.dead is False # PF1e dying: -1 > -CON assert on.dead is True # ── (c) PCs are never affected by the rule ─────────────────────────────────── def test_pc_at_zero_hp_is_never_dead_even_with_flag_on() -> None: st = _state(_pc(), "players", hp=0) _engine([st], monsters_die_at_zero=True) assert st.dies_at_zero is False assert st.dead is False assert st.active is True # disabled band preserved for PCs def test_pc_death_still_requires_minus_con_with_flag_on() -> None: dying = _state(_pc(con=12), "players", hp=-1) dead = _state(_pc(con=12), "players", hp=-12) _engine([dying, dead], monsters_die_at_zero=True) assert dying.dies_at_zero is False assert dying.dead is False # PC dying band untouched assert dead.dead is True # PF1e hp <= -CON still kills the PC # ── classification + dying machinery integration ───────────────────────────── def test_flag_off_marks_no_state() -> None: m = _state(_monster(), "monsters", hp=6) p = _state(_pc(), "players", hp=6) _engine([m, p]) assert m.dies_at_zero is False assert p.dies_at_zero is False def test_foundry_sourced_combatant_never_marked() -> None: """The classification helper keys on the ``fiches_personnages/`` source.""" st = _state(_pc(), "players", hp=6) _engine([st], monsters_die_at_zero=True) assert st.dies_at_zero is False def test_summon_style_source_is_a_monster() -> None: summoned = make_combatant("wolf").model_copy(update={"source": "summon monster II"}) st = _state(summoned, "players", hp=6) engine = _engine([st], monsters_die_at_zero=True) engine._apply_simple_monster_death(st) # the hook used at the summon site assert st.dies_at_zero is True def test_dying_stabilization_check_skipped_for_dead_monster() -> None: """With the flag on, a monster below 0 hp is dead: no CON check, no RNG.""" st = _state(_monster(con=12), "monsters", hp=-2) engine = CombatEngine(ScriptedRng([]), _grid(), [st], monsters_die_at_zero=True) engine._process_dying(st) # would raise RngExhaustedError if it rolled off = _state(_monster(con=12), "monsters", hp=-2) off_engine = CombatEngine(ScriptedRng([]), _grid(), [off]) with pytest.raises(RngExhaustedError): off_engine._process_dying(off) # dying CON check consumes the d20 # ── (d) flag OFF end-to-end: bit-identical transcripts when no monster # enters the hp 0..-CON band (tammara vs lich: the PC bleeds, the lich does not) def _golden_harness_engine(*, monsters_die_at_zero: bool) -> CombatEngine: pc = load_combatant(PCS_DIR / "tammara.json") monster = load_combatant(DATA_DIR / "monsters" / "lich.json") registry = load_spell_registry(DATA_DIR / "spells") pc_state = CombatantState(combatant=pc, side="players", pos=(0, 0), hp=pc.hp_max) monster_state = CombatantState( combatant=monster, side="monsters", pos=(10, 10), hp=monster.hp_max ) return CombatEngine( ScriptedRng(list(_build_queue())), _grid(width=20), [pc_state, monster_state], round_cap=10, spell_registry=registry, monsters_die_at_zero=monsters_die_at_zero, ) def test_flag_off_equivalence_when_band_never_entered() -> None: plain = _golden_harness_engine(monsters_die_at_zero=False) result_plain = plain.run() simple = _golden_harness_engine(monsters_die_at_zero=True) result_simple = simple.run() assert result_plain.transcript == result_simple.transcript assert result_plain.winner == result_simple.winner == "monsters" tammara = plain.states[0] assert tammara.hp < 0 # the PC bled into the dying band… assert tammara.dies_at_zero is False # …without dying at 0, flag or not assert tammara.dead is False # ── runner + CLI wiring ─────────────────────────────────────────────────────── def _spec() -> EncounterSpec: goblins = tuple(load_combatant(GOBLIN) for _ in range(3)) orcs = tuple(load_combatant(ORC) for _ in range(2)) return EncounterSpec( map=load_map(MAP), sides=( Side(name="players", combatants=goblins, zone="A"), Side(name="monsters", combatants=orcs, zone="B"), ), round_cap=100, ) def test_run_encounter_accepts_the_flag() -> None: report = run_encounter(_spec(), runs=5, seed=1, monsters_die_at_zero=True) assert report.runs == 5 assert sum(report.wins.values()) + report.draws == 5 states = build_states(_spec()) assert all(not s.dies_at_zero for s in states) # build_states stays neutral def test_cli_help_lists_the_option(capsys: pytest.CaptureFixture[str]) -> None: with pytest.raises(SystemExit) as excinfo: cli.main(["--help"]) assert excinfo.value.code == 0 assert "--simple-monster-death" in capsys.readouterr().out def test_cli_wires_the_flag_to_the_runner( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], ) -> None: seen: list[bool] = [] def spy( spec: EncounterSpec, runs: int, seed: int, *, policy: Policy | None = None, spell_registry: dict[str, SpellSpec] | None = None, capture_first_transcript: bool = False, monsters_die_at_zero: bool = False, ) -> EncounterReport: seen.append(monsters_die_at_zero) return EncounterReport( runs=runs, seed=seed, wins={side.name: runs for side in spec.sides}, draws=0, avg_rounds=1.0, attrition={ combatant.id: CombatantAttrition(0.0, 0.0, 0.0, 0.0, 0.0) for side in spec.sides for combatant in side.combatants }, ) monkeypatch.setattr(cli, "run_encounter", spy) base = [ "--map", str(MAP), "--side", "players", str(GOBLIN), "--side", "monsters", str(ORC), "--runs", "5", ] assert cli.main(base) == 0 assert cli.main([*base, "--simple-monster-death"]) == 0 assert seen == [False, True] assert "Rapport d'équilibrage" in capsys.readouterr().out