From 4f6b076a498effe7b22b923f3d309437a546b233 Mon Sep 17 00:00:00 2001 From: ctan Date: Fri, 11 Sep 2026 21:22:03 +0200 Subject: [PATCH] fix(rules): exempt retrieve and repair weapon from the disabled hit-point cost PF1e (CRB, Disabled): a disabled creature can take move actions without further injuring itself. retrieve_weapon and repair_weapon are budget-classified move actions (_ACTION_ECONOMY_MOVE_KINDS) but were absent from _DISABLED_MOVE_KINDS, so a creature at 0 hp paid 1 hp to draw or mend a weapon. Added both kinds to the exempt set. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- README.md | 8 ++++-- src/pf1e_simulator/combat.py | 12 ++++++++- tests/test_conformance_p2.py | 51 ++++++++++++++++++++++++++++++++++++ 3 files changed, 68 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 9782e42..fd602a6 100644 --- a/README.md +++ b/README.md @@ -347,8 +347,12 @@ Règles modélisées : d'action à round complet, mais les actions swift, libre et immédiate restent permises — CRB « Staggered »), se déplace à **moitié vitesse**, et achever une action non-mouvement (standard ou pénible) lui coûte **1 pv** à la fin de - cette action (pv → −1, agonie). Simplification documentée : si l'action - elle-même soigne la créature au-dessus de 0 pv, ce coût n'est pas appliqué. + cette action (pv → −1, agonie). Les actions de mouvement — dont + `retrieve_weapon` (sortir/récupérer une arme) et `repair_weapon` (raccommoder + une arme brisée), classées mouvement — ne risquent pas de blessure + supplémentaire et coûtent 0 pv (CRB « Disabled »). Simplification documentée : + si l'action elle-même soigne la créature au-dessus de 0 pv, ce coût n'est pas + appliqué. - `hp < 0` = inactive (mourante ou morte) ; `hp = 0` = active mais invalide. - Initiative : triée sur (total, modificateur, ordre de liste), sans re-jet. - Économie d'action : un tour normal donne une action standard + une action de diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index 25da034..c3f735e 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -195,8 +195,18 @@ _HELPLESS_CONDITION_NAMES: frozenset[str] = frozenset( # PF1e (CRB, Disabled): at exactly 0 hp a creature takes only a single move # or standard action (staggered, half speed); move-equivalent and free # activities are not "strenuous" and cost no hp — everything else does. +# retrieve_weapon / repair_weapon are move actions (CRB: drawing or mending a +# weapon is a move action), so they belong here too. _DISABLED_MOVE_KINDS: frozenset[str] = frozenset( - {"move", "5foot_step", "stand_up", "drop_prone", "wait"} + { + "move", + "5foot_step", + "stand_up", + "drop_prone", + "wait", + "retrieve_weapon", + "repair_weapon", + } ) # PF1e (CRB, Combat Maneuver): attempting a combat maneuver provokes an # attack of opportunity from the target unless the attacker holds the diff --git a/tests/test_conformance_p2.py b/tests/test_conformance_p2.py index 2a9a591..0a3d7a9 100644 --- a/tests/test_conformance_p2.py +++ b/tests/test_conformance_p2.py @@ -338,6 +338,57 @@ def test_disabled_move_action_costs_no_hp() -> None: assert not any("costs 1 hp" in ln for ln in eng._transcript) +def _disabled_one_action_policy(action: Action) -> PolicyFn: + def policy(_engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]: + if state.combatant.id == "hero": + return (action,) + return (Action(kind="wait"),) + + return policy + + +def test_disabled_retrieve_weapon_costs_no_hp() -> None: + # PF1e (CRB, Disabled): "Taking move actions doesn't risk further injury." + # Drawing/retrieving a weapon is a move action, so it must not cost 1 hp. + hero = _combatant("hero") + foe = _combatant("foe") + eng = _engine( + [hero, foe], [(0, 0), (0, 7)], ["players", "monsters"], [], + policy=_disabled_one_action_policy( + Action(kind="retrieve_weapon", weapon_id="hero-sword") + ), + ) + s = eng._states[0] + s.hp = 0 + s.weapon_stolen.add("hero-sword") # make the retrieve meaningful + eng._current_round = 1 + eng._take_turn(s) + assert s.hp == 0 + assert "hero-sword" not in s.weapon_stolen # the retrieve ran + assert not any("costs 1 hp" in ln for ln in eng._transcript) + + +def test_disabled_repair_weapon_costs_no_hp() -> None: + # Mending a broken weapon is likewise a move action: no hp cost while 0 hp. + hero = _combatant("hero") + foe = _combatant("foe") + eng = _engine( + [hero, foe], [(0, 0), (0, 7)], ["players", "monsters"], [], + policy=_disabled_one_action_policy( + Action(kind="repair_weapon", weapon_id="hero-sword") + ), + ) + s = eng._states[0] + s.hp = 0 + s.weapon_damage["hero-sword"] = 10 # a broken weapon, repairable + s.weapon_broken.add("hero-sword") + eng._current_round = 1 + eng._take_turn(s) + assert s.hp == 0 + assert "hero-sword" not in s.weapon_broken # the repair ran + assert not any("costs 1 hp" in ln for ln in eng._transcript) + + # ── B12: death threshold is -CON, no -10 floor ───────────────────────────────