feat(cli): add --simple-monster-death option to kill monsters at hp <= 0
This commit is contained in:
@@ -82,7 +82,8 @@ Rounds moyens : 6.0
|
|||||||
|
|
||||||
```
|
```
|
||||||
usage: pf1e-sim [-h] --map MAP [--side NAME [FILE ...]] [--runs RUNS]
|
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 |
|
| 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` |
|
| `--runs RUNS` | Nombre de combats simulés | `1000` |
|
||||||
| `--seed SEED` | Graine du générateur aléatoire | `1` |
|
| `--seed SEED` | Graine du générateur aléatoire | `1` |
|
||||||
| `--round-cap ROUND_CAP` | Plafond de rounds par combat (au-delà, combat nul) | `100` |
|
| `--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 :
|
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
|
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
|
(`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.
|
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`
|
- Invalide (conformance P2, CRB « Effects of Death ») : à `hp = 0`
|
||||||
exactement, une créature est **invalide** — elle reste active mais
|
exactement, une créature est **invalide** — elle reste active mais
|
||||||
**staggered** (une action de mouvement OU une action standard par tour, pas
|
**staggered** (une action de mouvement OU une action standard par tour, pas
|
||||||
|
|||||||
@@ -105,6 +105,14 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
action="store_true",
|
action="store_true",
|
||||||
help="print the turn-by-turn log of the first battle before the report",
|
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)
|
args = parser.parse_args(argv)
|
||||||
if not args.side:
|
if not args.side:
|
||||||
parser.error("at least one --side is required")
|
parser.error("at least one --side is required")
|
||||||
@@ -124,6 +132,7 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
seed=args.seed,
|
seed=args.seed,
|
||||||
spell_registry=registry,
|
spell_registry=registry,
|
||||||
capture_first_transcript=args.transcript,
|
capture_first_transcript=args.transcript,
|
||||||
|
monsters_die_at_zero=args.simple_monster_death,
|
||||||
)
|
)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
print(f"pf1e-sim: {exc}", file=sys.stderr)
|
print(f"pf1e-sim: {exc}", file=sys.stderr)
|
||||||
|
|||||||
@@ -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,
|
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
|
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).
|
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).
|
- 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
|
- 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
|
the true shortest path (Dijkstra cost field from the target, 5-10-5
|
||||||
@@ -426,6 +429,23 @@ class CombatantStats:
|
|||||||
damage_taken: int = 0
|
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
|
@dataclass
|
||||||
class CombatantState:
|
class CombatantState:
|
||||||
"""Mutable runtime state of one combatant on the grid."""
|
"""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
|
# Dying & stabilization (not in the frozen data model): the hp at which
|
||||||
# this combatant stabilized while dying; None = not stabilized.
|
# this combatant stabilized while dying; None = not stabilized.
|
||||||
stabilized_hp: int | None = None
|
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
|
# 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
|
# PREVIOUS turn, assigned at the end of each turn so the Observation built
|
||||||
# at the next policy invocation can expose them.
|
# at the next policy invocation can expose them.
|
||||||
@@ -534,8 +558,10 @@ class CombatantState:
|
|||||||
def active(self) -> bool:
|
def active(self) -> bool:
|
||||||
"""PF1e: hp > 0 acts normally; hp == 0 is DISABLED (CRB, Effects of
|
"""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
|
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."""
|
or standard action), so it remains active. hp < 0 is dying/dead.
|
||||||
return self.hp >= 0
|
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
|
@property
|
||||||
def dead(self) -> bool:
|
def dead(self) -> bool:
|
||||||
@@ -543,7 +569,10 @@ class CombatantState:
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def death_threshold(self) -> int:
|
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
|
return -self.combatant.abilities.con_score
|
||||||
|
|
||||||
|
|
||||||
@@ -874,6 +903,7 @@ class CombatEngine:
|
|||||||
llm_policy: LLMPolicy | None = None,
|
llm_policy: LLMPolicy | None = None,
|
||||||
spell_registry: dict[str, SpellSpec] | None = None,
|
spell_registry: dict[str, SpellSpec] | None = None,
|
||||||
reaction_policy: ReactionPolicy | None = None,
|
reaction_policy: ReactionPolicy | None = None,
|
||||||
|
monsters_die_at_zero: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
if policy is not None and llm_policy is not None:
|
if policy is not None and llm_policy is not None:
|
||||||
msg = "policy and llm_policy are mutually exclusive"
|
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._policy = policy if policy is not None else default_policy
|
||||||
self._spell_registry = spell_registry or {}
|
self._spell_registry = spell_registry or {}
|
||||||
self._reaction_policy = reaction_policy
|
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._in_reaction = False
|
||||||
self._transcript: list[str] = []
|
self._transcript: list[str] = []
|
||||||
self._stats: dict[str, _LiveStats] = {s.combatant.id: _LiveStats() for s in states}
|
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."""
|
"""The spell registry used for spell lookups."""
|
||||||
return self._spell_registry
|
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:
|
def _llm_adapter(self, llm_policy: LLMPolicy) -> Policy:
|
||||||
"""Wrap an ``LLMPolicy`` into a ``Policy``.
|
"""Wrap an ``LLMPolicy`` into a ``Policy``.
|
||||||
|
|
||||||
@@ -5165,6 +5205,7 @@ class CombatEngine:
|
|||||||
# Set expiration
|
# Set expiration
|
||||||
summoned_state.summon_duration = effect.duration_rounds
|
summoned_state.summon_duration = effect.duration_rounds
|
||||||
summoned_state.summon_caster_id = state.combatant.id
|
summoned_state.summon_caster_id = state.combatant.id
|
||||||
|
self._apply_simple_monster_death(summoned_state)
|
||||||
|
|
||||||
self._stats[summoned_state.combatant.id] = _LiveStats()
|
self._stats[summoned_state.combatant.id] = _LiveStats()
|
||||||
self._pending_summons.append(summoned_state)
|
self._pending_summons.append(summoned_state)
|
||||||
|
|||||||
@@ -169,6 +169,7 @@ def run_encounter(
|
|||||||
policy: Policy | None = None,
|
policy: Policy | None = None,
|
||||||
spell_registry: dict[str, SpellSpec] | None = None,
|
spell_registry: dict[str, SpellSpec] | None = None,
|
||||||
capture_first_transcript: bool = False,
|
capture_first_transcript: bool = False,
|
||||||
|
monsters_die_at_zero: bool = False,
|
||||||
) -> EncounterReport:
|
) -> EncounterReport:
|
||||||
"""Run `runs` battles (one SeededRng per run) and aggregate balance metrics.
|
"""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
|
When ``capture_first_transcript`` is True, the turn-by-turn log of the
|
||||||
first battle (``seed + 0``) is stored in ``report.transcript``.
|
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:
|
if runs <= 0:
|
||||||
msg = "runs must be positive"
|
msg = "runs must be positive"
|
||||||
@@ -202,6 +207,7 @@ def run_encounter(
|
|||||||
round_cap=spec.round_cap,
|
round_cap=spec.round_cap,
|
||||||
policy=policy,
|
policy=policy,
|
||||||
spell_registry=spell_registry,
|
spell_registry=spell_registry,
|
||||||
|
monsters_die_at_zero=monsters_die_at_zero,
|
||||||
).run()
|
).run()
|
||||||
if capture_first_transcript and index == 0:
|
if capture_first_transcript and index == 0:
|
||||||
transcript = result.transcript
|
transcript = result.transcript
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user