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 : Règles modélisées :
- 1 naturel = échec automatique ; 20 naturel = touche + menace de critique. - 1 naturel = échec automatique ; 20 naturel = touche + menace de critique.
- Critique : (dés + bonus) × multiplicateur ; la RD s'applique après - Critique : (dés + bonus) × multiplicateur ; le 20 naturel au jet de
multiplication, les types perforants annulent la RD, les dégâts plancher à 0. 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 - Critique améliorée (« Improved Critical ») : le don double la plage de
menace — plage effective = `21 - 2 × (21 - crit_range)` (20 → 19-20, 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 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 - Mourir et stabilisation : une créature à `hp < 0` mais pas morte est
mourante — inconsciente (inactive), elle perd 1 pv par tour si elle échoue 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 − 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 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 pv, reste inconsciente). Une nouvelle blessure sous le seuil de stabilisation
(`hp < stabilized_hp`) fait reprendre l'agonie (nouveau jet) ; un soin (`hp < stabilized_hp`) fait reprendre l'agonie (nouveau jet) ; un soin
ramenant à `hp ≥ 0` réinitialise la stabilisation pour toute chute future. ramenant à `hp ≥ 0` réinitialise la stabilisation pour toute chute future.
La mort survient au seuil existant `hp < min(-10, -CON)` — divergence RAW La mort survient quand les pv négatifs atteignent le score de CON
documentée : la règle du jeu meurt quand les pv négatifs atteignent le score (`hp ≤ -CON`) — conformance P2 : la règle du −10 de D&D 3.5 n'existe pas
de CON (le moteur est donc d'un pv plus permissif) ; sémantique inchangée. en PF1e et n'est plus modélisée.
- Mort quand `hp < min(-10, -CON)` ; `hp ≤ 0` = inactif. - 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. - 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 - Économie d'action : un tour normal donne une action standard + une action de
mouvement (ou une action à round complet), plus swift/free/immediate. La 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 confirmation de critique ; couvert mou : une créature active entre
l'attaquant et la cible octroie aussi +4 CA aux attaques à distance (les l'attaquant et la cible octroie aussi +4 CA aux attaques à distance (les
attaques de mêlée ignorent les créatures). 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 - Flanquement : +2 au jet d'attaque de mêlée (toucher de mêlée inclus, voir
de mêlée) menace la cible depuis la bordure ou le coin opposé. Les attaques à « Touch AC ») si un allié actif menace réellement la cible depuis la
distance ignorent le flanquement ; un allié désarmé ou à distance ne compte bordure ou le coin opposé — conformance P2 : l'allié doit **menacer** une
pas pour le flanquement. 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 - 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 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 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 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 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, 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. - Jets de sauvegarde : `resolve_save(state, save_type, dc)` — fort/ref/will.
1 naturel = échec automatique, 20 naturel = succès automatique. Les 1 naturel = échec automatique, 20 naturel = succès automatique. Les
modificateurs d'effets (`StatModifier` avec `target` correspondant) sont 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`). déjà via le chemin `denies_dex_to_ac`).
- Attaque sournoise : un combattant avec la capacité « Sneak Attack » inflige des - 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 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 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 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 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 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` - 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 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 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 −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 la mêlée et gagne +4 CA contre la distance (le CRB inverse du « −4 CA
contre tout » précédemment modélisé). 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, 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 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. 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é. de grâce n'est pas modélisé.
- Manœuvres de combat : trip, disarm, bull_rush, sunder, grapple, escape, - Manœuvres de combat : trip, disarm, bull_rush, sunder, grapple, escape,
overrun, dirty trick, steal, reposition et drag sont modélisées, chacune 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 trip → `prone` ; disarm → `disarmed` (−4 attaque) ; bull_rush → pousse la
cible (direction sélectionnable via `Action.direction`) ; cible (direction sélectionnable via `Action.direction`) ;
grapple → `grappled`/`pinned` ; escape → **une** sauvegarde (bonus statique de la 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 - Le Liche (`data/monsters/lich.json`) : `creature_type: "undead"` (sensible à l'énergie
positive canalisée), aura de terreur (30 ft, Volonté DD 22 ou 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, `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. `spell_slots` sans clés dupliquées.
- Snapshot d'observation (contrat LLM, Phase 2) : avant chaque appel de - Snapshot d'observation (contrat LLM, Phase 2) : avant chaque appel de
politique, le moteur construit une `Observation` sérialisable (`to_dict()` → 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 weapons in a single full attack are not modeled.
- Natural 1 always misses; natural 20 always hits and threatens a crit. - 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. - A confirmed crit multiplies the total damage (dice + flat bonus) by crit_mult.
- DR applies once, after crit multiplication; any bypassing type defeats DR. - DR applies once, after crit multiplication; a single bypass type defeats
- Death when hp < min(-10, -CON); hp <= 0 cannot act. 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). - 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 - 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 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 ranged reuse the same corner rule. Soft cover: active creatures between
attacker and target also grant +4 AC on ranged attacks (CRB soft cover); attacker and target also grant +4 AC on ranged attacks (CRB soft cover);
melee attacks ignore creatures. melee attacks ignore creatures.
- Flanking: +2 on melee attack rolls when an active ally with a melee weapon - Flanking: +2 on melee attack rolls (melee touch included) when an active
threatens the target from the opposite border or corner. Ranged attacks do 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. not benefit from flanking; allies without melee weapons do not threaten.
- Attacks of opportunity: a combatant threatens the 8 adjacent squares if - Attacks of opportunity: a combatant threatens the 8 adjacent squares if
active and wielding a melee weapon. Two triggers are modeled: (1) moving 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 _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_ONE = 1 # PF1e: natural 1 always misses
_NATURAL_TWENTY = 20 # PF1e: natural 20 always hits and threatens _NATURAL_TWENTY = 20 # PF1e: natural 20 always hits and threatens
_RANGE_INCREMENT_PENALTY = 2 # PF1e: -2 per full range increment beyond the first _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_CONDITION_NAMES: frozenset[str] = frozenset(
{"helpless", "paralyzed", "asleep", "unconscious"} {"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_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) _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
@@ -481,15 +521,19 @@ class CombatantState:
@property @property
def active(self) -> bool: 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 @property
def dead(self) -> bool: def dead(self) -> bool:
return self.hp < self.death_threshold return self.hp <= self.death_threshold
@property @property
def death_threshold(self) -> int: 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) @dataclass(frozen=True)
@@ -1291,36 +1335,25 @@ class CombatEngine:
state.aid_ac_bonus = 0 state.aid_ac_bonus = 0
state.running = False state.running = False
original = self._policy(self, state) original = self._policy(self, state)
actions = original
results: list[ActionResult] = [] results: list[ActionResult] = []
if state.immediate_used: actions = self._filter_turn_actions(state, original, results)
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
before = len(self._transcript) before = len(self._transcript)
was_delayed = state.delayed_this_round was_delayed = state.delayed_this_round
disabled = self._is_disabled(state)
for action in actions: for action in actions:
if not state.active: if not state.active:
break break
results.append(self._execute(state, action)) 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: if state.delayed_this_round and not was_delayed:
# A fresh delay forfeits the rest of the turn. # A fresh delay forfeits the rest of the turn.
break break
@@ -1334,6 +1367,43 @@ class CombatEngine:
state.last_action_results = results state.last_action_results = results
return self._winner_side() is not None 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: def has_condition_flag(self, state: CombatantState, flag: str) -> bool:
"""Check if any active condition has the given behavioral flag set to True.""" """Check if any active condition has the given behavioral flag set to True."""
for ac in state.active_conditions: for ac in state.active_conditions:
@@ -1349,6 +1419,11 @@ class CombatEngine:
or any(cond.name == name for cond in state.conditions) 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: def _is_helpless(self, state: CombatantState) -> bool:
"""PF1e (CRB): helpless = paralyzed, held, bound, sleeping, unconscious. """PF1e (CRB): helpless = paralyzed, held, bound, sleeping, unconscious.
@@ -1389,8 +1464,12 @@ class CombatEngine:
return False return False
def _get_speed_mult(self, state: CombatantState) -> float: def _get_speed_mult(self, state: CombatantState) -> float:
"""Get the minimum speed multiplier from all active conditions.""" """Get the minimum speed multiplier from all active conditions.
speed_mult = 1.0
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: for ac in state.active_conditions:
if ac.condition.speed_mult != 1.0: if ac.condition.speed_mult != 1.0:
speed_mult = min(speed_mult, ac.condition.speed_mult) 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 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: def range_penalty(self, weapon: AttackSpec, dist_ft: int) -> int:
"""PF1e: -2 per full range increment beyond the first, zero within it.""" """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: 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). """+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 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 = { opposite = {
(2 * tr - ar, 2 * tc - ac) (2 * tr - ar, 2 * tc - ac)
@@ -1775,9 +1867,11 @@ class CombatEngine:
continue continue
if not ally.active or ally.side != attacker.side: if not ally.active or ally.side != attacker.side:
continue continue
if self.has_condition_flag(ally, "cant_act"):
continue
if not (ally.occupied_cells() & opposite): if not (ally.occupied_cells() & opposite):
continue continue
if any(w.kind == "melee" for w in ally.combatant.attacks): if self.threatened_squares(ally) & target.occupied_cells():
return _FLANK_BONUS return _FLANK_BONUS
return 0 return 0
@@ -1952,10 +2046,12 @@ class CombatEngine:
roll = self._rng.d20() roll = self._rng.d20()
dist_ft = self.creature_distance(attacker, target) * _SQUARE_FT dist_ft = self.creature_distance(attacker, target) * _SQUARE_FT
penalty = self.range_penalty(weapon, dist_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 = (
_HIGH_GROUND_BONUS _HIGH_GROUND_BONUS
if weapon.kind == "melee" if self._is_melee_attack(weapon)
and self._elevation(attacker) > self._elevation(target) and self._elevation(attacker) > self._elevation(target)
else 0 else 0
) )
@@ -1979,10 +2075,10 @@ class CombatEngine:
# attack and crit confirmation, target loses its Dex bonus to AC). # attack and crit confirmation, target loses its Dex bonus to AC).
if self._has_condition_named(attacker, "invisible"): if self._has_condition_named(attacker, "invisible"):
atk_mods.append(StatModifier(target="attack", value=_INVISIBLE_ATTACK_BONUS)) atk_mods.append(StatModifier(target="attack", value=_INVISIBLE_ATTACK_BONUS))
# PF1e (CRB): melee attacks against a helpless target gain +4; the # PF1e (CRB): melee attacks (melee touch included) against a helpless
# helpless state includes paralyzed, asleep and unconscious (stunned # target gain +4; the helpless state includes paralyzed, asleep and
# is NOT helpless). Ranged attacks gain no bonus. # unconscious (stunned is NOT helpless). Ranged attacks gain no bonus.
if weapon.kind == "melee" and self._is_helpless(target): if self._is_melee_attack(weapon) and self._is_helpless(target):
atk_mods.append(StatModifier(target="attack", value=_HELPLESS_MELEE_BONUS)) atk_mods.append(StatModifier(target="attack", value=_HELPLESS_MELEE_BONUS))
attack_mod = resolve_modifiers(atk_mods) attack_mod = resolve_modifiers(atk_mods)
pa_amt = self._power_attack_amt(attacker) if weapon.kind == "melee" else 0 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 + attack_mod - pa_amt + fim_penalty + attacker.aid_attack_bonus
+ (_FIGHT_DEFENSIVELY_ATTACK_PENALTY if attacker.fight_defensively else 0) + (_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) concealment = self._concealment_chance(target)
# PF1e: a blinded character's opponents count as having total # PF1e: a blinded character's opponents count as having total
# concealment against it — the blinded attacker itself misses 50%. # concealment against it — the blinded attacker itself misses 50%.
@@ -2166,14 +2266,19 @@ class CombatEngine:
) -> int: ) -> int:
"""Sneak attack d6 count for this swing (0 when the sneak does not apply). """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 PF1e: precision damage when the target is flanked (melee only, melee
denied its Dex bonus to AC (melee, or ranged within 30 ft); dice = touch included) or denied its Dex bonus to AC (melee, or ranged within
(level + 1) // 2; concealment denies the sneak attack entirely. 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): if concealment > 0 or not self._has_feat(attacker, _SNEAK_ATTACK_FEATURE):
return 0 return 0
if target.combatant.creature_type.lower() in _PRECISION_IMMUNE_TYPES:
return 0
dice = (attacker.combatant.level + 1) // 2 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: if is_melee and self._flanking_bonus(attacker, target) > 0:
return dice return dice
if denies_dex and ( if denies_dex and (
@@ -2249,12 +2354,26 @@ class CombatEngine:
attacker.deed_up_close_and_deadly = False attacker.deed_up_close_and_deadly = False
return damage 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: def _apply_dr(self, damage: int, weapon: AttackSpec, target: CombatantState) -> int:
dr: DamageReduction | None = target.combatant.dr dr: DamageReduction | None = target.combatant.dr
if dr is None: if dr is None:
return damage return damage
types = {t for component in weapon.damage for t in component.types} types = frozenset(t for comp in weapon.damage for t in comp.types)
if types & dr.bypass: if self._dr_bypassed(dr, types):
return damage return damage
return max(0, damage - dr.amount) return max(0, damage - dr.amount)
@@ -2264,7 +2383,7 @@ class CombatEngine:
dr: DamageReduction | None = target.combatant.dr dr: DamageReduction | None = target.combatant.dr
if dr is None: if dr is None:
return damage return damage
if damage_types & dr.bypass: if self._dr_bypassed(dr, damage_types):
return damage return damage
return max(0, damage - dr.amount) return max(0, damage - dr.amount)
@@ -2375,8 +2494,14 @@ class CombatEngine:
ability_name = skill_spec.ability ability_name = skill_spec.ability
ability_score = getattr(state.combatant.abilities, f"{ability_name}_score") ability_score = getattr(state.combatant.abilities, f"{ability_name}_score")
ability_mod = (ability_score - 10) // 2 ability_mod = (ability_score - 10) // 2
# Effect/condition modifiers # Effect/condition modifiers. PF1e: modifiers targeting "dex_skills"
mod_bonus = resolve_modifiers(self._stat_modifiers(state, skill)) # (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 return rank + class_bonus + ability_mod + armor_penalty + mod_bonus
def _roll_initiative(self) -> list[CombatantState]: 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 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 (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 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 min_reach_cells = weapon.reach_ft // _SQUARE_FT if weapon.reach_ft > _SQUARE_FT else 0
speed_cells = _speed_ft(attacker) // _SQUARE_FT speed_cells = _speed_ft(attacker) // _SQUARE_FT
@@ -3441,13 +3569,26 @@ class CombatEngine:
): ):
return 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) attacker_cmb = self._maneuver_cmb(state)
target_cmd = self._maneuver_cmd(target) target_cmd = self._maneuver_cmd(target)
roll = self._rng.d20() roll = self._rng.d20()
total = roll + attacker_cmb total = roll + attacker_cmb
m = _ManeuverRoll( m = _ManeuverRoll(
roll=roll, attacker_cmb=attacker_cmb, total=total, 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]] = { handlers: dict[str, Callable[[], None]] = {
@@ -3466,6 +3607,21 @@ class CombatEngine:
} }
handlers[maneuver]() 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: def _retrieve_weapon(self, state: CombatantState, weapon_id: str | None) -> None:
"""Restore a stolen weapon to its owner (remove it from ``weapon_stolen``). """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. The starting square is not threatened — no AoO when leaving it.
Subsequent squares provoke AoOs normally (step-by-step, as for move). 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: if threat is None:
return return
path = self._flee_path(state, threat) 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( BLINDED = Condition(
name="blinded", name="blinded",
modifiers=( modifiers=(
StatModifier(target="ac", value=-2), StatModifier(target="ac", value=-2),
StatModifier(target="attack", value=-2), StatModifier(target="dex_skills", value=-4),
), ),
denies_dex_to_ac=True, 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)) a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (1, 2)) b = make_state(b_spec, "monsters", (1, 2))
engine = make_engine( 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() result = engine.run()
assert result.winner == "players" 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 #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 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 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", "round 3 a: b down",
"battle over: players win in 3 rounds", "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["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=12) assert result.stats["b"] == CombatantStats(hits=0, crits=0, damage_dealt=0, damage_taken=13)
def test_count_swings_stop_when_target_drops() -> None: 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.active is True
assert st.dead is False assert st.dead is False
st.hp = 0 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 assert st.dead is False
st.hp = -11 st.hp = -11
assert st.active is False assert st.active is False
assert st.dead is False assert st.dead is False
# B12 conformance (CRB): death when negative hp reaches CON (hp <= -12).
st.hp = -12 st.hp = -12
assert st.active is False 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 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) archer = make_combatant("archer", attack_bonus=4, kind="ranged", range_increment_ft=60)
target = make_combatant("target", speed=0) target = make_combatant("target", speed=0)
states = [make_state(archer, "players", (0, 1)), make_state(target, "monsters", (0, 4))] 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) engine = CombatEngine(rng, grid, states, round_cap=4)
result = engine.run() result = engine.run()
assert result.winner == "players" assert result.winner == "players"
@@ -568,7 +570,7 @@ def test_ranged_no_line_of_effect_moves_around_wall() -> None:
"round 1 target: wait", "round 1 target: wait",
"round 2 archer: short bow vs target d20=15+4=19 AC 13 -> HIT 3 damage (6->3)", "round 2 archer: short bow vs target d20=15+4=19 AC 13 -> HIT 3 damage (6->3)",
"round 2 target: wait", "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", "round 3 archer: target down",
"battle over: players win in 3 rounds", "battle over: players win in 3 rounds",
) )
@@ -842,24 +844,31 @@ def test_diagonal_flanking() -> None:
def test_flanking_requires_active_ally() -> 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) atk = make_combatant("atk", attack_bonus=2, speed=0)
aly = make_combatant("aly", attack_bonus=0, speed=0) aly = make_combatant("aly", attack_bonus=0, speed=0)
tgt = make_combatant("tgt", hp=10, ac=15, attack_bonus=2, 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 = make_state(aly, "players", (5, 4))
aly_state.hp = 0 aly_state.hp = -1
states = [ states = [
make_state(atk, "players", (3, 4)), make_state(atk, "players", (3, 4)),
aly_state, aly_state,
make_state(tgt, "monsters", (4, 4)), 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() result = engine.run()
assert result.transcript == ( assert result.transcript == (
"initiative: atk d20=10+0=10", "initiative: atk d20=10+0=10",
"initiative: aly d20=9+0=9", "initiative: aly d20=9+0=9",
"initiative: tgt d20=8+0=8", "initiative: tgt d20=8+0=8",
"round 1 atk: short sword vs tgt d20=12+2=14 AC 15 -> MISS", "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", "round 1 tgt: short sword vs atk d20=5+2=7 AC 13 -> MISS",
"battle over: draw after 1 rounds", "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: 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) 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 = [ states = [
make_state(war, "players", (3, 4)), make_state(war, "players", (3, 4)),
make_state(tgt, "monsters", (4, 4)), make_state(tgt, "monsters", (4, 4)),
@@ -947,7 +960,7 @@ def test_full_attack_stops_on_target_down() -> None:
assert result.transcript == ( assert result.transcript == (
"initiative: war d20=10+0=10", "initiative: war d20=10+0=10",
"initiative: tgt d20=8+0=8", "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", "round 1 war: tgt down",
"battle over: players win in 1 rounds", "battle over: players win in 1 rounds",
) )
@@ -1020,7 +1033,7 @@ def test_aoo_drops_mover() -> None:
make_state(dest, "monsters", (4, 5)), make_state(dest, "monsters", (4, 5)),
] ]
engine = CombatEngine( engine = CombatEngine(
ScriptedRng([10, 9, 8, 15, 3]), ScriptedRng([10, 9, 8, 15, 4]),
make_grid(), states, round_cap=1, policy=_move_to_dest, make_grid(), states, round_cap=1, policy=_move_to_dest,
) )
result = engine.run() result = engine.run()
@@ -1029,7 +1042,7 @@ def test_aoo_drops_mover() -> None:
"initiative: runner d20=10+0=10", "initiative: runner d20=10+0=10",
"initiative: guard d20=9+0=9", "initiative: guard d20=9+0=9",
"initiative: dest d20=8+0=8", "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", "round 1 guard: runner down",
"battle over: monsters win in 1 rounds", "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 a, t = eng._states
_add_condition(a, "blinded") _add_condition(a, "blinded")
result = eng.resolve_attack(a, t, a.combatant.attacks[0]) 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.hit is True
assert result.total == 18 assert result.total == 20
assert result.damage == 0 assert result.damage == 0
assert t.hp == 100 assert t.hp == 100
assert any("attacker blinded" in line and "misses" in line for line in eng._transcript) 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: 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) return CombatEngine(ScriptedRng(queue), make_grid(), states)
@@ -73,7 +76,7 @@ def test_reposition_direction_right() -> None:
b_spec = make_combatant("b") b_spec = make_combatant("b")
a = make_state(a_spec, "players", (1, 1)) a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (1, 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, "reposition", direction=(0, 1)) engine._execute_maneuver(a, b, "reposition", direction=(0, 1))
assert b.pos == (1, 3) assert b.pos == (1, 3)
@@ -83,7 +86,7 @@ def test_reposition_direction_down() -> None:
b_spec = make_combatant("b") b_spec = make_combatant("b")
a = make_state(a_spec, "players", (1, 1)) a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (1, 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, "reposition", direction=(1, 0)) engine._execute_maneuver(a, b, "reposition", direction=(1, 0))
assert b.pos == (2, 2) assert b.pos == (2, 2)
@@ -95,7 +98,7 @@ def test_drag_direction_up() -> None:
b_spec = make_combatant("b") b_spec = make_combatant("b")
a = make_state(a_spec, "players", (1, 1)) a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (1, 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, "drag", direction=(-1, 0)) engine._execute_maneuver(a, b, "drag", direction=(-1, 0))
assert b.pos == (0, 2) assert b.pos == (0, 2)
assert a.pos == (1, 2) assert a.pos == (1, 2)
@@ -108,7 +111,7 @@ def test_bull_rush_direction_left() -> None:
b_spec = make_combatant("b") b_spec = make_combatant("b")
a = make_state(a_spec, "players", (2, 2)) a = make_state(a_spec, "players", (2, 2))
b = make_state(b_spec, "monsters", (1, 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)) engine._execute_maneuver(a, b, "bull_rush", direction=(0, -1))
assert b.pos == (1, 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") b_spec = make_combatant("b")
a = make_state(a_spec, "players", (1, 1)) a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (0, 0)) 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)) engine._execute_maneuver(a, b, "reposition", direction=(-1, 0))
assert b.pos == (0, 0) assert b.pos == (0, 0)
@@ -130,7 +133,7 @@ def test_reposition_direction_occupied_no_move() -> None:
b_spec = make_combatant("b") b_spec = make_combatant("b")
a = make_state(a_spec, "players", (1, 1)) a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (1, 2)) 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 # direction points at the attacker's own cell -> occupied -> no move
engine._execute_maneuver(a, b, "reposition", direction=(0, -1)) engine._execute_maneuver(a, b, "reposition", direction=(0, -1))
assert b.pos == (1, 2) 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") b_spec = make_combatant("b")
a = make_state(a_spec, "players", (1, 1)) a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (0, 0)) 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)) engine._execute_maneuver(a, b, "bull_rush", direction=(-1, 0))
assert b.pos == (0, 0) assert b.pos == (0, 0)
@@ -153,7 +156,7 @@ def test_reposition_none_uses_first_free_neighbor() -> None:
b_spec = make_combatant("b") b_spec = make_combatant("b")
a = make_state(a_spec, "players", (1, 1)) a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (1, 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, "reposition") engine._execute_maneuver(a, b, "reposition")
# First free neighbor in _NEIGHBOR_OFFSETS order, skipping attacker's cell. # First free neighbor in _NEIGHBOR_OFFSETS order, skipping attacker's cell.
assert b.pos == (0, 1) assert b.pos == (0, 1)
@@ -164,7 +167,7 @@ def test_drag_none_uses_beyond_cell() -> None:
b_spec = make_combatant("b") b_spec = make_combatant("b")
a = make_state(a_spec, "players", (1, 1)) a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (1, 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, "drag") engine._execute_maneuver(a, b, "drag")
# Beyond the target from the attacker: (1, 3). # Beyond the target from the attacker: (1, 3).
assert b.pos == (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") b_spec = make_combatant("b")
a = make_state(a_spec, "players", (2, 2)) a = make_state(a_spec, "players", (2, 2))
b = make_state(b_spec, "monsters", (1, 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") engine._execute_maneuver(a, b, "bull_rush")
# Away from attacker (up, since target is above): (0, 2). # Away from attacker (up, since target is above): (0, 2).
assert b.pos == (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: 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) return CombatEngine(ScriptedRng(queue), make_grid(), states)
@@ -77,7 +80,7 @@ def test_dirty_trick_blinded() -> None:
b_spec = make_combatant("b") b_spec = make_combatant("b")
a = make_state(a_spec, "players", (1, 1)) a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (1, 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, "dirty_trick", trick_condition="blinded") engine._execute_maneuver(a, b, "dirty_trick", trick_condition="blinded")
assert _condition_names(b) == ["blinded"] assert _condition_names(b) == ["blinded"]
@@ -89,7 +92,7 @@ def test_dirty_trick_default_dazzled() -> None:
b_spec = make_combatant("b") b_spec = make_combatant("b")
a = make_state(a_spec, "players", (1, 1)) a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (1, 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, "dirty_trick") engine._execute_maneuver(a, b, "dirty_trick")
assert _condition_names(b) == ["dazzled"] assert _condition_names(b) == ["dazzled"]
@@ -101,7 +104,7 @@ def test_dirty_trick_deafened() -> None:
b_spec = make_combatant("b") b_spec = make_combatant("b")
a = make_state(a_spec, "players", (1, 1)) a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (1, 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, "dirty_trick", trick_condition="deafened") engine._execute_maneuver(a, b, "dirty_trick", trick_condition="deafened")
assert _condition_names(b) == ["deafened"] assert _condition_names(b) == ["deafened"]
@@ -111,7 +114,7 @@ def test_dirty_trick_entangled() -> None:
b_spec = make_combatant("b") b_spec = make_combatant("b")
a = make_state(a_spec, "players", (1, 1)) a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (1, 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, "dirty_trick", trick_condition="entangled") engine._execute_maneuver(a, b, "dirty_trick", trick_condition="entangled")
assert _condition_names(b) == ["entangled"] assert _condition_names(b) == ["entangled"]
@@ -121,7 +124,7 @@ def test_dirty_trick_nauseated() -> None:
b_spec = make_combatant("b") b_spec = make_combatant("b")
a = make_state(a_spec, "players", (1, 1)) a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (1, 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, "dirty_trick", trick_condition="nauseated") engine._execute_maneuver(a, b, "dirty_trick", trick_condition="nauseated")
assert _condition_names(b) == ["nauseated"] assert _condition_names(b) == ["nauseated"]
@@ -133,7 +136,7 @@ def test_dirty_trick_failure_no_condition() -> None:
b_spec = make_combatant("b") b_spec = make_combatant("b")
a = make_state(a_spec, "players", (1, 1)) a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (1, 2)) 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") engine._execute_maneuver(a, b, "dirty_trick", trick_condition="blinded")
assert _condition_names(b) == [] 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: 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 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) eng._process_dying(s)
assert s.hp == -10 assert s.hp == -10
assert not s.dead
eng._process_dying(s)
assert s.hp == -11
assert s.dead assert s.dead
# Dead creatures never roll again (empty queue would raise if they did). # Dead creatures never roll again (empty queue would raise if they did).
eng._rng = ScriptedRng([]) eng._rng = ScriptedRng([])
eng._process_dying(s) eng._process_dying(s)
assert s.hp == -11 assert s.hp == -10
def test_dying_tick_runs_on_initiative_slot_during_round() -> None: 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( def make_engine(
queue: list[int], states: list[CombatantState] queue: list[int], states: list[CombatantState]
) -> CombatEngine: ) -> 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) 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)) a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (1, 2)) b = make_state(b_spec, "monsters", (1, 2))
hp_before = b.hp hp_before = b.hp
engine = make_engine([12], [a, b]) engine = make_engine([1, 12], [a, b])
engine._execute_maneuver(a, b, "sunder") engine._execute_maneuver(a, b, "sunder")
assert b.hp == hp_before assert b.hp == hp_before
assert b.weapon_damage["b-w"] == 2 assert b.weapon_damage["b-w"] == 2
@@ -87,7 +90,8 @@ def test_sunder_destroys_weapon_and_disarms() -> None:
b_spec = make_combatant("b") b_spec = make_combatant("b")
a = make_state(a_spec, "players", (1, 1)) a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (1, 2)) 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): for _ in range(5):
engine._execute_maneuver(a, b, "sunder") engine._execute_maneuver(a, b, "sunder")
assert b.hp == 6 assert b.hp == 6
@@ -123,7 +127,7 @@ def test_overrun_knocks_prone_and_moves_through() -> None:
b_spec = make_combatant("b") b_spec = make_combatant("b")
a = make_state(a_spec, "players", (1, 1)) a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (1, 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, "overrun") engine._execute_maneuver(a, b, "overrun")
prone = next(c for c in b.active_conditions if c.condition.name == "prone") prone = next(c for c in b.active_conditions if c.condition.name == "prone")
assert prone.rounds_left == 1 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)) a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (1, 2)) b = make_state(b_spec, "monsters", (1, 2))
c = make_state(c_spec, "monsters", (1, 3)) 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") engine._execute_maneuver(a, b, "overrun")
assert any(cond.condition.name == "prone" for cond in b.active_conditions) assert any(cond.condition.name == "prone" for cond in b.active_conditions)
assert a.pos == (1, 1) assert a.pos == (1, 1)
@@ -151,7 +155,7 @@ def test_dirty_trick_dazzles() -> None:
b_spec = make_combatant("b") b_spec = make_combatant("b")
a = make_state(a_spec, "players", (1, 1)) a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (1, 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, "dirty_trick") engine._execute_maneuver(a, b, "dirty_trick")
dazzled = next(c for c in b.active_conditions if c.condition.name == "dazzled") dazzled = next(c for c in b.active_conditions if c.condition.name == "dazzled")
assert dazzled.rounds_left == 1 assert dazzled.rounds_left == 1
@@ -163,7 +167,7 @@ def test_dirty_trick_fails_on_natural_1() -> None:
b_spec = make_combatant("b") b_spec = make_combatant("b")
a = make_state(a_spec, "players", (1, 1)) a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (1, 2)) 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") 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 == "dazzled" for c in b.active_conditions)
assert not any(c.condition.name == "prone" 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)) a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (1, 2)) b = make_state(b_spec, "monsters", (1, 2))
hp_before = b.hp hp_before = b.hp
engine = make_engine([12], [a, b]) engine = make_engine([1, 12], [a, b])
engine._execute_maneuver(a, b, "steal") engine._execute_maneuver(a, b, "steal")
assert "b-w" in b.weapon_stolen assert "b-w" in b.weapon_stolen
assert engine.weapon_for(b, a) is None 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)) a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (1, 2)) b = make_state(b_spec, "monsters", (1, 2))
c = make_state(c_spec, "monsters", (1, 3)) 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") engine._execute_maneuver(a, b, "reposition")
assert b.pos == (0, 1) assert b.pos == (0, 1)
assert c.pos == (1, 3) assert c.pos == (1, 3)
@@ -217,7 +221,7 @@ def test_reposition_no_space() -> None:
] ]
a = make_state(a_spec, "players", (2, 1)) a = make_state(a_spec, "players", (2, 1))
b = make_state(b_spec, "monsters", (1, 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") engine._execute_maneuver(a, b, "reposition")
assert b.pos == (1, 1) assert b.pos == (1, 1)
assert "no space" in engine._transcript[-1] assert "no space" in engine._transcript[-1]
@@ -228,7 +232,7 @@ def test_drag_moves_pair_together() -> None:
b_spec = make_combatant("b") b_spec = make_combatant("b")
a = make_state(a_spec, "players", (1, 1)) a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (1, 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, "drag") engine._execute_maneuver(a, b, "drag")
assert b.pos == (1, 3) assert b.pos == (1, 3)
assert a.pos == (1, 2) assert a.pos == (1, 2)
@@ -241,7 +245,7 @@ def test_drag_no_space() -> None:
a = make_state(a_spec, "players", (1, 1)) a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (1, 2)) b = make_state(b_spec, "monsters", (1, 2))
c = make_state(c_spec, "monsters", (1, 3)) 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") engine._execute_maneuver(a, b, "drag")
assert b.pos == (1, 2) assert b.pos == (1, 2)
assert a.pos == (1, 1) 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: 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 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") actor = make_combatant("actor")
enemy = make_combatant("enemy") enemy = make_combatant("enemy")
actor_state = make_state(actor, "players", (0, 0)) actor_state = make_state(actor, "players", (0, 0))
enemy_state = make_state(enemy, "monsters", (0, 3)) enemy_state = make_state(enemy, "monsters", (0, 3))
enemy_state.hp = 0 enemy_state.hp = -1
engine = make_engine([actor_state, enemy_state]) engine = make_engine([actor_state, enemy_state])
obs = _snapshot(engine, actor_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)) hero = make_state(_combatant("hero"), "players", (0, 0))
foe = make_state(_combatant("foe"), "monsters", (0, 1)) foe = make_state(_combatant("foe"), "monsters", (0, 1))
eng = CombatEngine( 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, policy=lambda _e, _s: _WAIT, round_cap=1,
) )
eng._current_round = 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") eng._execute_maneuver(foe, hero, "trip")
assert "trip vs hero d20=10+1=11 CMD 11 -> SUCCESS (prone)" in "\n".join( assert "trip vs hero d20=10+1=11 CMD 11 -> SUCCESS (prone)" in "\n".join(
eng._transcript eng._transcript
+1 -1
View File
@@ -186,7 +186,7 @@ def test_ranged_threat_empty_without_ranged_enemies() -> None:
archer = make_state( archer = make_state(
_combatant("archer", _ranged_weapon("archer", "ranged", 60)), "monsters", (0, 0) _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)) hero = make_state(_combatant("hero", _sword("hero")), "players", (4, 4))
engine = _engine([hero, melee_foe, archer], _open_rows()) 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") b_spec = make_combatant("b")
a = make_state(a_spec, "players", (1, 1)) a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (1, 2)) 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): for _ in range(5):
engine._execute(a, Action(kind="maneuver", target_id="b", maneuver="sunder")) engine._execute(a, Action(kind="maneuver", target_id="b", maneuver="sunder"))
assert "b-w" in b.weapon_broken 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") b_spec = make_combatant("b")
a = make_state(a_spec, "players", (1, 1)) a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (1, 2)) 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")) engine._execute(a, Action(kind="maneuver", target_id="b", maneuver="steal"))
assert "b-w" in b.weapon_stolen assert "b-w" in b.weapon_stolen
assert "b-w" not in _weapon_ids(b) 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") b_spec = make_combatant("b")
a = make_state(a_spec, "players", (1, 1)) a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (1, 2)) 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")) engine._execute(a, Action(kind="maneuver", target_id="b", maneuver="steal"))
assert "b-w" in b.weapon_stolen assert "b-w" in b.weapon_stolen
assert engine.weapon_for(b, a) is None assert engine.weapon_for(b, a) is None