diff --git a/README.md b/README.md index 088be1a..00e4113 100644 --- a/README.md +++ b/README.md @@ -1357,8 +1357,9 @@ Règles modélisées : condition choisie par `Action.trick_condition` (défaut `dazzled` ; durée = 1 round + 1 round par 5 points dont l'attaque dépasse le CMD — APG « Dirty Trick » ; - `blinded`/`deafened`/`entangled` possibles, `nauseated` exclu car hors de - la liste RAW) ; steal → le modèle ne porte + `blinded`/`dazzled`/`deafened`/`entangled`/`shaken`/`sickened` possibles + (liste exacte de l'APG), `nauseated` exclu car hors de + la liste RAW, et rejeté à l'exécution avec journalisation) ; steal → le modèle ne porte aucun objet transporté non tenu : en application de l'APG « Steal » (« Items held in the hands (such as wielded weapons or wands) also cannot be taken with the steal maneuver—you must use the disarm combat maneuver instead »), diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index adc5a0c..41a2171 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -151,7 +151,9 @@ if TYPE_CHECKING: from pf1e_simulator.rng import Rng from pf1e_simulator.spells import SpellSpec - TrickCondition = Literal["dazzled", "blinded", "deafened", "entangled"] + TrickCondition = Literal[ + "dazzled", "blinded", "deafened", "entangled", "shaken", "sickened" + ] ReadyTrigger = Literal[ "enemy_enters_reach", "enemy_casts", "enemy_attacks", "enemy_hits_ally" @@ -268,6 +270,11 @@ _RUN_SPEED_FACTOR = 4 # PF1e: running is a full-round action at 4x speed _OVERRUN_PRONE_MARGIN = 5 # PF1e: overrun knocks prone only on a 5+ CMB margin _BULL_RUSH_EXTRA_PER_MARGIN = 5 # PF1e: +5 ft push per 5 points above CMD _DIRTY_TRICK_EXTRA_ROUND_MARGIN = 5 # PF1e APG: +1 round per 5 points above CMD +# PF1e (APG "Dirty Trick"): "The penalty is limited to one of the following +# conditions: blinded, dazzled, deafened, entangled, shaken, or sickened." +_DIRTY_TRICK_CONDITIONS: frozenset[str] = frozenset( + {"blinded", "dazzled", "deafened", "entangled", "shaken", "sickened"} +) _REPOSITION_EXTRA_PER_MARGIN = 5 # PF1e APG: +5 ft move per 5 points above CMD _DRAG_EXTRA_PER_MARGIN = 5 # PF1e APG: +5 ft drag per 5 points above CMD _TRIP_RETALIATION_MARGIN = 10 # PF1e: trip failing by 10+ knocks the attacker prone @@ -5102,12 +5109,22 @@ class CombatEngine: target: CombatantState, m: _ManeuverRoll, *, - trick_condition: TrickCondition | None = None, + trick_condition: str | None = None, ) -> None: + # ``trick_condition`` is typed ``str`` here (not the TrickCondition + # Literal) because the APG RAW list is enforced at RUNTIME by + # ``_DIRTY_TRICK_CONDITIONS`` — untrusted policy payloads must be + # rejected, not just statically annotated away. if not m.success: self._maneuver_fail(state, target, "dirty_trick", m) return condition_name = trick_condition or "dazzled" + if condition_name not in _DIRTY_TRICK_CONDITIONS: + self._log( + f"round {self._current_round} {state.combatant.id}: " + f"dirty trick unknown condition {condition_name!r}" + ) + return condition = CONDITIONS_BY_NAME[condition_name] # PF1e (APG Dirty Trick): "This condition lasts for 1 round. For # every 5 by which your attack exceeds your opponent's CMD, the diff --git a/tests/test_dirty_trick.py b/tests/test_dirty_trick.py index 849fd7d..7c5cf8a 100644 --- a/tests/test_dirty_trick.py +++ b/tests/test_dirty_trick.py @@ -2,7 +2,8 @@ L6 of the ``llm-policy-contract`` plan: ``Action`` gains a ``trick_condition`` field that lets a policy choose which condition a -successful dirty trick applies (blinded, dazzled, deafened, entangled) +successful dirty trick applies (blinded, dazzled, deafened, entangled, +shaken, sickened) instead of always applying ``dazzled``. APG Dirty Trick limits the penalty to those conditions — nauseated is NOT on the RAW list and is not a valid choice. The condition lasts 1 round plus 1 more per 5 by which the attack @@ -14,7 +15,13 @@ kept bit-identical. from __future__ import annotations -from pf1e_simulator.combat import Action, CombatantState, CombatEngine +from pf1e_simulator.combat import ( + _DIRTY_TRICK_CONDITIONS, + Action, + CombatantState, + CombatEngine, + _ManeuverRoll, +) from pf1e_simulator.grid import Grid from pf1e_simulator.map import MapSpec, TerrainType from pf1e_simulator.models import ( @@ -122,6 +129,50 @@ def test_dirty_trick_entangled() -> None: assert _condition_names(b) == ["entangled"] +def test_dirty_trick_shaken() -> None: + # APG Dirty Trick RAW list: blinded, dazzled, deafened, entangled, + # shaken, or sickened. + a_spec = make_combatant("a") + b_spec = make_combatant("b") + a = make_state(a_spec, "players", (1, 1)) + b = make_state(b_spec, "monsters", (1, 2)) + engine = make_engine([1, 12], [a, b]) + engine._execute_maneuver(a, b, "dirty_trick", trick_condition="shaken") + assert _condition_names(b) == ["shaken"] + + +def test_dirty_trick_sickened() -> None: + a_spec = make_combatant("a") + b_spec = make_combatant("b") + a = make_state(a_spec, "players", (1, 1)) + b = make_state(b_spec, "monsters", (1, 2)) + engine = make_engine([1, 12], [a, b]) + engine._execute_maneuver(a, b, "dirty_trick", trick_condition="sickened") + assert _condition_names(b) == ["sickened"] + + +def test_dirty_trick_rejects_condition_off_the_raw_list() -> None: + # Runtime validation: a condition off the APG list (e.g. nauseated) is + # refused with a logged line and applies nothing. + a_spec = make_combatant("a") + b_spec = make_combatant("b") + a = make_state(a_spec, "players", (1, 1)) + b = make_state(b_spec, "monsters", (1, 2)) + engine = make_engine([], [a, b]) + m = _ManeuverRoll(roll=12, attacker_cmb=2, total=14, target_cmd=12, success=True) + engine._maneuver_dirty_trick(a, b, m, trick_condition="nauseated") + assert _condition_names(b) == [] + assert any("unknown condition" in line for line in engine._transcript) + + +def test_dirty_trick_conditions_set_matches_apg_list() -> None: + # The APG list is exactly: blinded, dazzled, deafened, entangled, + # shaken, sickened (nauseated excluded). + assert frozenset( + {"blinded", "dazzled", "deafened", "entangled", "shaken", "sickened"} + ) == _DIRTY_TRICK_CONDITIONS + + def test_dirty_trick_duration_scales_with_margin() -> None: # APG Dirty Trick: "This condition lasts for 1 round. For every 5 by # which your attack exceeds your opponent's CMD, the penalty lasts 1