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