From 9aee4f6f6b82bec2098aaaa663426deb2a439325 Mon Sep 17 00:00:00 2001 From: ctan Date: Thu, 10 Sep 2026 21:45:50 +0200 Subject: [PATCH] fix(rules): correct P2/P3 PF1e conformance bugs (flanking, touch, blinded, disabled, death, maneuvers, DR, sneak) --- README.md | 77 ++- src/pf1e_simulator/combat.py | 266 ++++++++-- src/pf1e_simulator/conditions.py | 7 +- tests/test_combat.py | 49 +- tests/test_conditions_blinded_invisible.py | 6 +- tests/test_conformance_p2.py | 565 +++++++++++++++++++++ tests/test_direction.py | 23 +- tests/test_dirty_trick.py | 15 +- tests/test_dying_and_crits.py | 11 +- tests/test_maneuvers.py | 26 +- tests/test_observation_enriched.py | 9 +- tests/test_prone_actions.py | 6 +- tests/test_ranged_threat.py | 2 +- tests/test_repair_weapon.py | 2 +- tests/test_retrieve_weapon.py | 4 +- 15 files changed, 934 insertions(+), 134 deletions(-) create mode 100644 tests/test_conformance_p2.py diff --git a/README.md b/README.md index 84eaf35..fd02a1d 100644 --- a/README.md +++ b/README.md @@ -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 » 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()` → diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index dcc55ff..452e6d4 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -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) diff --git a/src/pf1e_simulator/conditions.py b/src/pf1e_simulator/conditions.py index 9a95d90..e5a4594 100644 --- a/src/pf1e_simulator/conditions.py +++ b/src/pf1e_simulator/conditions.py @@ -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, ) diff --git a/tests/test_combat.py b/tests/test_combat.py index d6ee634..fa414c8 100644 --- a/tests/test_combat.py +++ b/tests/test_combat.py @@ -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", ) diff --git a/tests/test_conditions_blinded_invisible.py b/tests/test_conditions_blinded_invisible.py index 6c29f8b..4d39142 100644 --- a/tests/test_conditions_blinded_invisible.py +++ b/tests/test_conditions_blinded_invisible.py @@ -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) diff --git a/tests/test_conformance_p2.py b/tests/test_conformance_p2.py new file mode 100644 index 0000000..2a9a591 --- /dev/null +++ b/tests/test_conformance_p2.py @@ -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) diff --git a/tests/test_direction.py b/tests/test_direction.py index 7088a33..5313df5 100644 --- a/tests/test_direction.py +++ b/tests/test_direction.py @@ -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) diff --git a/tests/test_dirty_trick.py b/tests/test_dirty_trick.py index c91c915..4ea192a 100644 --- a/tests/test_dirty_trick.py +++ b/tests/test_dirty_trick.py @@ -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) == [] diff --git a/tests/test_dying_and_crits.py b/tests/test_dying_and_crits.py index f460583..35bcb2c 100644 --- a/tests/test_dying_and_crits.py +++ b/tests/test_dying_and_crits.py @@ -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: diff --git a/tests/test_maneuvers.py b/tests/test_maneuvers.py index edea503..d00768d 100644 --- a/tests/test_maneuvers.py +++ b/tests/test_maneuvers.py @@ -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) diff --git a/tests/test_observation_enriched.py b/tests/test_observation_enriched.py index a3b81ae..f395419 100644 --- a/tests/test_observation_enriched.py +++ b/tests/test_observation_enriched.py @@ -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) diff --git a/tests/test_prone_actions.py b/tests/test_prone_actions.py index 2994e19..3b7e729 100644 --- a/tests/test_prone_actions.py +++ b/tests/test_prone_actions.py @@ -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 diff --git a/tests/test_ranged_threat.py b/tests/test_ranged_threat.py index d605ac7..d23b271 100644 --- a/tests/test_ranged_threat.py +++ b/tests/test_ranged_threat.py @@ -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()) diff --git a/tests/test_repair_weapon.py b/tests/test_repair_weapon.py index 46c632d..0d1c6fe 100644 --- a/tests/test_repair_weapon.py +++ b/tests/test_repair_weapon.py @@ -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 diff --git a/tests/test_retrieve_weapon.py b/tests/test_retrieve_weapon.py index f7ff08a..a9e1bcf 100644 --- a/tests/test_retrieve_weapon.py +++ b/tests/test_retrieve_weapon.py @@ -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