Files
pf1e-simulator/tests/test_conformance_simple_monster_death.py

287 lines
10 KiB
Python

"""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