feat(cli): add --simple-monster-death option to kill monsters at hp <= 0
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user