feat(cli): add --simple-monster-death option to kill monsters at hp <= 0

This commit is contained in:
2026-09-11 12:48:43 +02:00
parent b7c80babfd
commit d6c8f88f77
5 changed files with 365 additions and 4 deletions
+9
View File
@@ -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)
+44 -3
View File
@@ -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)
+6
View File
@@ -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