fix(rules): dirty trick RAW list extended to shaken and sickened (APG), with runtime validation

This commit is contained in:
2026-09-16 20:16:35 +02:00
parent 2b5920c85c
commit 06da519fc6
3 changed files with 75 additions and 6 deletions
+3 -2
View File
@@ -1357,8 +1357,9 @@ Règles modélisées :
condition choisie par `Action.trick_condition` (défaut `dazzled` ; durée 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 = 1 round + 1 round par 5 points dont l'attaque dépasse le CMD — APG
« Dirty Trick » ; « Dirty Trick » ;
`blinded`/`deafened`/`entangled` possibles, `nauseated` exclu car hors de `blinded`/`dazzled`/`deafened`/`entangled`/`shaken`/`sickened` possibles
la liste RAW) ; steal → le modèle ne porte (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 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 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 »), with the steal maneuver—you must use the disarm combat maneuver instead »),
+19 -2
View File
@@ -151,7 +151,9 @@ if TYPE_CHECKING:
from pf1e_simulator.rng import Rng from pf1e_simulator.rng import Rng
from pf1e_simulator.spells import SpellSpec from pf1e_simulator.spells import SpellSpec
TrickCondition = Literal["dazzled", "blinded", "deafened", "entangled"] TrickCondition = Literal[
"dazzled", "blinded", "deafened", "entangled", "shaken", "sickened"
]
ReadyTrigger = Literal[ ReadyTrigger = Literal[
"enemy_enters_reach", "enemy_casts", "enemy_attacks", "enemy_hits_ally" "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 _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 _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 _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 _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 _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 _TRIP_RETALIATION_MARGIN = 10 # PF1e: trip failing by 10+ knocks the attacker prone
@@ -5102,12 +5109,22 @@ class CombatEngine:
target: CombatantState, target: CombatantState,
m: _ManeuverRoll, m: _ManeuverRoll,
*, *,
trick_condition: TrickCondition | None = None, trick_condition: str | None = 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: if not m.success:
self._maneuver_fail(state, target, "dirty_trick", m) self._maneuver_fail(state, target, "dirty_trick", m)
return return
condition_name = trick_condition or "dazzled" 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] condition = CONDITIONS_BY_NAME[condition_name]
# PF1e (APG Dirty Trick): "This condition lasts for 1 round. For # PF1e (APG Dirty Trick): "This condition lasts for 1 round. For
# every 5 by which your attack exceeds your opponent's CMD, the # every 5 by which your attack exceeds your opponent's CMD, the
+53 -2
View File
@@ -2,7 +2,8 @@
L6 of the ``llm-policy-contract`` plan: ``Action`` gains a L6 of the ``llm-policy-contract`` plan: ``Action`` gains a
``trick_condition`` field that lets a policy choose which condition 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 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 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 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 __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.grid import Grid
from pf1e_simulator.map import MapSpec, TerrainType from pf1e_simulator.map import MapSpec, TerrainType
from pf1e_simulator.models import ( from pf1e_simulator.models import (
@@ -122,6 +129,50 @@ def test_dirty_trick_entangled() -> None:
assert _condition_names(b) == ["entangled"] 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: def test_dirty_trick_duration_scales_with_margin() -> None:
# APG Dirty Trick: "This condition lasts for 1 round. For every 5 by # 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 # which your attack exceeds your opponent's CMD, the penalty lasts 1