fix(rules): scale dirty trick duration and drop nauseated

PF1e (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
additional round" — rounds_left = 1 + margin//5. The RAW condition list
(blinded, dazzled, deafened, entangled, shaken, sickened) does not include
nauseated, so it is removed from TrickCondition. README updated.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
2026-09-12 11:34:20 +02:00
parent 34be75c492
commit f9420c7b09
3 changed files with 39 additions and 15 deletions
+5 -2
View File
@@ -1064,8 +1064,11 @@ Règles modélisées :
la plus proche de la cible sur la ligne d'approche (adjacent : immobile) ; la plus proche de la cible sur la ligne d'approche (adjacent : immobile) ;
conformance L7 (SRD « Trip ») : un trip qui « fails by 10 or more » renverse conformance L7 (SRD « Trip ») : un trip qui « fails by 10 or more » renverse
l'**attaquant** (`prone` 1 round, `total <= CMD - 10`) ; dirty trick → l'**attaquant** (`prone` 1 round, `total <= CMD - 10`) ; dirty trick →
condition choisie par `Action.trick_condition` (défaut `dazzled` 1 round ; condition choisie par `Action.trick_condition` (défaut `dazzled` ; durée
`blinded`/`deafened`/`entangled`/`nauseated` possibles) ; steal → le modèle ne porte = 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
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 »),
+8 -3
View File
@@ -148,7 +148,7 @@ 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", "nauseated"] TrickCondition = Literal["dazzled", "blinded", "deafened", "entangled"]
ReadyTrigger = Literal[ ReadyTrigger = Literal[
"enemy_enters_reach", "enemy_casts", "enemy_attacks", "enemy_hits_ally" "enemy_enters_reach", "enemy_casts", "enemy_attacks", "enemy_hits_ally"
@@ -263,6 +263,7 @@ _AID_ANOTHER_BONUS = 2 # PF1e: a successful aid grants +2 (next attack or AC)
_RUN_SPEED_FACTOR = 4 # PF1e: running is a full-round action at 4x speed _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
_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
_MANEUVER_MAX_SIZE_DIFF = 2 # PF1e: 2+ size categories larger = unmaneuverable _MANEUVER_MAX_SIZE_DIFF = 2 # PF1e: 2+ size categories larger = unmaneuverable
_SAVE_DESCRIPTOR_CONDITIONS = frozenset({ _SAVE_DESCRIPTOR_CONDITIONS = frozenset({
@@ -4450,17 +4451,21 @@ class CombatEngine:
return return
condition_name = trick_condition or "dazzled" condition_name = trick_condition or "dazzled"
condition = CONDITIONS_BY_NAME[condition_name] 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
# penalty lasts 1 additional round."
rounds = 1 + max(0, m.total - m.target_cmd) // _DIRTY_TRICK_EXTRA_ROUND_MARGIN
target.active_conditions.append( target.active_conditions.append(
ActiveCondition( ActiveCondition(
condition=condition, condition=condition,
rounds_left=1, rounds_left=rounds,
source="maneuver: dirty trick", source="maneuver: dirty trick",
) )
) )
self._log( self._log(
f"round {self._current_round} {state.combatant.id}: dirty trick vs " f"round {self._current_round} {state.combatant.id}: dirty trick vs "
f"{target.combatant.id} d20={m.roll}+{m.attacker_cmb}={m.total} " f"{target.combatant.id} d20={m.roll}+{m.attacker_cmb}={m.total} "
f"CMD {m.target_cmd} -> SUCCESS ({condition_name})" f"CMD {m.target_cmd} -> SUCCESS ({condition_name} {rounds}r)"
) )
def _maneuver_steal( def _maneuver_steal(
+26 -10
View File
@@ -2,8 +2,11 @@
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, deafened, entangled, nauseated) successful dirty trick applies (blinded, dazzled, deafened, entangled)
instead of always applying ``dazzled``. 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
exceeds the target's CMD.
When ``trick_condition`` is ``None`` the previous behavior (``dazzled``) is When ``trick_condition`` is ``None`` the previous behavior (``dazzled``) is
kept bit-identical. kept bit-identical.
@@ -119,14 +122,27 @@ def test_dirty_trick_entangled() -> None:
assert _condition_names(b) == ["entangled"] assert _condition_names(b) == ["entangled"]
def test_dirty_trick_nauseated() -> None: def test_dirty_trick_duration_scales_with_margin() -> None:
a_spec = make_combatant("a") # APG Dirty Trick: "This condition lasts for 1 round. For every 5 by
b_spec = make_combatant("b") # which your attack exceeds your opponent's CMD, the penalty lasts 1
a = make_state(a_spec, "players", (1, 1)) # additional round." b CMD 12, a CMB 2: d20 17 -> total 19, margin 7
b = make_state(b_spec, "monsters", (1, 2)) # -> 2 rounds; d20 20 -> total 22, margin 10 -> 3 rounds.
engine = make_engine([1, 12], [a, b]) for roll, expected_rounds in ((17, 2), (20, 3)):
engine._execute_maneuver(a, b, "dirty_trick", trick_condition="nauseated") a = make_state(make_combatant("a"), "players", (1, 1))
assert _condition_names(b) == ["nauseated"] b = make_state(make_combatant("b"), "monsters", (1, 2))
engine = make_engine([1, roll], [a, b]) # leading 1: AoO auto-miss
engine._execute_maneuver(a, b, "dirty_trick")
assert b.active_conditions[0].rounds_left == expected_rounds
assert f"dazzled {expected_rounds}r" in engine._transcript[-1]
def test_dirty_trick_small_margin_stays_one_round() -> None:
# Margin 0-4 (d20 10-14 -> total 12-16 vs CMD 12): exactly 1 round.
a = make_state(make_combatant("a"), "players", (1, 1))
b = make_state(make_combatant("b"), "monsters", (1, 2))
engine = make_engine([1, 13], [a, b])
engine._execute_maneuver(a, b, "dirty_trick")
assert b.active_conditions[0].rounds_left == 1
# --- Test 4: failed maneuver applies no condition ---------------------------- # --- Test 4: failed maneuver applies no condition ----------------------------