fix(rules): correct P2/P3 PF1e conformance bugs (flanking, touch, blinded, disabled, death, maneuvers, DR, sneak)

This commit is contained in:
2026-09-10 21:45:50 +02:00
parent 5afe495c4a
commit 9aee4f6f6b
15 changed files with 934 additions and 134 deletions
+60 -17
View File
@@ -269,8 +269,13 @@ vers la fiche Foundry d'origine.
Règles modélisées :
- 1 naturel = échec automatique ; 20 naturel = touche + menace de critique.
- Critique : (dés + bonus) × multiplicateur ; la RD s'applique après
multiplication, les types perforants annulent la RD, les dégâts plancher à 0.
- Critique : (dés + bonus) × multiplicateur ; le 20 naturel au jet de
confirmation confirme automatiquement la critique (conformance P2 — le jet
de confirmation est un jet d'attaque) ; la RD s'applique après
multiplication, un type de contournement annule la RD — une RD à plusieurs
composants (« DR X/Y and Z », conformance P2) exige que l'attaque porte
TOUS les composants (le contournement n'est plus traité comme un OU), les
dégâts plancher à 0.
- Critique améliorée (« Improved Critical ») : le don double la plage de
menace — plage effective = `21 - 2 × (21 - crit_range)` (20 → 19-20,
19 → 17-20, 18 → 15-20). Il n'affecte que le jet de menace, jamais le jet de
@@ -279,15 +284,22 @@ Règles modélisées :
- Mourir et stabilisation : une créature à `hp < 0` mais pas morte est
mourante — inconsciente (inactive), elle perd 1 pv par tour si elle échoue
son jet de Constitution DD 10 à son tour d'initiative : `d20 + mod de CON −
|hp|` contre 10 ; un 20 naturel réussit automatiquement. En cas d'échec, elle
|pv négatifs|` contre 10 ; un 20 naturel réussit automatiquement. En cas d'échec, elle
perd 1 pv de plus ; en cas de réussite, elle est stabilisée (plus de perte de
pv, reste inconsciente). Une nouvelle blessure sous le seuil de stabilisation
(`hp < stabilized_hp`) fait reprendre l'agonie (nouveau jet) ; un soin
ramenant à `hp ≥ 0` réinitialise la stabilisation pour toute chute future.
La mort survient au seuil existant `hp < min(-10, -CON)` — divergence RAW
documentée : la règle du jeu meurt quand les pv négatifs atteignent le score
de CON (le moteur est donc d'un pv plus permissif) ; sémantique inchangée.
- Mort quand `hp < min(-10, -CON)` ; `hp ≤ 0` = inactif.
La mort survient quand les pv négatifs atteignent le score de CON
(`hp ≤ -CON`) — conformance P2 : la règle du −10 de D&D 3.5 n'existe pas
en PF1e et n'est plus modélisée.
- Invalide (conformance P2, CRB « Effects of Death ») : à `hp = 0`
exactement, une créature est **invalide** — elle reste active mais
**staggered** (une action de mouvement OU une action standard par tour, pas
d'action à round complet), 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é.
- `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
mouvement (ou une action à round complet), plus swift/free/immediate. La
@@ -480,10 +492,13 @@ Règles modélisées :
confirmation de critique ; couvert mou : une créature active entre
l'attaquant et la cible octroie aussi +4 CA aux attaques à distance (les
attaques de mêlée ignorent les créatures).
- Flanquement : +2 au jet d'attaque de mêlée si un allié actif (doté d'une arme
de mêlée) menace la cible depuis la bordure ou le coin opposé. Les attaques à
distance ignorent le flanquement ; un allié désarmé ou à distance ne compte
pas pour le flanquement.
- Flanquement : +2 au jet d'attaque de mêlée (toucher de mêlée inclus, voir
« Touch AC ») si un allié actif menace réellement la cible depuis la
bordure ou le coin opposé — conformance P2 : l'allié doit **menacer** une
case de la cible avec une arme de mêlée utilisable (la « dead zone » de 5 ft
d'une arme allongée ne menace pas) et ne pas être incapable d'agir
(étourdi/paralysé ne flanque pas). Les attaques à distance ignorent le
flanquement ; un allié désarmé ou à distance ne compte pas.
- Attaques d'opportunité : un combattant actif doté d'une arme de mêlée menace
les cases à distance de son allonge effective (les 8 cases adjacentes pour
une arme sans allonge ; l'anneau à son allonge pour une arme reach — la
@@ -543,7 +558,11 @@ Règles modélisées :
avec incrément est une attaque à distance : elle provoque des attaques
d'opportunité depuis une case menacée, subit −4 si l'attaquant est à plat
ventre et bénéficie du couvert mou ; un contact de mêlée (sans incrément,
ex. le contact paralysant du liche) n'en fait rien.
ex. le contact paralysant du liche) n'en fait rien. Conformance P2 : le
contact de mêlée (touch sans incrément) est une **attaque de mêlée** — il
bénéficie du flanquement, de la hauteur (+1), du +4 contre une cible inerme
et peut porter une attaque sournoise ; Power Attack reste réservé aux armes
de mêlée (le contact n'en bénéficie pas).
- Jets de sauvegarde : `resolve_save(state, save_type, dc)` — fort/ref/will.
1 naturel = échec automatique, 20 naturel = succès automatique. Les
modificateurs d'effets (`StatModifier` avec `target` correspondant) sont
@@ -631,12 +650,16 @@ Règles modélisées :
déjà via le chemin `denies_dex_to_ac`).
- Attaque sournoise : un combattant avec la capacité « Sneak Attack » inflige des
d6 de dégâts de précision ((niveau + 1) // 2 dés : 1d6 au niveau 1, 2d6 au
niveau 3…) si la cible est flanquée (attaque de mêlée uniquement) ou privée de
niveau 3…) si la cible est flanquée (attaque de mêlée uniquement — toucher de
mêlée inclus, conformance P2) ou privée de
son bonus de Dextérité à la CA (condition `denies_dex_to_ac` ; mêlée, ou
attaque à distance à ≤ 30 ft). Les dés sont jetés avec le RNG du moteur et
ajoutés APRÈS le multiplicateur de critique (la précision n'est jamais
multipliée) et AVANT la RD et les résistances énergétiques ; jamais contre une
cible avec concealment.
cible avec concealment. Conformance P2 : les morts-vivants (`undead`) et les
constructeurs (`construct`) sont immunisés aux dégâts de précision — la cible
ne peut pas être la victime d'une attaque sournoise ; les autres immunités RAW
(vase, plante) ne sont pas modélisées (pas de tels types dans le modèle).
- Tirer dans une mêlée / Precise Shot : toute attaque à distance (kind `ranged`
ou `touch` avec incrément) contre une cible engagée dans une mêlée subit −4 au
jet d'attaque ET au jet de confirmation (la cible est « engagée » quand un
@@ -665,7 +688,17 @@ Règles modélisées :
−4, simplification documentée) ; la cible à plat ventre subit −4 CA contre
la mêlée et gagne +4 CA contre la distance (le CRB inverse du « −4 CA
contre tout » précédemment modélisé).
Agrippé : −4 Dextérité, −2 attaque/CA/Réflexes, sans déplacement ni AoO.
Agrippé : −4 Dextérité — le modificateur cible `dex_skills` s'applique
désormais réellement aux compétences de Dexterity (Acrobaties, Escalade,
Équilibre, Évasion, Vol, Équitation, Manipulation de corde, Discrétion,
Natation, Désamorçage — conformance P2) —, −2 attaque/CA/Réflexes, sans
déplacement ni AoO. Épinglé : même −4 `dex_skills`.
Aveuglé (conformance P2) : −2 CA, perte du bonus de Dextérité à la CA, −4
aux compétences de Dexterity (`dex_skills`) et **aucun malus plat
d'attaque** — la pénalité RAW est la dissimulation totale (50 % d'échec,
modélisée) ; le −2 d'attaque non-RAW qui s'y superposait a été supprimé.
Simplification documentée : la moitié Force du −4 RAW (compétences de
Force) n'est pas modélisée (pas de cible `str_skills`).
Inerme (`helpless`, `paralyzed`, `asleep`, `unconscious`) : ne peut pas agir,
Dextérité effective 0 (−5 CA en plus de la perte du bonus), +4 en mêlée
contre lui (voir « Cible inerme ») ; `stunned` ne rend pas inerte. Fasciné (`fascinated`) : ne peut pas agir.
@@ -687,7 +720,14 @@ Règles modélisées :
de grâce n'est pas modélisé.
- Manœuvres de combat : trip, disarm, bull_rush, sunder, grapple, escape,
overrun, dirty trick, steal, reposition et drag sont modélisées, chacune
comme une tentative unique (1d20 + CMB contre CMD) :
comme une tentative unique (1d20 + CMB contre CMD) : le jet de manœuvre est
un jet d'attaque — 20 naturel réussit toujours, 1 naturel échoue toujours
(conformance P2). Conformance P2 : tenter une manœuvre (sauf `escape`)
**provoque une attaque d'opportunité** de la cible, résolue **avant** le jet
de CMB ; le don « Improved <Maneuver> » correspondant (Improved Trip, …)
annule cette provocation ; si l'AoO fait passer l'attaquant sous 0 pv, la
manœuvre est perdue. L'AoO de manœuvre consomme le budget d'AoO du round de
la cible (Combat Reflexes inclus).
trip → `prone` ; disarm → `disarmed` (−4 attaque) ; bull_rush → pousse la
cible (direction sélectionnable via `Action.direction`) ;
grapple → `grappled`/`pinned` ; escape → **une** sauvegarde (bonus statique de la
@@ -736,7 +776,10 @@ Règles modélisées :
- Le Liche (`data/monsters/lich.json`) : `creature_type: "undead"` (sensible à l'énergie
positive canalisée), aura de terreur (30 ft, Volonté DD 22 ou
`shaken` 1 round, au début de son tour) et contact paralysant (au toucher,
`paralysé` 1d4+1 rounds), RD 15/magie+contondant ; son JSON porte un dictionnaire
`paralysé` 1d4+1 rounds), RD 15/contondant **et** magie (conformance P2 :
les deux composants sont requis — une arme contondante non magique ne
contourne plus la RD ; lacune documentée : le modèle d'arme n'expose pas de
propriété « magique », donc aucun équipement ne contourne cette RD) ; son JSON porte un dictionnaire
`spell_slots` sans clés dupliquées.
- Snapshot d'observation (contrat LLM, Phase 2) : avant chaque appel de
politique, le moteur construit une `Observation` sérialisable (`to_dict()` →
+213 -53
View File
@@ -28,8 +28,13 @@ Phase 0 documented deviations from PF1e (conventions):
natural weapons in a single full attack are not modeled.
- Natural 1 always misses; natural 20 always hits and threatens a crit.
- A confirmed crit multiplies the total damage (dice + flat bonus) by crit_mult.
- DR applies once, after crit multiplication; any bypassing type defeats DR.
- Death when hp < min(-10, -CON); hp <= 0 cannot act.
- DR applies once, after crit multiplication; a single bypass type defeats
DR; a multi-component "and" bypass (e.g. "bludgeoning and magic") requires
EVERY component (PF1e "DR X/Y and Z" notation).
- Death when negative hp reaches the Constitution score (hp <= -CON); PF1e
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).
- 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
@@ -42,8 +47,11 @@ Phase 0 documented deviations from PF1e (conventions):
ranged reuse the same corner rule. Soft cover: active creatures between
attacker and target also grant +4 AC on ranged attacks (CRB soft cover);
melee attacks ignore creatures.
- Flanking: +2 on melee attack rolls when an active ally with a melee weapon
threatens the target from the opposite border or corner. Ranged attacks do
- Flanking: +2 on melee attack rolls (melee touch included) when an active
ally THREATENS the target from the opposite border or corner — the ally
must actually threaten the target's squares with a usable melee weapon
(a reach weapon's 5-ft dead zone does not threaten) and must not be
unable to act (stunned/paralyzed allies do not flank). Ranged attacks do
not benefit from flanking; allies without melee weapons do not threaten.
- Attacks of opportunity: a combatant threatens the 8 adjacent squares if
active and wielding a melee weapon. Two triggers are modeled: (1) moving
@@ -130,7 +138,6 @@ from pf1e_simulator.los import (
)
_SQUARE_FT = 5 # Phase 0 maps use 5-ft squares
_DEATH_FLOOR = -10 # PF1e: dead when hp < -10 or -CON, whichever is lower
_NATURAL_ONE = 1 # PF1e: natural 1 always misses
_NATURAL_TWENTY = 20 # PF1e: natural 20 always hits and threatens
_RANGE_INCREMENT_PENALTY = 2 # PF1e: -2 per full range increment beyond the first
@@ -174,6 +181,39 @@ _DEX_ZERO_MOD = -5
_HELPLESS_CONDITION_NAMES: frozenset[str] = frozenset(
{"helpless", "paralyzed", "asleep", "unconscious"}
)
# 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.
_DISABLED_MOVE_KINDS: frozenset[str] = frozenset(
{"move", "5foot_step", "stand_up", "drop_prone", "wait"}
)
# PF1e (CRB, Combat Maneuver): attempting a combat maneuver provokes an
# attack of opportunity from the target unless the attacker holds the
# Improved feat for that maneuver (escape never provokes).
_IMPROVED_MANEUVER_FEATS: dict[str, str] = {
"trip": "Improved Trip",
"disarm": "Improved Disarm",
"bull_rush": "Improved Bull Rush",
"sunder": "Improved Sunder",
"grapple": "Improved Grapple",
"overrun": "Improved Overrun",
"dirty_trick": "Improved Dirty Trick",
"steal": "Improved Steal",
"reposition": "Improved Reposition",
"drag": "Improved Drag",
}
# PF1e: skill checks that take Dexterity-based penalties (the "dex_skills"
# modifier target — grappled/pinned/blinded -4). Climb and Swim are the
# Strength skills the conformance adjudication groups with the Dex skills.
_DEX_BASED_SKILLS: frozenset[str] = frozenset(
{
"acrobatics", "climb", "disable_device", "escape_artist", "fly",
"ride", "sleight_of_hand", "stealth", "swim",
}
)
# PF1e (CRB): undead and constructs are immune to precision damage (sneak
# attack). Ooze/plant immunity is not modeled (the model has no such types).
_PRECISION_IMMUNE_TYPES: frozenset[str] = frozenset({"undead", "construct"})
_AID_ANOTHER_DC = 10 # PF1e: aid another is an attack roll against AC 10
_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
@@ -481,15 +521,19 @@ class CombatantState:
@property
def active(self) -> bool:
return self.hp > 0
"""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
@property
def dead(self) -> bool:
return self.hp < self.death_threshold
return self.hp <= self.death_threshold
@property
def death_threshold(self) -> int:
return min(_DEATH_FLOOR, -self.combatant.abilities.con_score)
"""PF1e (CRB): death when negative hp reaches the CON score (no -10 rule)."""
return -self.combatant.abilities.con_score
@dataclass(frozen=True)
@@ -1291,36 +1335,25 @@ class CombatEngine:
state.aid_ac_bonus = 0
state.running = False
original = self._policy(self, state)
actions = original
results: list[ActionResult] = []
if state.immediate_used:
kept = _without_swift_bonus(actions)
results.extend(
ActionResult(kind=kind, ok=False, reason=_REASON_DOWNGRADED_IMMEDIATE_SWIFT)
for kind in _dropped_kinds(actions, kept)
)
actions = kept
state.immediate_used = False
if self._has_condition_named(state, "staggered"):
kept = _staggered_filter(actions)
results.extend(
ActionResult(kind=kind, ok=False, reason=_REASON_DOWNGRADED_STAGGERED)
for kind in _dropped_kinds(actions, kept)
)
actions = kept
elif self.has_condition_flag(state, "cant_attack"):
kept = _nauseated_filter(actions)
results.extend(
ActionResult(kind=kind, ok=False, reason=_REASON_DOWNGRADED_NAUSEATED)
for kind in _dropped_kinds(actions, kept)
)
actions = kept
actions = self._filter_turn_actions(state, original, results)
before = len(self._transcript)
was_delayed = state.delayed_this_round
disabled = self._is_disabled(state)
for action in actions:
if not state.active:
break
results.append(self._execute(state, action))
if disabled and state.hp == 0 and action.kind not in _DISABLED_MOVE_KINDS:
# PF1e (CRB, Disabled): completing a standard (or otherwise
# strenuous) action costs 1 hp — the creature slips to -1 and
# is dying. Simplification: if the action itself healed the
# creature above 0 hp the cost is not applied.
state.hp -= 1
self._log(
f"round {self._current_round} {state.combatant.id}: "
f"disabled: strenuous action costs 1 hp (hp 0 -> -1, dying)"
)
if state.delayed_this_round and not was_delayed:
# A fresh delay forfeits the rest of the turn.
break
@@ -1334,6 +1367,43 @@ class CombatEngine:
state.last_action_results = results
return self._winner_side() is not None
def _filter_turn_actions(
self,
state: CombatantState,
actions: tuple[Action, ...],
results: list[ActionResult],
) -> tuple[Action, ...]:
"""Downgrade the policy's action sequence to the legal action economy.
Swift consumed by an immediate action; PF1e (CRB, Disabled): a
creature at exactly 0 hp is staggered — one move OR one standard
action per round, no full-round actions; nauseated: single move only.
Dropped kinds are reported into ``results``.
"""
if state.immediate_used:
kept = _without_swift_bonus(actions)
results.extend(
ActionResult(kind=kind, ok=False, reason=_REASON_DOWNGRADED_IMMEDIATE_SWIFT)
for kind in _dropped_kinds(actions, kept)
)
actions = kept
state.immediate_used = False
if self._is_disabled(state) or self._has_condition_named(state, "staggered"):
kept = _staggered_filter(actions)
results.extend(
ActionResult(kind=kind, ok=False, reason=_REASON_DOWNGRADED_STAGGERED)
for kind in _dropped_kinds(actions, kept)
)
return kept
if self.has_condition_flag(state, "cant_attack"):
kept = _nauseated_filter(actions)
results.extend(
ActionResult(kind=kind, ok=False, reason=_REASON_DOWNGRADED_NAUSEATED)
for kind in _dropped_kinds(actions, kept)
)
return kept
return actions
def has_condition_flag(self, state: CombatantState, flag: str) -> bool:
"""Check if any active condition has the given behavioral flag set to True."""
for ac in state.active_conditions:
@@ -1349,6 +1419,11 @@ class CombatEngine:
or any(cond.name == name for cond in state.conditions)
)
def _is_disabled(self, state: CombatantState) -> bool:
"""PF1e (CRB, Effects of Death): a living creature at exactly 0 hp is
disabled — staggered, half speed, and a strenuous action costs 1 hp."""
return state.hp == 0
def _is_helpless(self, state: CombatantState) -> bool:
"""PF1e (CRB): helpless = paralyzed, held, bound, sleeping, unconscious.
@@ -1389,8 +1464,12 @@ class CombatEngine:
return False
def _get_speed_mult(self, state: CombatantState) -> float:
"""Get the minimum speed multiplier from all active conditions."""
speed_mult = 1.0
"""Get the minimum speed multiplier from all active conditions.
PF1e (CRB, Disabled): a disabled creature (exactly 0 hp) moves at
half speed.
"""
speed_mult = 0.5 if state.hp == 0 else 1.0
for ac in state.active_conditions:
if ac.condition.speed_mult != 1.0:
speed_mult = min(speed_mult, ac.condition.speed_mult)
@@ -1750,6 +1829,15 @@ class CombatEngine:
weapon.kind == "touch" and weapon.range_increment_ft is not None
)
def _is_melee_attack(self, weapon: AttackSpec) -> bool:
"""PF1e: a melee touch attack (kind "touch" with no range increment,
e.g. the lich's Paralyzing Touch) is a melee attack — it gains the
melee benefits (flanking, high ground, vs-helpless +4, sneak attack).
"""
return weapon.kind == "melee" or (
weapon.kind == "touch" and weapon.range_increment_ft is None
)
def range_penalty(self, weapon: AttackSpec, dist_ft: int) -> int:
"""PF1e: -2 per full range increment beyond the first, zero within it."""
if weapon.kind not in ("ranged", "touch") or weapon.range_increment_ft is None:
@@ -1763,7 +1851,11 @@ class CombatEngine:
"""+2 if an active ally threatens the target from the opposite side (CRB flanking).
The opposite side is the mirror of every attacker cell across every
target cell; an ally occupying any mirrored cell flanks.
target cell; an ally occupying any mirrored cell flanks. PF1e (CRB):
the ally must actually THREATEN the target — a usable melee weapon
whose reach covers a target square (a reach weapon's 5-ft dead zone
does not), and no condition that stops it from acting (stunned,
paralyzed, ...).
"""
opposite = {
(2 * tr - ar, 2 * tc - ac)
@@ -1775,9 +1867,11 @@ class CombatEngine:
continue
if not ally.active or ally.side != attacker.side:
continue
if self.has_condition_flag(ally, "cant_act"):
continue
if not (ally.occupied_cells() & opposite):
continue
if any(w.kind == "melee" for w in ally.combatant.attacks):
if self.threatened_squares(ally) & target.occupied_cells():
return _FLANK_BONUS
return 0
@@ -1952,10 +2046,12 @@ class CombatEngine:
roll = self._rng.d20()
dist_ft = self.creature_distance(attacker, target) * _SQUARE_FT
penalty = self.range_penalty(weapon, dist_ft)
flank = self._flanking_bonus(attacker, target) if weapon.kind == "melee" else 0
# PF1e: melee touch attacks are melee attacks — flanking and high
# ground apply to them (see _is_melee_attack).
flank = self._flanking_bonus(attacker, target) if self._is_melee_attack(weapon) else 0
high_ground = (
_HIGH_GROUND_BONUS
if weapon.kind == "melee"
if self._is_melee_attack(weapon)
and self._elevation(attacker) > self._elevation(target)
else 0
)
@@ -1979,10 +2075,10 @@ class CombatEngine:
# attack and crit confirmation, target loses its Dex bonus to AC).
if self._has_condition_named(attacker, "invisible"):
atk_mods.append(StatModifier(target="attack", value=_INVISIBLE_ATTACK_BONUS))
# PF1e (CRB): melee attacks against a helpless target gain +4; the
# helpless state includes paralyzed, asleep and unconscious (stunned
# is NOT helpless). Ranged attacks gain no bonus.
if weapon.kind == "melee" and self._is_helpless(target):
# PF1e (CRB): melee attacks (melee touch included) against a helpless
# target gain +4; the helpless state includes paralyzed, asleep and
# unconscious (stunned is NOT helpless). Ranged attacks gain no bonus.
if self._is_melee_attack(weapon) and self._is_helpless(target):
atk_mods.append(StatModifier(target="attack", value=_HELPLESS_MELEE_BONUS))
attack_mod = resolve_modifiers(atk_mods)
pa_amt = self._power_attack_amt(attacker) if weapon.kind == "melee" else 0
@@ -2100,7 +2196,11 @@ class CombatEngine:
+ attack_mod - pa_amt + fim_penalty + attacker.aid_attack_bonus
+ (_FIGHT_DEFENSIVELY_ATTACK_PENALTY if attacker.fight_defensively else 0)
)
crit = confirm != _NATURAL_ONE and confirm_total >= ac
# PF1e (CRB): the confirmation roll is an attack roll — a natural
# 20 on it is an automatic hit (auto-confirm), a natural 1 miss.
crit = confirm == _NATURAL_TWENTY or (
confirm != _NATURAL_ONE and confirm_total >= ac
)
concealment = self._concealment_chance(target)
# PF1e: a blinded character's opponents count as having total
# concealment against it — the blinded attacker itself misses 50%.
@@ -2166,14 +2266,19 @@ class CombatEngine:
) -> int:
"""Sneak attack d6 count for this swing (0 when the sneak does not apply).
PF1e: precision damage when the target is flanked (melee only) or
denied its Dex bonus to AC (melee, or ranged within 30 ft); dice =
(level + 1) // 2; concealment denies the sneak attack entirely.
PF1e: precision damage when the target is flanked (melee only, melee
touch included) or denied its Dex bonus to AC (melee, or ranged within
30 ft); dice = (level + 1) // 2; concealment denies the sneak attack
entirely. PF1e (CRB): undead and constructs are immune to precision
damage — ooze/plant immunity is not modeled (no such creature types
exist in the data model).
"""
if concealment > 0 or not self._has_feat(attacker, _SNEAK_ATTACK_FEATURE):
return 0
if target.combatant.creature_type.lower() in _PRECISION_IMMUNE_TYPES:
return 0
dice = (attacker.combatant.level + 1) // 2
is_melee = weapon.kind == "melee"
is_melee = self._is_melee_attack(weapon)
if is_melee and self._flanking_bonus(attacker, target) > 0:
return dice
if denies_dex and (
@@ -2249,12 +2354,26 @@ class CombatEngine:
attacker.deed_up_close_and_deadly = False
return damage
def _dr_bypassed(self, dr: DamageReduction, types: frozenset[str]) -> bool:
"""PF1e (CRB): "DR X/Y and Z" requires EVERY listed component to bypass.
A single-component bypass behaves as before (that type defeats the
DR); a multi-component bypass is an "and" list — all components must
be present on the attack. Documented gap: the weapon model exposes no
"magic" property, so a bypass list containing "magic" (the lich's
DR 15/bludgeoning and magic) can never be satisfied by a mundane
weapon — correctly, a non-magical weapon must NOT bypass it.
"""
if not dr.bypass:
return False
return dr.bypass <= types
def _apply_dr(self, damage: int, weapon: AttackSpec, target: CombatantState) -> int:
dr: DamageReduction | None = target.combatant.dr
if dr is None:
return damage
types = {t for component in weapon.damage for t in component.types}
if types & dr.bypass:
types = frozenset(t for comp in weapon.damage for t in comp.types)
if self._dr_bypassed(dr, types):
return damage
return max(0, damage - dr.amount)
@@ -2264,7 +2383,7 @@ class CombatEngine:
dr: DamageReduction | None = target.combatant.dr
if dr is None:
return damage
if damage_types & dr.bypass:
if self._dr_bypassed(dr, damage_types):
return damage
return max(0, damage - dr.amount)
@@ -2375,8 +2494,14 @@ class CombatEngine:
ability_name = skill_spec.ability
ability_score = getattr(state.combatant.abilities, f"{ability_name}_score")
ability_mod = (ability_score - 10) // 2
# Effect/condition modifiers
mod_bonus = resolve_modifiers(self._stat_modifiers(state, skill))
# Effect/condition modifiers. PF1e: modifiers targeting "dex_skills"
# (grappled/pinned/blinded -4) apply to the Dexterity-based skills
# listed in _DEX_BASED_SKILLS — they are stored under that aggregate
# target, not the concrete skill name, so collect them explicitly.
mods = self._stat_modifiers(state, skill)
if skill in _DEX_BASED_SKILLS:
mods.extend(self._stat_modifiers(state, "dex_skills"))
mod_bonus = resolve_modifiers(mods)
return rank + class_bonus + ability_mod + armor_penalty + mod_bonus
def _roll_initiative(self) -> list[CombatantState]:
@@ -2807,8 +2932,11 @@ class CombatEngine:
Sets a -2 AC penalty on the attacker that lasts until the start of their next turn
(cleared in ``_take_turn``). Movement during a charge provokes AoOs step-by-step
as for any move action.
as for any move action. Like every other movement it is mutually exclusive
with movement already spent this turn (``moved_this_turn``).
"""
if attacker.moved_this_turn:
return
reach_cells = _effective_reach_ft(attacker, weapon) // _SQUARE_FT
min_reach_cells = weapon.reach_ft // _SQUARE_FT if weapon.reach_ft > _SQUARE_FT else 0
speed_cells = _speed_ft(attacker) // _SQUARE_FT
@@ -3441,13 +3569,26 @@ class CombatEngine:
):
return
# PF1e (CRB, Combat Maneuver): attempting the maneuver provokes an
# attack of opportunity from the target — resolved BEFORE the CMB
# check — unless the attacker holds the Improved feat for it. Being
# knocked below 0 hp by that AoO forfeits the maneuver.
if not self._has_feat(state, _IMPROVED_MANEUVER_FEATS[maneuver]):
self._maneuver_provoked_aoo(state, target)
if not state.active:
return
attacker_cmb = self._maneuver_cmb(state)
target_cmd = self._maneuver_cmd(target)
roll = self._rng.d20()
total = roll + attacker_cmb
m = _ManeuverRoll(
roll=roll, attacker_cmb=attacker_cmb, total=total,
target_cmd=target_cmd, success=roll != 1 and total >= target_cmd,
target_cmd=target_cmd,
# PF1e: the CMB check is an attack roll — natural 20 always
# succeeds, natural 1 always fails.
success=roll == _NATURAL_TWENTY
or (roll != _NATURAL_ONE and total >= target_cmd),
)
handlers: dict[str, Callable[[], None]] = {
@@ -3466,6 +3607,21 @@ class CombatEngine:
}
handlers[maneuver]()
def _maneuver_provoked_aoo(
self, state: CombatantState, target: CombatantState
) -> None:
"""Resolve the single AoO a combat maneuver provokes from its target.
The target strikes only if it threatens the attacker, is able to
make AoOs (``_resolve_aoo`` honours ``cant_aoo``) and has per-round
AoO allowance left — same budget as movement/ranged provocation.
"""
if not self.threatened_squares(target) & state.occupied_cells():
return
if self._aoo_used.get(target.combatant.id, 0) >= self._aoo_allowance(target):
return
self._resolve_aoo(target, state)
def _retrieve_weapon(self, state: CombatantState, weapon_id: str | None) -> None:
"""Restore a stolen weapon to its owner (remove it from ``weapon_stolen``).
@@ -5207,7 +5363,11 @@ class CombatEngine:
The starting square is not threatened — no AoO when leaving it.
Subsequent squares provoke AoOs normally (step-by-step, as for move).
Like every other movement it is mutually exclusive with movement
already spent this turn (``moved_this_turn``).
"""
if state.moved_this_turn:
return
if threat is None:
return
path = self._flee_path(state, threat)
+5 -2
View File
@@ -143,12 +143,15 @@ DAZZLED = Condition(
),
)
# Blinded: -2 AC, loses Dex, -4 STR/DEX skill checks (CRB)
# Blinded: -2 AC, loses Dex, -4 Dexterity skill checks; NO flat attack
# penalty (the -2 was a non-RAW double penalty on top of the engine's
# modeled 50% total-concealment miss). The Str-skill half of the -4 is not
# modeled (no str_skills target). (CRB)
BLINDED = Condition(
name="blinded",
modifiers=(
StatModifier(target="ac", value=-2),
StatModifier(target="attack", value=-2),
StatModifier(target="dex_skills", value=-4),
),
denies_dex_to_ac=True,
)
+31 -18
View File
@@ -240,7 +240,7 @@ def test_count_two_resolves_two_swings() -> None:
a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (1, 2))
engine = make_engine(
[10, 9, 12, 3, 10, 7, 13, 4, 11, 2, 8, 12, 3], [a, b]
[10, 9, 12, 3, 10, 7, 13, 4, 11, 2, 8, 12, 4], [a, b]
)
result = engine.run()
assert result.winner == "players"
@@ -254,12 +254,12 @@ def test_count_two_resolves_two_swings() -> None:
"round 2 a: short sword #1 vs b d20=13+2=15 AC 13 -> HIT 4 damage (9->5)",
"round 2 a: short sword #2 vs b d20=11+2=13 AC 13 -> HIT 2 damage (5->3)",
"round 2 b: short sword vs a d20=8+2=10 AC 13 -> MISS",
"round 3 a: short sword #1 vs b d20=12+2=14 AC 13 -> HIT 3 damage (3->0)",
"round 3 a: short sword #1 vs b d20=12+2=14 AC 13 -> HIT 4 damage (3->-1)",
"round 3 a: b down",
"battle over: players win in 3 rounds",
)
assert result.stats["a"] == CombatantStats(hits=4, crits=0, damage_dealt=12, damage_taken=0)
assert result.stats["b"] == CombatantStats(hits=0, crits=0, damage_dealt=0, damage_taken=12)
assert result.stats["a"] == CombatantStats(hits=4, crits=0, damage_dealt=13, damage_taken=0)
assert result.stats["b"] == CombatantStats(hits=0, crits=0, damage_dealt=0, damage_taken=13)
def test_count_swings_stop_when_target_drops() -> None:
@@ -310,16 +310,18 @@ def test_hp_states_active_down_dead() -> None:
assert st.active is True
assert st.dead is False
st.hp = 0
assert st.active is False
# B11 conformance: exactly 0 hp is DISABLED — active (single action round).
assert st.active is True
assert st.dead is False
st.hp = -1
assert st.active is False # dying
assert st.dead is False
st.hp = -11
assert st.active is False
assert st.dead is False
# B12 conformance (CRB): death when negative hp reaches CON (hp <= -12).
st.hp = -12
assert st.active is False
assert st.dead is False # dead requires hp < -12, not <=
st.hp = -13
assert st.active is False
assert st.dead is True
@@ -556,7 +558,7 @@ def test_ranged_no_line_of_effect_moves_around_wall() -> None:
archer = make_combatant("archer", attack_bonus=4, kind="ranged", range_increment_ft=60)
target = make_combatant("target", speed=0)
states = [make_state(archer, "players", (0, 1)), make_state(target, "monsters", (0, 4))]
rng = ScriptedRng([10, 9, 5, 15, 3, 10, 3, 12, 2, 8, 2, 14, 1])
rng = ScriptedRng([10, 9, 5, 15, 3, 10, 4, 12, 2, 8, 2, 14, 1])
engine = CombatEngine(rng, grid, states, round_cap=4)
result = engine.run()
assert result.winner == "players"
@@ -568,7 +570,7 @@ def test_ranged_no_line_of_effect_moves_around_wall() -> None:
"round 1 target: wait",
"round 2 archer: short bow vs target d20=15+4=19 AC 13 -> HIT 3 damage (6->3)",
"round 2 target: wait",
"round 3 archer: short bow vs target d20=10+4=14 AC 13 -> HIT 3 damage (3->0)",
"round 3 archer: short bow vs target d20=10+4=14 AC 13 -> HIT 4 damage (3->-1)",
"round 3 archer: target down",
"battle over: players win in 3 rounds",
)
@@ -842,24 +844,31 @@ def test_diagonal_flanking() -> None:
def test_flanking_requires_active_ally() -> None:
"""A downed ally on the opposite side does not grant flanking."""
"""A downed ally (below 0 hp, dying) on the opposite side does not flank.
B11 conformance: an ally at exactly 0 hp is disabled but active — it
would flank; only hp < 0 removes it from the fight."""
atk = make_combatant("atk", attack_bonus=2, speed=0)
aly = make_combatant("aly", attack_bonus=0, speed=0)
tgt = make_combatant("tgt", hp=10, ac=15, attack_bonus=2, speed=0)
aly_state = make_state(aly, "players", (5, 4))
aly_state.hp = 0
aly_state.hp = -1
states = [
make_state(atk, "players", (3, 4)),
aly_state,
make_state(tgt, "monsters", (4, 4)),
]
engine = make_engine([10, 9, 8, 12, 5], states, round_cap=1)
engine = make_engine([10, 9, 8, 12, 5, 5], states, round_cap=1)
result = engine.run()
assert result.transcript == (
"initiative: atk d20=10+0=10",
"initiative: aly d20=9+0=9",
"initiative: tgt d20=8+0=8",
"round 1 atk: short sword vs tgt d20=12+2=14 AC 15 -> MISS",
(
"round 1 aly: dying — Constitution check d20(5) + CON(1) - 1 = 5 "
"vs DC 10 failed, loses 1 hp (hp -1 -> -2)"
),
"round 1 tgt: short sword vs atk d20=5+2=7 AC 13 -> MISS",
"battle over: draw after 1 rounds",
)
@@ -935,9 +944,13 @@ def test_full_attack_bab11_three_iteratives() -> None:
def test_full_attack_stops_on_target_down() -> None:
"""Remaining iteratives are lost when the target drops mid-full-attack."""
"""Remaining iteratives are lost when the target drops mid-full-attack.
The scripted blow takes tgt below 0 (hp 4 - 5 = -1): a target at exactly
0 hp is disabled but still ACTIVE (B11 conformance) and would not drop.
"""
war = make_combatant("war", attack_bonus=6, hp=10, speed=0)
tgt = make_combatant("tgt", hp=5, ac=15, attack_bonus=0, speed=0)
tgt = make_combatant("tgt", hp=4, ac=15, attack_bonus=0, speed=0)
states = [
make_state(war, "players", (3, 4)),
make_state(tgt, "monsters", (4, 4)),
@@ -947,7 +960,7 @@ def test_full_attack_stops_on_target_down() -> None:
assert result.transcript == (
"initiative: war d20=10+0=10",
"initiative: tgt d20=8+0=8",
"round 1 war: short sword #1 vs tgt d20=12+6=18 AC 15 -> HIT 5 damage (5->0)",
"round 1 war: short sword #1 vs tgt d20=12+6=18 AC 15 -> HIT 5 damage (4->-1)",
"round 1 war: tgt down",
"battle over: players win in 1 rounds",
)
@@ -1020,7 +1033,7 @@ def test_aoo_drops_mover() -> None:
make_state(dest, "monsters", (4, 5)),
]
engine = CombatEngine(
ScriptedRng([10, 9, 8, 15, 3]),
ScriptedRng([10, 9, 8, 15, 4]),
make_grid(), states, round_cap=1, policy=_move_to_dest,
)
result = engine.run()
@@ -1029,7 +1042,7 @@ def test_aoo_drops_mover() -> None:
"initiative: runner d20=10+0=10",
"initiative: guard d20=9+0=9",
"initiative: dest d20=8+0=8",
"round 1 guard: AoO vs runner d20=15+5=20 AC 12 -> HIT 3 damage (3->0)",
"round 1 guard: AoO vs runner d20=15+5=20 AC 12 -> HIT 4 damage (3->-1)",
"round 1 guard: runner down",
"battle over: monsters win in 1 rounds",
)
+4 -2
View File
@@ -38,9 +38,11 @@ def test_blinded_attacker_misses_on_low_d100() -> None:
a, t = eng._states
_add_condition(a, "blinded")
result = eng.resolve_attack(a, t, a.combatant.attacks[0])
# roll 15 + bonus 5 - 2 (blinded) = 18 beats AC 10, then d100 30 <= 50 misses.
# roll 15 + bonus 5 = 20 beats AC 10 (B10 conformance: blinded carries NO
# flat attack penalty — the 50% total-concealment miss below is the RAW
# penalty), then d100 30 <= 50 misses.
assert result.hit is True
assert result.total == 18
assert result.total == 20
assert result.damage == 0
assert t.hp == 100
assert any("attacker blinded" in line and "misses" in line for line in eng._transcript)
+565
View File
@@ -0,0 +1,565 @@
"""P2/P3 PF1e conformance fixes (Oracle-adjudicated audit).
Twelve RAW deviations, one section per bug:
B8 Flanking: the flanking ally must actually THREATEN the target (a reach
weapon's 5-ft dead zone does not threaten) and must not be unable to act.
B9 Melee touch (kind "touch", no range increment) is a melee attack: it gains
flanking, high ground, the vs-helpless +4 and can sneak attack; Power
Attack stays melee-only.
B10 Blinded: NO flat -2 on attack rolls (the modeled 50% total-concealment
miss is the RAW penalty); the -4 Dex-skill penalty rides the dex_skills
target (B22).
B11 Disabled (exactly 0 hp): active, staggered (one move OR standard action),
half speed; a strenuous action costs 1 hp at its end (hp -> -1, dying).
B12 Death threshold: dead when hp <= -CON (PF1e has no -10 floor).
B13 Combat maneuvers provoke an AoO from the target unless the attacker holds
the Improved feat for that maneuver; escape never provokes; an AoO that
takes the attacker below 0 hp forfeits the maneuver.
B15 DR "and" bypass: a multi-component bypass list requires EVERY component
(the lich's DR 15/bludgeoning and magic is not beaten by a mundane
bludgeoning weapon).
B17 Crit confirmation: a natural 20 on the confirmation roll auto-confirms.
B18 Maneuver CMB: natural 20 always succeeds, natural 1 always fails.
B19 moved_this_turn: charge and withdraw are mutually exclusive with movement
already spent this turn, like move/5-foot-step/run.
B22 dex_skills: the aggregate "dex_skills" modifier target now actually
applies to the Dexterity-based skills.
B23 Sneak attack: undead and constructs are immune to precision damage.
"""
from __future__ import annotations
from collections.abc import Callable
from pf1e_simulator.abilities import AbilitySpec
from pf1e_simulator.combat import Action, CombatantState, CombatEngine
from pf1e_simulator.conditions import CONDITIONS_BY_NAME
from pf1e_simulator.dice import parse_dice
from pf1e_simulator.effects import ActiveCondition
from pf1e_simulator.grid import Grid
from pf1e_simulator.map import MapSpec, TerrainType
from pf1e_simulator.models import (
AbilityScores,
ACProfile,
AttackSpec,
Combatant,
DamageComponent,
DamageReduction,
Saves,
)
from pf1e_simulator.rng import ScriptedRng
PolicyFn = Callable[[CombatEngine, CombatantState], tuple[Action, ...]]
def _sword(cid: str, bonus: int = 5) -> AttackSpec:
return AttackSpec(
id=f"{cid}-sword",
name="short sword",
kind="melee",
attack_bonus=bonus,
damage=[DamageComponent(formula=parse_dice("1d6"), types=["slashing"])],
)
def _reach_sword(cid: str) -> AttackSpec:
return AttackSpec(
id=f"{cid}-glaive",
name="glaive",
kind="melee",
attack_bonus=5,
damage=[DamageComponent(formula=parse_dice("1d6"), types=["slashing"])],
reach_ft=10,
)
def _touch(cid: str, bonus: int = 10, increment: int | None = None) -> AttackSpec:
return AttackSpec(
id=f"{cid}-touch",
name="paralyzing touch",
kind="touch",
attack_bonus=bonus,
damage=[DamageComponent(formula=parse_dice("1d6"), types=["cold"])],
range_increment_ft=increment,
)
def _club(cid: str, types: tuple[str, ...] = ("bludgeoning",)) -> AttackSpec:
return AttackSpec(
id=f"{cid}-club",
name="club",
kind="melee",
attack_bonus=5,
damage=[DamageComponent(formula=parse_dice("1d6"), types=list(types))],
)
def _combatant(
cid: str,
*,
level: int = 1,
hp: int = 100,
ac: int = 10,
con: int = 10,
bab: int = 1,
creature_type: str = "living",
dr: DamageReduction | None = None,
attacks: tuple[AttackSpec, ...] = (),
features: tuple[str, ...] = (),
cmd: int | None = None,
) -> Combatant:
return Combatant(
id=cid,
name=cid,
level=level,
size="Medium",
creature_type=creature_type,
abilities=AbilityScores(
str_score=10, dex_score=10, con_score=con,
int_score=10, wis_score=10, cha_score=10,
),
hp_max=hp,
ac=ACProfile(total=ac, touch=ac, flat_footed=ac),
bab=bab,
initiative_mod=0,
speed_land_ft=30,
saves=Saves(fort=0, ref=0, will=0),
attacks=list(attacks) or [_sword(cid)],
features=[AbilitySpec(name=f, category="feat") for f in features],
dr=dr,
cmd=cmd,
)
def _engine(
combatants: list[Combatant],
positions: list[tuple[int, int]],
sides: list[str],
rng: list[int],
policy: PolicyFn | None = None,
) -> CombatEngine:
states = [
CombatantState(combatant=c, side=side, pos=pos, hp=c.hp_max)
for c, pos, side in zip(combatants, positions, sides, strict=True)
]
legend = {".": TerrainType(type="floor", move_cost=1)}
spec = MapSpec(name="test", terrain=tuple(["." * 8] * 8), legend=legend)
grid = Grid.from_spec(spec)
kwargs: dict[str, object] = {} if policy is None else {"policy": policy}
return CombatEngine(ScriptedRng(rng), grid, states, **kwargs)
def _add_condition(state: CombatantState, name: str) -> None:
state.active_conditions.append(
ActiveCondition(
condition=CONDITIONS_BY_NAME[name], rounds_left=5, source="test"
)
)
# ── B8: flanking requires an ally that actually threatens ────────────────────
def test_flanking_denied_to_reach_ally_in_dead_zone() -> None:
# Ally on the mirror cell with a reach weapon: the target stands 5 ft
# away — inside the weapon's dead zone, so it is NOT threatened (CRB).
atk = _combatant("atk")
ally = _combatant("aly", attacks=(_reach_sword("aly"),))
tgt = _combatant("tgt")
eng = _engine([atk, ally, tgt], [(0, 0), (0, 2), (0, 1)],
["players", "players", "monsters"], [])
assert eng._flanking_bonus(eng._states[0], eng._states[2]) == 0
def test_flanking_granted_by_threatening_ally() -> None:
# Control: the same geometry with a normal melee weapon threatens.
atk = _combatant("atk")
ally = _combatant("aly")
tgt = _combatant("tgt")
eng = _engine([atk, ally, tgt], [(0, 0), (0, 2), (0, 1)],
["players", "players", "monsters"], [])
assert eng._flanking_bonus(eng._states[0], eng._states[2]) == 2
def test_flanking_denied_to_incapacitated_ally() -> None:
# A stunned ally (cant_act) cannot threaten: no flanking (CRB).
atk = _combatant("atk")
ally = _combatant("aly")
tgt = _combatant("tgt")
eng = _engine([atk, ally, tgt], [(0, 0), (0, 2), (0, 1)],
["players", "players", "monsters"], [])
_add_condition(eng._states[1], "stunned")
assert eng._flanking_bonus(eng._states[0], eng._states[2]) == 0
# ── B9: melee touch attacks are melee attacks ────────────────────────────────
def test_melee_touch_gains_flanking() -> None:
atk = _combatant("atk", attacks=(_touch("atk"),))
ally = _combatant("aly")
tgt = _combatant("tgt")
eng = _engine([atk, ally, tgt], [(0, 0), (0, 2), (0, 1)],
["players", "players", "monsters"], [12, 3])
result = eng.resolve_attack(eng._states[0], eng._states[2], _touch("atk"))
# roll 12 + bonus 10 + 2 (flank) = 24 — the melee-touch swing flanks.
assert result.flank_bonus == 2
assert result.total == 24
def test_melee_touch_gains_helpless_bonus() -> None:
atk = _combatant("atk", attacks=(_touch("atk"),))
tgt = _combatant("tgt")
eng = _engine([atk, tgt], [(0, 0), (0, 1)], ["players", "monsters"], [10, 3])
_add_condition(eng._states[1], "paralyzed")
result = eng.resolve_attack(eng._states[0], eng._states[1], _touch("atk"))
# roll 10 + bonus 10 + 4 (melee vs helpless) = 24.
assert result.total == 24
def test_melee_touch_can_sneak_attack() -> None:
rogue = _combatant(
"rogue", level=3, features=("Sneak Attack",), attacks=(_touch("rogue"),)
)
tgt = _combatant("tgt")
eng = _engine([rogue, tgt], [(0, 0), (0, 1)], ["players", "monsters"],
[15, 2, 3, 4])
_add_condition(eng._states[1], "flat_footed")
result = eng.resolve_attack(eng._states[0], eng._states[1], _touch("rogue"))
# 2d6 sneak (2+3) added to the 1d6 weapon die (4): precision on a touch.
assert result.damage == 9
assert any("sneak attack" in line for line in eng._transcript)
def test_power_attack_still_excludes_melee_touch() -> None:
atk = _combatant(
"atk", bab=5, attacks=(_touch("atk"),), features=("Power Attack",)
)
tgt = _combatant("tgt")
eng = _engine([atk, tgt], [(0, 0), (0, 1)], ["players", "monsters"], [10, 2])
a = eng._states[0]
a.power_attack = True
result = eng.resolve_attack(a, eng._states[1], _touch("atk"))
# Power Attack is melee-weapon-only: the touch swing keeps its full bonus.
assert result.total == 10 + 10
# ── B10: blinded has no flat attack penalty ──────────────────────────────────
def test_blinded_condition_has_no_attack_modifier() -> None:
blinded = CONDITIONS_BY_NAME["blinded"]
assert all(m.target != "attack" for m in blinded.modifiers)
def test_blinded_attacker_suffers_no_attack_penalty() -> None:
atk = _combatant("atk")
tgt = _combatant("tgt", ac=99)
eng = _engine([atk, tgt], [(0, 0), (0, 1)], ["players", "monsters"], [10])
_add_condition(eng._states[0], "blinded")
result = eng.resolve_attack(eng._states[0], eng._states[1], _sword("atk"))
# roll 10 + bonus 5 = 15 — the RAW penalty is the 50% miss, not the hit.
assert result.total == 15
def test_blinded_imposes_minus4_dex_skills() -> None:
hero = _combatant("hero")
eng = _engine([hero], [(0, 0)], ["players"], [])
s = eng._states[0]
_add_condition(s, "blinded")
assert eng._skill_static_bonus(s, "stealth") == -4
assert eng._skill_static_bonus(s, "perception") == 0
# ── B11: disabled at exactly 0 hp ────────────────────────────────────────────
def _disabled_attack_policy(
_engine: CombatEngine, state: CombatantState
) -> tuple[Action, ...]:
if state.combatant.id == "hero":
return (Action(kind="full_attack", target_id="foe"),)
return (Action(kind="wait"),)
def test_disabled_is_active_staggered_and_half_speed() -> None:
hero = _combatant("hero")
foe = _combatant("foe")
eng = _engine([hero, foe], [(0, 0), (0, 1)], ["players", "monsters"], [])
s = eng._states[0]
s.hp = 0
assert s.active is True
assert s.dead is False
assert eng._get_speed_mult(s) == 0.5
def test_disabled_full_attack_downgrades_and_costs_one_hp() -> None:
hero = _combatant("hero")
foe = _combatant("foe")
eng = _engine(
[hero, foe], [(0, 0), (0, 1)], ["players", "monsters"], [15, 6],
policy=_disabled_attack_policy,
)
s = eng._states[0]
s.hp = 0
eng._current_round = 1
eng._take_turn(s)
# Staggered: the full attack collapsed to a single standard attack, and
# completing that strenuous action costs 1 hp -> dying (CRB, Disabled).
assert s.hp == -1
assert s.active is False
assert any("strenuous action costs 1 hp" in ln for ln in eng._transcript)
assert any("HIT 6 damage (100->94)" in ln for ln in eng._transcript)
reasons = [(r.kind, r.reason) for r in s.last_action_results]
assert ("full_attack", "downgraded:staggered") in reasons
assert ("attack", "ok") in reasons
def test_disabled_move_action_costs_no_hp() -> None:
hero = _combatant("hero")
foe = _combatant("foe")
def policy(_engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
if state.combatant.id == "hero":
return (Action(kind="move", target_id="foe"),)
return (Action(kind="wait"),)
eng = _engine(
[hero, foe], [(0, 0), (0, 7)], ["players", "monsters"], [],
policy=policy,
)
s = eng._states[0]
s.hp = 0
eng._current_round = 1
eng._take_turn(s)
# A move action is not strenuous: the disabled creature stays at 0 hp.
assert s.hp == 0
assert not any("costs 1 hp" in ln for ln in eng._transcript)
# ── B12: death threshold is -CON, no -10 floor ───────────────────────────────
def test_death_at_minus_con_no_floor() -> None:
scrawny = _combatant("scrawny", con=8)
hardy = _combatant("hardy", con=16)
eng = _engine([scrawny, hardy], [(0, 0), (0, 4)],
["players", "monsters"], [])
s, h = eng._states
s.hp = -7
assert s.dead is False
s.hp = -8
assert s.dead is True # hp <= -CON
h.hp = -10
assert h.dead is False # no -10 rule in PF1e
h.hp = -16
assert h.dead is True
# ── B13: maneuvers provoke an AoO from the target ────────────────────────────
def test_maneuver_provokes_target_aoo_before_cmb() -> None:
a = _combatant("a")
b = _combatant("b")
eng = _engine([a, b], [(1, 1), (1, 2)], ["players", "monsters"], [1, 12])
eng._execute_maneuver(eng._states[0], eng._states[1], "trip")
lines = "\n".join(eng._transcript).lower()
# The target's AoO (d20 1 — natural 1 miss) resolves BEFORE the CMB check.
assert "aoo vs a" in lines
assert lines.index("aoo vs a") < lines.index("trip vs b")
assert any(
ac.condition.name == "prone" for ac in eng._states[1].active_conditions
)
def test_maneuver_aoo_can_drop_attacker_and_forfeit_trip() -> None:
a = _combatant("a", hp=3)
b = _combatant("b", attacks=(_sword("b", bonus=5),))
# b's AoO: d20 15 + 5 = 20 vs AC 10 -> HIT, damage 6 -> a at -3 (dying).
eng = _engine([a, b], [(1, 1), (1, 2)], ["players", "monsters"], [15, 6])
eng._execute_maneuver(eng._states[0], eng._states[1], "trip")
assert eng._states[0].hp == -3
assert eng._states[0].active is False
# The CMB check never happened: the queue is exhausted exactly by the AoO.
assert not eng._rng._queue
assert not any(
ac.condition.name == "prone" for ac in eng._states[1].active_conditions
)
def test_improved_trip_skips_the_provoking_aoo() -> None:
a = _combatant("a", features=("Improved Trip",))
b = _combatant("b")
eng = _engine([a, b], [(1, 1), (1, 2)], ["players", "monsters"], [12])
eng._execute_maneuver(eng._states[0], eng._states[1], "trip")
assert not any("aoo" in ln.lower() for ln in eng._transcript)
assert any(
ac.condition.name == "prone" for ac in eng._states[1].active_conditions
)
def test_escape_does_not_provoke() -> None:
a = _combatant("a")
b = _combatant("b")
eng = _engine([a, b], [(1, 1), (1, 2)], ["players", "monsters"], [20])
_add_condition(eng._states[0], "grappled")
eng._states[0].grappled_with = "b"
eng._execute_maneuver(eng._states[0], eng._states[1], "escape")
# Exactly one roll consumed (the escape check): no AoO die was spent.
assert not eng._rng._queue
assert eng._states[0].grappled_with is None
# ── B15: DR "and" bypass requires every component ────────────────────────────
def test_dr_and_bypass_requires_all_components() -> None:
lich_dr = DamageReduction(
amount=15, bypass=frozenset({"bludgeoning", "magic"})
)
lich = _combatant("lich", dr=lich_dr)
eng = _engine([lich], [(0, 0)], ["monsters"], [])
# A mundane bludgeoning weapon does NOT bypass DR 15/bludgeoning and magic.
assert eng._apply_dr(20, _club("m"), eng._states[0]) == 5
# Only an attack carrying EVERY component bypasses (model gap: weapons
# carry no "magic" property, so this stays unreachable for real gear).
assert eng._apply_dr(
20, _club("e", types=("bludgeoning", "magic")), eng._states[0]
) == 20
def test_dr_single_component_bypass_unchanged() -> None:
dr = DamageReduction(amount=5, bypass=frozenset({"slashing"}))
fighter = _combatant("f", dr=dr)
eng = _engine([fighter], [(0, 0)], ["monsters"], [])
assert eng._apply_dr(10, _sword("w"), eng._states[0]) == 10
assert eng._apply_dr(10, _club("w"), eng._states[0]) == 5
# ── B17: natural 20 auto-confirms a critical ─────────────────────────────────
def test_natural_twenty_auto_confirms_crit() -> None:
atk = _combatant("atk")
tgt = _combatant("tgt", ac=99)
eng = _engine([atk, tgt], [(0, 0), (0, 1)], ["players", "monsters"],
[20, 20, 6])
result = eng.resolve_attack(eng._states[0], eng._states[1], _sword("atk"))
# Threaten on nat 20; confirm nat 20 is an automatic hit even though
# 20 + 5 = 25 < AC 99 (old engine: no crit).
assert result.hit is True
assert result.crit is True
assert result.damage == 12
# ── B18: natural 20 auto-succeeds a CMB check ────────────────────────────────
def test_maneuver_natural_twenty_auto_succeeds() -> None:
a = _combatant("a", features=("Improved Trip",))
tgt = _combatant("tgt", cmd=99)
eng = _engine([a, tgt], [(1, 1), (1, 2)], ["players", "monsters"], [20])
eng._execute_maneuver(eng._states[0], eng._states[1], "trip")
# 20 + CMB 1 = 21 << CMD 99, yet the natural 20 succeeds (attack roll).
assert any(
ac.condition.name == "prone" for ac in eng._states[1].active_conditions
)
def test_maneuver_natural_one_still_fails() -> None:
a = _combatant("a", features=("Improved Trip",))
tgt = _combatant("tgt", cmd=2)
eng = _engine([a, tgt], [(1, 1), (1, 2)], ["players", "monsters"], [1])
eng._execute_maneuver(eng._states[0], eng._states[1], "trip")
assert not any(
ac.condition.name == "prone" for ac in eng._states[1].active_conditions
)
# ── B19: charge/withdraw respect moved_this_turn ─────────────────────────────
def test_charge_after_move_is_noop() -> None:
a = _combatant("a")
b = _combatant("b")
eng = _engine([a, b], [(0, 0), (0, 5)], ["players", "monsters"], [])
s = eng._states[0]
s.moved_this_turn = True
eng._current_round = 1
eng._execute(s, Action(kind="charge", target_id="b"))
# The empty RNG queue would raise on any roll: the charge never ran.
assert s.pos == (0, 0)
assert not any("charge" in ln for ln in eng._transcript)
def test_withdraw_after_move_is_noop() -> None:
a = _combatant("a")
b = _combatant("b")
eng = _engine([a, b], [(0, 0), (0, 1)], ["players", "monsters"], [])
s = eng._states[0]
s.moved_this_turn = True
eng._withdraw(s, eng._states[1])
assert s.pos == (0, 0)
assert not any("withdraw" in ln for ln in eng._transcript)
# ── B22: dex_skills modifiers reach Dex-based skill checks ───────────────────
def test_grappled_dex_skills_minus4_applies() -> None:
hero = _combatant("hero")
eng = _engine([hero], [(0, 0)], ["players"], [])
s = eng._states[0]
_add_condition(s, "grappled")
assert eng._skill_static_bonus(s, "stealth") == -4
assert eng._skill_static_bonus(s, "acrobatics") == -4
assert eng._skill_static_bonus(s, "escape_artist") == -4
# Wisdom (and other non-Dex) skills are untouched by dex_skills.
assert eng._skill_static_bonus(s, "perception") == 0
def test_pinned_dex_skills_minus4_applies() -> None:
hero = _combatant("hero")
eng = _engine([hero], [(0, 0)], ["players"], [])
s = eng._states[0]
_add_condition(s, "pinned")
assert eng._skill_static_bonus(s, "stealth") == -4
# ── B23: undead and constructs are immune to precision damage ────────────────
def test_sneak_attack_denied_vs_undead() -> None:
rogue = _combatant("rogue", level=3, features=("Sneak Attack",))
skeleton = _combatant("skel", creature_type="undead")
eng = _engine([rogue, skeleton], [(0, 0), (0, 1)],
["players", "monsters"], [15, 4])
_add_condition(eng._states[1], "flat_footed")
result = eng.resolve_attack(eng._states[0], eng._states[1], _sword("rogue"))
# Weapon die only — the precision dice never roll against undead.
assert result.damage == 4
assert not any("sneak" in ln for ln in eng._transcript)
def test_sneak_attack_denied_vs_construct() -> None:
rogue = _combatant("rogue", level=3, features=("Sneak Attack",))
golem = _combatant("golem", creature_type="construct")
eng = _engine([rogue, golem], [(0, 0), (0, 1)],
["players", "monsters"], [15, 4])
_add_condition(eng._states[1], "flat_footed")
result = eng.resolve_attack(eng._states[0], eng._states[1], _sword("rogue"))
assert result.damage == 4
assert not any("sneak" in ln for ln in eng._transcript)
def test_sneak_attack_still_works_vs_living_control() -> None:
rogue = _combatant("rogue", level=3, features=("Sneak Attack",))
victim = _combatant("man")
eng = _engine([rogue, victim], [(0, 0), (0, 1)],
["players", "monsters"], [15, 2, 3, 4])
_add_condition(eng._states[1], "flat_footed")
result = eng.resolve_attack(eng._states[0], eng._states[1], _sword("rogue"))
assert result.damage == 9
assert any("sneak attack" in ln for ln in eng._transcript)
+13 -10
View File
@@ -63,6 +63,9 @@ def make_grid() -> Grid:
def make_engine(queue: list[int], states: list[CombatantState]) -> CombatEngine:
# A maneuver provokes an AoO from the (threatening) target BEFORE the CMB
# check (B13 conformance): the scripted queues carry a leading natural 1
# so the provoking attack always misses.
return CombatEngine(ScriptedRng(queue), make_grid(), states)
@@ -73,7 +76,7 @@ def test_reposition_direction_right() -> None:
b_spec = make_combatant("b")
a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (1, 2))
engine = make_engine([12], [a, b])
engine = make_engine([1, 12], [a, b])
engine._execute_maneuver(a, b, "reposition", direction=(0, 1))
assert b.pos == (1, 3)
@@ -83,7 +86,7 @@ def test_reposition_direction_down() -> None:
b_spec = make_combatant("b")
a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (1, 2))
engine = make_engine([12], [a, b])
engine = make_engine([1, 12], [a, b])
engine._execute_maneuver(a, b, "reposition", direction=(1, 0))
assert b.pos == (2, 2)
@@ -95,7 +98,7 @@ def test_drag_direction_up() -> None:
b_spec = make_combatant("b")
a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (1, 2))
engine = make_engine([12], [a, b])
engine = make_engine([1, 12], [a, b])
engine._execute_maneuver(a, b, "drag", direction=(-1, 0))
assert b.pos == (0, 2)
assert a.pos == (1, 2)
@@ -108,7 +111,7 @@ def test_bull_rush_direction_left() -> None:
b_spec = make_combatant("b")
a = make_state(a_spec, "players", (2, 2))
b = make_state(b_spec, "monsters", (1, 2))
engine = make_engine([12], [a, b])
engine = make_engine([1, 12], [a, b])
engine._execute_maneuver(a, b, "bull_rush", direction=(0, -1))
assert b.pos == (1, 1)
@@ -120,7 +123,7 @@ def test_reposition_direction_out_of_bounds_no_move() -> None:
b_spec = make_combatant("b")
a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (0, 0))
engine = make_engine([12], [a, b])
engine = make_engine([1, 12], [a, b])
engine._execute_maneuver(a, b, "reposition", direction=(-1, 0))
assert b.pos == (0, 0)
@@ -130,7 +133,7 @@ def test_reposition_direction_occupied_no_move() -> None:
b_spec = make_combatant("b")
a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (1, 2))
engine = make_engine([12], [a, b])
engine = make_engine([1, 12], [a, b])
# direction points at the attacker's own cell -> occupied -> no move
engine._execute_maneuver(a, b, "reposition", direction=(0, -1))
assert b.pos == (1, 2)
@@ -141,7 +144,7 @@ def test_bull_rush_direction_out_of_bounds_no_move() -> None:
b_spec = make_combatant("b")
a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (0, 0))
engine = make_engine([12], [a, b])
engine = make_engine([1, 12], [a, b])
engine._execute_maneuver(a, b, "bull_rush", direction=(-1, 0))
assert b.pos == (0, 0)
@@ -153,7 +156,7 @@ def test_reposition_none_uses_first_free_neighbor() -> None:
b_spec = make_combatant("b")
a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (1, 2))
engine = make_engine([12], [a, b])
engine = make_engine([1, 12], [a, b])
engine._execute_maneuver(a, b, "reposition")
# First free neighbor in _NEIGHBOR_OFFSETS order, skipping attacker's cell.
assert b.pos == (0, 1)
@@ -164,7 +167,7 @@ def test_drag_none_uses_beyond_cell() -> None:
b_spec = make_combatant("b")
a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (1, 2))
engine = make_engine([12], [a, b])
engine = make_engine([1, 12], [a, b])
engine._execute_maneuver(a, b, "drag")
# Beyond the target from the attacker: (1, 3).
assert b.pos == (1, 3)
@@ -176,7 +179,7 @@ def test_bull_rush_none_uses_away_from_attacker() -> None:
b_spec = make_combatant("b")
a = make_state(a_spec, "players", (2, 2))
b = make_state(b_spec, "monsters", (1, 2))
engine = make_engine([12], [a, b])
engine = make_engine([1, 12], [a, b])
engine._execute_maneuver(a, b, "bull_rush")
# Away from attacker (up, since target is above): (0, 2).
assert b.pos == (0, 2)
+9 -6
View File
@@ -63,6 +63,9 @@ def make_grid() -> Grid:
def make_engine(queue: list[int], states: list[CombatantState]) -> CombatEngine:
# A maneuver provokes an AoO from the (threatening) target BEFORE the CMB
# check (B13 conformance): the scripted queues carry a leading natural 1
# so the provoking attack always misses.
return CombatEngine(ScriptedRng(queue), make_grid(), states)
@@ -77,7 +80,7 @@ def test_dirty_trick_blinded() -> None:
b_spec = make_combatant("b")
a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (1, 2))
engine = make_engine([12], [a, b])
engine = make_engine([1, 12], [a, b])
engine._execute_maneuver(a, b, "dirty_trick", trick_condition="blinded")
assert _condition_names(b) == ["blinded"]
@@ -89,7 +92,7 @@ def test_dirty_trick_default_dazzled() -> None:
b_spec = make_combatant("b")
a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (1, 2))
engine = make_engine([12], [a, b])
engine = make_engine([1, 12], [a, b])
engine._execute_maneuver(a, b, "dirty_trick")
assert _condition_names(b) == ["dazzled"]
@@ -101,7 +104,7 @@ def test_dirty_trick_deafened() -> None:
b_spec = make_combatant("b")
a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (1, 2))
engine = make_engine([12], [a, b])
engine = make_engine([1, 12], [a, b])
engine._execute_maneuver(a, b, "dirty_trick", trick_condition="deafened")
assert _condition_names(b) == ["deafened"]
@@ -111,7 +114,7 @@ def test_dirty_trick_entangled() -> None:
b_spec = make_combatant("b")
a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (1, 2))
engine = make_engine([12], [a, b])
engine = make_engine([1, 12], [a, b])
engine._execute_maneuver(a, b, "dirty_trick", trick_condition="entangled")
assert _condition_names(b) == ["entangled"]
@@ -121,7 +124,7 @@ def test_dirty_trick_nauseated() -> None:
b_spec = make_combatant("b")
a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (1, 2))
engine = make_engine([12], [a, b])
engine = make_engine([1, 12], [a, b])
engine._execute_maneuver(a, b, "dirty_trick", trick_condition="nauseated")
assert _condition_names(b) == ["nauseated"]
@@ -133,7 +136,7 @@ def test_dirty_trick_failure_no_condition() -> None:
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], [a, b]) # roll 1 -> automatic failure
engine = make_engine([1, 1], [a, b]) # AoO miss, then roll 1 -> automatic failure
engine._execute_maneuver(a, b, "dirty_trick", trick_condition="blinded")
assert _condition_names(b) == []
+5 -6
View File
@@ -156,19 +156,18 @@ def test_stabilized_then_healed_resets_stabilization() -> None:
def test_dying_bleeds_to_death_threshold() -> None:
eng, s = _solo(con_score=10) # death threshold: hp < min(-10, -10) = -10
# PF1e (B12 conformance): death when negative hp reaches the CON score
# (hp <= -CON = -10 here); there is no -10 floor.
eng, s = _solo(con_score=10)
s.hp = -9
eng._rng = ScriptedRng([1, 1]) # two failed checks: -9 -> -10 -> -11
eng._rng = ScriptedRng([1]) # failed check: -9 -> -10 reaches the threshold
eng._process_dying(s)
assert s.hp == -10
assert not s.dead
eng._process_dying(s)
assert s.hp == -11
assert s.dead
# Dead creatures never roll again (empty queue would raise if they did).
eng._rng = ScriptedRng([])
eng._process_dying(s)
assert s.hp == -11
assert s.hp == -10
def test_dying_tick_runs_on_initiative_slot_during_round() -> None:
+15 -11
View File
@@ -66,6 +66,9 @@ def make_grid() -> Grid:
def make_engine(
queue: list[int], states: list[CombatantState]
) -> CombatEngine:
# A maneuver provokes an AoO from the (threatening) target BEFORE the CMB
# check (B13 conformance): scripted queues carry a leading natural 1 so
# the provoking attack always misses.
return CombatEngine(ScriptedRng(queue), make_grid(), states)
@@ -75,7 +78,7 @@ def test_sunder_damages_weapon_not_hp() -> None:
a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (1, 2))
hp_before = b.hp
engine = make_engine([12], [a, b])
engine = make_engine([1, 12], [a, b])
engine._execute_maneuver(a, b, "sunder")
assert b.hp == hp_before
assert b.weapon_damage["b-w"] == 2
@@ -87,7 +90,8 @@ def test_sunder_destroys_weapon_and_disarms() -> None:
b_spec = make_combatant("b")
a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (1, 2))
engine = make_engine([12, 12, 12, 12, 12], [a, b])
# Only the first sunder provokes: b's per-round AoO allowance is spent.
engine = make_engine([1, 12, 12, 12, 12, 12], [a, b])
for _ in range(5):
engine._execute_maneuver(a, b, "sunder")
assert b.hp == 6
@@ -123,7 +127,7 @@ def test_overrun_knocks_prone_and_moves_through() -> None:
b_spec = make_combatant("b")
a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (1, 2))
engine = make_engine([12], [a, b])
engine = make_engine([1, 12], [a, b])
engine._execute_maneuver(a, b, "overrun")
prone = next(c for c in b.active_conditions if c.condition.name == "prone")
assert prone.rounds_left == 1
@@ -138,7 +142,7 @@ def test_overrun_prone_but_no_space() -> None:
a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (1, 2))
c = make_state(c_spec, "monsters", (1, 3))
engine = make_engine([12], [a, b, c])
engine = make_engine([1, 12], [a, b, c])
engine._execute_maneuver(a, b, "overrun")
assert any(cond.condition.name == "prone" for cond in b.active_conditions)
assert a.pos == (1, 1)
@@ -151,7 +155,7 @@ def test_dirty_trick_dazzles() -> None:
b_spec = make_combatant("b")
a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (1, 2))
engine = make_engine([12], [a, b])
engine = make_engine([1, 12], [a, b])
engine._execute_maneuver(a, b, "dirty_trick")
dazzled = next(c for c in b.active_conditions if c.condition.name == "dazzled")
assert dazzled.rounds_left == 1
@@ -163,7 +167,7 @@ def test_dirty_trick_fails_on_natural_1() -> None:
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], [a, b])
engine = make_engine([1, 1], [a, b])
engine._execute_maneuver(a, b, "dirty_trick")
assert not any(c.condition.name == "dazzled" for c in b.active_conditions)
assert not any(c.condition.name == "prone" for c in b.active_conditions)
@@ -176,7 +180,7 @@ def test_steal_takes_melee_weapon() -> None:
a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (1, 2))
hp_before = b.hp
engine = make_engine([12], [a, b])
engine = make_engine([1, 12], [a, b])
engine._execute_maneuver(a, b, "steal")
assert "b-w" in b.weapon_stolen
assert engine.weapon_for(b, a) is None
@@ -201,7 +205,7 @@ def test_reposition_moves_to_first_free_neighbor() -> None:
a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (1, 2))
c = make_state(c_spec, "monsters", (1, 3))
engine = make_engine([12], [a, b, c])
engine = make_engine([1, 12], [a, b, c])
engine._execute_maneuver(a, b, "reposition")
assert b.pos == (0, 1)
assert c.pos == (1, 3)
@@ -217,7 +221,7 @@ def test_reposition_no_space() -> None:
]
a = make_state(a_spec, "players", (2, 1))
b = make_state(b_spec, "monsters", (1, 1))
engine = make_engine([12], [a, b, *blockers])
engine = make_engine([1, 12], [a, b, *blockers])
engine._execute_maneuver(a, b, "reposition")
assert b.pos == (1, 1)
assert "no space" in engine._transcript[-1]
@@ -228,7 +232,7 @@ def test_drag_moves_pair_together() -> None:
b_spec = make_combatant("b")
a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (1, 2))
engine = make_engine([12], [a, b])
engine = make_engine([1, 12], [a, b])
engine._execute_maneuver(a, b, "drag")
assert b.pos == (1, 3)
assert a.pos == (1, 2)
@@ -241,7 +245,7 @@ def test_drag_no_space() -> None:
a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (1, 2))
c = make_state(c_spec, "monsters", (1, 3))
engine = make_engine([12], [a, b, c])
engine = make_engine([1, 12], [a, b, c])
engine._execute_maneuver(a, b, "drag")
assert b.pos == (1, 2)
assert a.pos == (1, 1)
+6 -3
View File
@@ -825,14 +825,17 @@ def test_observation_enriched_threatened_bounded_to_grid() -> None:
def test_observation_enriched_threatened_ignores_inactive_enemy() -> None:
"""Given: an enemy whose hp <= 0 (inactive)
"""Given: an enemy whose hp < 0 (dying, inactive)
When: an Observation is built
Then: threatened has no True cells (inactive enemies do not threaten)."""
Then: threatened has no True cells (inactive enemies do not threaten).
B11 conformance: at exactly 0 hp a creature is disabled but still active
and threatening — downing it requires hp < 0."""
actor = make_combatant("actor")
enemy = make_combatant("enemy")
actor_state = make_state(actor, "players", (0, 0))
enemy_state = make_state(enemy, "monsters", (0, 3))
enemy_state.hp = 0
enemy_state.hp = -1
engine = make_engine([actor_state, enemy_state])
obs = _snapshot(engine, actor_state)
+4 -2
View File
@@ -233,11 +233,13 @@ def test_stand_up_removes_trip_applied_prone() -> None:
hero = make_state(_combatant("hero"), "players", (0, 0))
foe = make_state(_combatant("foe"), "monsters", (0, 1))
eng = CombatEngine(
ScriptedRng([10, 4, 3]), make_grid(), [hero, foe],
ScriptedRng([1, 10, 4, 3]), make_grid(), [hero, foe],
policy=lambda _e, _s: _WAIT, round_cap=1,
)
eng._current_round = 1
# Real trip handler: d20 10 + CMB 1 = 11 vs CMD 11 -> prone (active_conditions).
# The prone hero threatens, so the trip provokes its AoO first (d20 1 ->
# natural 1 miss); then the real trip handler: d20 10 + CMB 1 = 11 vs
# CMD 11 -> prone (active_conditions).
eng._execute_maneuver(foe, hero, "trip")
assert "trip vs hero d20=10+1=11 CMD 11 -> SUCCESS (prone)" in "\n".join(
eng._transcript
+1 -1
View File
@@ -186,7 +186,7 @@ def test_ranged_threat_empty_without_ranged_enemies() -> None:
archer = make_state(
_combatant("archer", _ranged_weapon("archer", "ranged", 60)), "monsters", (0, 0)
)
archer.hp = 0 # inactive: hp <= 0
archer.hp = -1 # inactive: hp < 0 (0 hp would be disabled-but-active, B11)
hero = make_state(_combatant("hero", _sword("hero")), "players", (4, 4))
engine = _engine([hero, melee_foe, archer], _open_rows())
+1 -1
View File
@@ -144,7 +144,7 @@ def test_sunder_then_repair_restores_weapon() -> None:
b_spec = make_combatant("b")
a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (1, 2))
engine = make_engine([12] * 5, [a, b])
engine = make_engine([1] + [12] * 5, [a, b])
for _ in range(5):
engine._execute(a, Action(kind="maneuver", target_id="b", maneuver="sunder"))
assert "b-w" in b.weapon_broken
+2 -2
View File
@@ -77,7 +77,7 @@ def test_retrieve_restores_stolen_weapon() -> None:
b_spec = make_combatant("b")
a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (1, 2))
engine = make_engine([12], [a, b])
engine = make_engine([1, 12], [a, b])
engine._execute(a, Action(kind="maneuver", target_id="b", maneuver="steal"))
assert "b-w" in b.weapon_stolen
assert "b-w" not in _weapon_ids(b)
@@ -121,7 +121,7 @@ def test_steal_then_retrieve_restores_weapon() -> None:
b_spec = make_combatant("b")
a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (1, 2))
engine = make_engine([12], [a, b])
engine = make_engine([1, 12], [a, b])
engine._execute(a, Action(kind="maneuver", target_id="b", maneuver="steal"))
assert "b-w" in b.weapon_stolen
assert engine.weapon_for(b, a) is None