fix(rules): correct P2/P3 PF1e conformance bugs (flanking, touch, blinded, disabled, death, maneuvers, DR, sneak)
This commit is contained in:
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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) == []
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user