diff --git a/README.md b/README.md index 07faade..b1c39e3 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,8 @@ Rounds moyens : 6.0 ``` usage: pf1e-sim [-h] --map MAP [--side NAME [FILE ...]] [--runs RUNS] - [--seed SEED] [--round-cap ROUND_CAP] + [--seed SEED] [--round-cap ROUND_CAP] [--transcript] + [--simple-monster-death] ``` | Option | Rôle | Défaut | @@ -92,6 +93,8 @@ usage: pf1e-sim [-h] --map MAP [--side NAME [FILE ...]] [--runs RUNS] | `--runs RUNS` | Nombre de combats simulés | `1000` | | `--seed SEED` | Graine du générateur aléatoire | `1` | | `--round-cap ROUND_CAP` | Plafond de rounds par combat (au-delà, combat nul) | `100` | +| `--transcript` | Imprimer le déroulé du premier combat avant le rapport | désactivé | +| `--simple-monster-death` | Mort simplifiée des monstres : `hp ≤ 0` au lieu de `hp ≤ -CON` (les PJ ne sont jamais affectés) | désactivé | Codes de sortie : @@ -294,6 +297,22 @@ Règles modélisées : La mort survient quand les pv négatifs atteignent le score de CON (`hp ≤ -CON`) — conformance P2 : la règle du −10 de D&D 3.5 n'existe pas en PF1e et n'est plus modélisée. +- Mort simplifiée des monstres (option `--simple-monster-death`, défaut + désactivé) : quand l'option est présente dans la commande, un **monstre** + meurt dès `hp ≤ 0` — ni bande d'invalidité à 0 pv, ni bande d'agonie entre + 0 et `-CON`. Les **personnages joueurs ne sont jamais affectés**, quel que + soit le réglage : ils gardent la règle PF1e complète (`hp ≤ -CON` pour + mourir, invalidité à 0 pv, agonie et stabilisation en dessous). La + distinction monstre/PJ repose sur une seule marque fiable issue du + chargeur : le champ `source` des JSON de PJ pointe vers la fiche Foundry + d'origine sous `fiches_personnages/` (convention documentée plus bas), + alors que les JSON de monstres portent des sources « Bestiary », « summon + monster » ou « bestiary » ; le nom de camp (`--side`) ne peut pas servir + de critère puisqu'il est choisi par l'utilisateur et que la carte d'exemple + oppose des monstres déployés côté `players`. Sans l'option, le comportement + du moteur est bit-identique à celui d'aujourd'hui (transcripts dorés + intacts). Équivalent Python : `run_encounter(..., monsters_die_at_zero=True)` + et `CombatEngine(..., monsters_die_at_zero=True)`. - Invalide (conformance P2, CRB « Effects of Death ») : à `hp = 0` exactement, une créature est **invalide** — elle reste active mais **staggered** (une action de mouvement OU une action standard par tour, pas diff --git a/src/pf1e_simulator/cli.py b/src/pf1e_simulator/cli.py index 353f935..959f114 100644 --- a/src/pf1e_simulator/cli.py +++ b/src/pf1e_simulator/cli.py @@ -105,6 +105,14 @@ def main(argv: list[str] | None = None) -> int: action="store_true", help="print the turn-by-turn log of the first battle before the report", ) + parser.add_argument( + "--simple-monster-death", + action="store_true", + help=( + "monsters die at hp <= 0 instead of the PF1e hp <= -CON rule " + "(player characters are never affected)" + ), + ) args = parser.parse_args(argv) if not args.side: parser.error("at least one --side is required") @@ -124,6 +132,7 @@ def main(argv: list[str] | None = None) -> int: seed=args.seed, spell_registry=registry, capture_first_transcript=args.transcript, + monsters_die_at_zero=args.simple_monster_death, ) except ValueError as exc: print(f"pf1e-sim: {exc}", file=sys.stderr) diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index 217d623..8d2bce3 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -40,6 +40,9 @@ Phase 0 documented deviations from PF1e (conventions): has no -10 rule. At exactly 0 hp a living creature is disabled: active, staggered (one move OR standard action per round) at half speed, and a non-movement action costs 1 hp at its end (hp -> -1, dying). + Optional rule (``monsters_die_at_zero``, CLI ``--simple-monster-death``): + monster combatants die at hp <= 0 instead — player characters are never + affected and the default (flag off) is the PF1e rule above, bit-identical. - Initiative ties: higher initiative_mod first, then list order (no re-roll). - Movement: a move action travels up to the combatant's speed in cells along the true shortest path (Dijkstra cost field from the target, 5-10-5 @@ -426,6 +429,23 @@ class CombatantStats: damage_taken: int = 0 +_PC_SOURCE_PREFIX = "fiches_personnages/" + + +def _is_pc_combatant(combatant: Combatant) -> bool: + """True for player characters, False for monsters (X5 classification). + + The unified loader records each combatant's origin in ``source``: every + PC JSON (``data/pcs/*.json``) points at its Foundry sheet under + ``fiches_personnages/`` (README convention), while monster JSONs carry + bestiary/summon sources. ``side`` is a user-chosen faction name and + ``cr``/``xp`` are absent from some monster files, so ``source`` is the + only robust per-combatant marker that survives loading without a schema + change. + """ + return combatant.source.startswith(_PC_SOURCE_PREFIX) + + @dataclass class CombatantState: """Mutable runtime state of one combatant on the grid.""" @@ -459,6 +479,10 @@ class CombatantState: # 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 + # Simple monster death (X5, not in the frozen data model): set by the + # engine when ``monsters_die_at_zero`` is on, for monster combatants only + # — the combatant dies at hp <= 0 instead of the PF1e hp <= -CON rule. + dies_at_zero: bool = False # Action feedback (not in the frozen data model): the ActionResults of the # PREVIOUS turn, assigned at the end of each turn so the Observation built # at the next policy invocation can expose them. @@ -534,8 +558,10 @@ class CombatantState: def active(self) -> bool: """PF1e: hp > 0 acts normally; hp == 0 is DISABLED (CRB, Effects of Death: a living creature at exactly 0 hp can still take a single move - or standard action), so it remains active. hp < 0 is dying/dead.""" - return self.hp >= 0 + or standard action), so it remains active. hp < 0 is dying/dead. + Under the simple monster-death rule (``dies_at_zero``) hp <= 0 is + dead, so the disabled band never applies.""" + return self.hp >= 0 and not self.dead @property def dead(self) -> bool: @@ -543,7 +569,10 @@ class CombatantState: @property def death_threshold(self) -> int: - """PF1e (CRB): death when negative hp reaches the CON score (no -10 rule).""" + """PF1e (CRB): death when negative hp reaches the CON score (no -10 rule). + The optional simple monster-death rule raises the threshold to 0.""" + if self.dies_at_zero: + return 0 return -self.combatant.abilities.con_score @@ -874,6 +903,7 @@ class CombatEngine: llm_policy: LLMPolicy | None = None, spell_registry: dict[str, SpellSpec] | None = None, reaction_policy: ReactionPolicy | None = None, + monsters_die_at_zero: bool = False, ) -> None: if policy is not None and llm_policy is not None: msg = "policy and llm_policy are mutually exclusive" @@ -888,6 +918,10 @@ class CombatEngine: self._policy = policy if policy is not None else default_policy self._spell_registry = spell_registry or {} self._reaction_policy = reaction_policy + self._monsters_die_at_zero = monsters_die_at_zero + if monsters_die_at_zero: + for st in states: + self._apply_simple_monster_death(st) self._in_reaction = False self._transcript: list[str] = [] self._stats: dict[str, _LiveStats] = {s.combatant.id: _LiveStats() for s in states} @@ -911,6 +945,12 @@ class CombatEngine: """The spell registry used for spell lookups.""" return self._spell_registry + def _apply_simple_monster_death(self, state: CombatantState) -> None: + """Opt-in X5 rule: with ``monsters_die_at_zero``, monster combatants + die at hp <= 0 (no dying/disabled band); PCs are never marked.""" + if self._monsters_die_at_zero and not _is_pc_combatant(state.combatant): + state.dies_at_zero = True + def _llm_adapter(self, llm_policy: LLMPolicy) -> Policy: """Wrap an ``LLMPolicy`` into a ``Policy``. @@ -5165,6 +5205,7 @@ class CombatEngine: # Set expiration summoned_state.summon_duration = effect.duration_rounds summoned_state.summon_caster_id = state.combatant.id + self._apply_simple_monster_death(summoned_state) self._stats[summoned_state.combatant.id] = _LiveStats() self._pending_summons.append(summoned_state) diff --git a/src/pf1e_simulator/runner.py b/src/pf1e_simulator/runner.py index 19d4967..d0f442b 100644 --- a/src/pf1e_simulator/runner.py +++ b/src/pf1e_simulator/runner.py @@ -169,6 +169,7 @@ def run_encounter( policy: Policy | None = None, spell_registry: dict[str, SpellSpec] | None = None, capture_first_transcript: bool = False, + monsters_die_at_zero: bool = False, ) -> EncounterReport: """Run `runs` battles (one SeededRng per run) and aggregate balance metrics. @@ -177,6 +178,10 @@ def run_encounter( When ``capture_first_transcript`` is True, the turn-by-turn log of the first battle (``seed + 0``) is stored in ``report.transcript``. + + ``monsters_die_at_zero`` enables the optional simple monster-death rule + (CLI ``--simple-monster-death``): monsters die at hp <= 0; PCs keep the + PF1e hp <= -CON rule. Default False keeps behavior bit-identical. """ if runs <= 0: msg = "runs must be positive" @@ -202,6 +207,7 @@ def run_encounter( round_cap=spec.round_cap, policy=policy, spell_registry=spell_registry, + monsters_die_at_zero=monsters_die_at_zero, ).run() if capture_first_transcript and index == 0: transcript = result.transcript diff --git a/tests/test_conformance_simple_monster_death.py b/tests/test_conformance_simple_monster_death.py new file mode 100644 index 0000000..04fcc68 --- /dev/null +++ b/tests/test_conformance_simple_monster_death.py @@ -0,0 +1,286 @@ +"""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) + 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