From 97f4429c2b0518554e98fd1a22515442e1bbbc59 Mon Sep 17 00:00:00 2001 From: ctan Date: Thu, 10 Sep 2026 10:22:50 +0200 Subject: [PATCH] feat(combat): add concealment miss chance, sneak attack, and Precise Shot --- README.md | 42 ++- src/pf1e_simulator/combat.py | 213 ++++++++++++-- src/pf1e_simulator/conditions.py | 16 +- src/pf1e_simulator/rng.py | 12 +- tests/test_accuracy_rules.py | 432 +++++++++++++++++++++++++++++ tests/test_combat.py | 2 +- tests/test_observation_enriched.py | 5 +- 7 files changed, 682 insertions(+), 40 deletions(-) create mode 100644 tests/test_accuracy_rules.py diff --git a/README.md b/README.md index 7d0687c..a576090 100644 --- a/README.md +++ b/README.md @@ -410,16 +410,41 @@ Règles modélisées : chemin de la RD. La résistance énergétique s'applique au dégât final (même étape que la RD), y compris aux dégâts de sorts et aux composantes énergétiques des armes. +- Concealment (chance de rater) : les conditions `blurred` (20 %, sort flou) et + `displaced` (50 %, concealment total) portent un champ `miss_chance` ; quand la + cible en porte une, après la touche et la confirmation de critique, le moteur + tire un d100 : si `d100 ≤ miss_chance`, l'attaque rate complètement (aucun + dégât, pas de critique, deed non consommé — journalisé « concealment »). Une + cible avec concealment ne peut pas être la victime d'une attaque sournoise. +- 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 + 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. +- 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 + ennemi actif autre que l'attaquant, doté d'une arme de mêlée, menace ses + cases) ; la pénalité tombe à −2 si la cible ou l'adversaire menaçant est à + ≥ 10 ft (2 cases) de l'attaquant. Le don « Precise Shot » annule la pénalité. + Les attaques de mêlée ne sont pas concernées. +- Couvert : seul le couvert standard (+4, terrain et couvert mou des créatures) + est modélisé ; le couvert partiel (+2) et le couvert amélioré (+8) ne sont PAS + modélisés. - Invocation : les créatures invoquées agissent (calées sur la case d'initiative de leur conjurateur via `_build_round_order`) puis expirent après leur `summon_duration` (`_expire_summons` décrémente puis retire). -- Conditions effectives : 18 conditions prédéfinies (`conditions.py`). Leurs drapeaux +- Conditions effectives : 23 conditions prédéfinies (`conditions.py`). Leurs drapeaux comportementaux sont appliqués par le moteur : `cant_act` (étourdi, paralysé, dazed, endormi) annule le tour ; `cant_attack` (nauséeux) n'autorise qu'une unique action simple et bloque toute attaque / manœuvre ; `staggered` restreint à une action simple (pas d'attaque à outrance) ; `cant_move` bloque le déplacement ; `denies_dex_to_ac` (à plat ventre, paralysé…) fait perdre le bonus de Dextérité à - la CA (CA flat-footed). À plat ventre : −4 CA et −4 aux attaques à distance. + la CA (CA flat-footed) ; `miss_chance` (flou 20 %, déplacement 50 %) impose la + chance de rater du concealment. À plat ventre : −4 CA et −4 aux attaques à distance. Agrippé : −4 Dextérité, −2 attaque/CA/Réflexes, sans déplacement ni AoO. - Manœuvres de combat : trip, disarm, bull_rush, sunder, grapple, escape, overrun, dirty trick, steal, reposition et drag sont modélisées, chacune @@ -496,7 +521,10 @@ Règles modélisées : spells (résumés enrichis du registre : name, level, school, range_ft, save, dc, summary, half_on_save, save_negates (E4)), spell_resistance (int), resistances {type: montant}, immunities - (tuple triée des types immunisés). + (tuple triée des types immunisés), sneak_dice (dés de précision disponibles : + (niveau + 1) // 2 avec « Sneak Attack », sinon 0), engaged_in_melee (bool — + une cible engagée dans une mêlée, signal pour le tir dans la mêlée et + l'attaque sournoise). - Par paire combattant actif × cible : target_index, distance_ft, weapon_for, range_penalty, can_charge, can_step5_to_attack, has_line_of_effect, flanking, movement_cost_ft, target_threatens_me, enemies_threatening_me. @@ -504,9 +532,9 @@ Règles modélisées : threatened (grille bool des cases menacées par les ennemis), terrain_semantics (un dict par type de terrain : type, move_cost, blocks_los, cover), condition_definitions (C2 : tuple trié de (nom, - {mods, flags, speed_mult}) — définitions complètes des 21 conditions de - CONDITIONS_BY_NAME pour que la LLM interprète les drapeaux et - modificateurs). + {mods, flags, speed_mult, miss_chance}) — définitions complètes des 23 + conditions de CONDITIONS_BY_NAME pour que la LLM interprète les drapeaux, + les modificateurs et les chances de rater (concealment)). Les helpers publics du moteur (L5) sont désormais embarqués EN DONNÉES dans le snapshot (`threatened`, `terrain_semantics`, `movement_cost_ft`, `target_threatens_me`, `enemies_threatening_me`) — une `LLMPolicy` ne peut @@ -563,7 +591,7 @@ la lecture mais non appliquée dans la résolution. enhancement…) gardent la valeur la plus élevée. Les pénalités suivent les mêmes règles. Fondation pour sorts, conditions, dons et capacités de classe. - `conditions.py` — conditions PF1e : `Condition` (dataclass avec - modificateurs + flags comportementaux) et 18 conditions prédéfinies + modificateurs + flags comportementaux + `miss_chance`) et 23 conditions prédéfinies (shaken, sickened, fatigued, exhausted, entangled, dazzled, blinded, deafened, flat-footed, prone, stunned, paralyzed, nauseated, dazed, staggered, frightened, panicked, cowering). Les modificateurs sont intégrés diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index ad41127..f0531ac 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -138,6 +138,11 @@ _CHARGE_MIN_CELLS = 2 # PF1e: charge must move at least 10 ft (2 squares) _UP_CLOSE_RANGE_FT = 30 # PF1e: Up Close and Deadly works within 30 ft # PF1e: Point-Blank Shot grants +1/+1 within 30 ft with ranged weapons _POINT_BLANK_RANGE_FT = 30 +_FIRING_MELEE_PENALTY = -4 # PF1e: ranged attack into melee +_FIRING_MELEE_REDUCED_PENALTY = -2 # PF1e: -2 when the melee is 10+ ft away +_FIRING_MELEE_SAFE_FT = 10 # PF1e: 2 cells (10 ft) from attacker = reduced penalty +_SNEAK_ATTACK_FEATURE = "Sneak Attack" # PF1e: rogue class feature (precision dice) +_PRECISE_SHOT_FEATURE = "Precise Shot" # PF1e: feat negating firing-into-melee penalty _SAVE_DESCRIPTOR_CONDITIONS = frozenset({ "fear", "cold", "electricity", "fey", "divination", "poison", "disease", "mind_affecting", @@ -236,6 +241,7 @@ def _condition_definition_dict(cond: Condition) -> dict[str, object]: "mods": [_stat_modifier_dict(m) for m in cond.modifiers], "flags": flags, "speed_mult": cond.speed_mult, + "miss_chance": cond.miss_chance, } @@ -395,6 +401,7 @@ class AttackResult: flank_bonus: int = 0 high_ground_bonus: int = 0 base_bonus: int = 0 + melee_penalty: int = 0 @dataclass(frozen=True) @@ -845,6 +852,12 @@ class CombatEngine: "spell_resistance": state.combatant.spell_resistance, "resistances": dict(state.combatant.resistances), "immunities": tuple(state.combatant.immunities), + "sneak_dice": ( + (state.combatant.level + 1) // 2 + if self._has_feat(state, _SNEAK_ATTACK_FEATURE) + else 0 + ), + "engaged_in_melee": self._engaged_in_melee(state), } def _pair_snapshot( @@ -1311,6 +1324,11 @@ class CombatEngine: else 0 ) base_bonus = bonus_override if bonus_override is not None else weapon.attack_bonus + fim_penalty = ( + self._firing_into_melee_penalty(attacker, target) + if self._is_ranged_attack(weapon) + else 0 + ) atk_mods = self._stat_modifiers( attacker, "attack", weapon_name=weapon.name, weapon=weapon, dist_ft=dist_ft ) @@ -1318,7 +1336,10 @@ class CombatEngine: atk_mods.append(StatModifier(target="attack", value=-4)) attack_mod = resolve_modifiers(atk_mods) pa_amt = self._power_attack_amt(attacker) if weapon.kind == "melee" else 0 - total = roll + base_bonus + penalty + flank + high_ground + attack_mod - pa_amt + total = ( + roll + base_bonus + penalty + flank + high_ground + attack_mod - pa_amt + + fim_penalty + ) # Check if target denies DEX to AC (flat-footed) denies_dex = self.has_condition_flag(target, "denies_dex_to_ac") full_ac_base = ( @@ -1358,38 +1379,173 @@ class CombatEngine: ): ac += _COVER_AC_BONUS hit = roll == _NATURAL_TWENTY or (roll != _NATURAL_ONE and total >= ac) - crit = False damage = 0 + concealment_miss = False + crit = False if hit: - if roll != _NATURAL_ONE and roll >= weapon.crit_range: - confirm = self._rng.d20() - confirm_total = ( - confirm + base_bonus + penalty + flank + high_ground + attack_mod - pa_amt - ) - crit = confirm != _NATURAL_ONE and confirm_total >= ac - dmg_mods = self._stat_modifiers( - attacker, "damage", weapon_name=weapon.name, weapon=weapon, dist_ft=dist_ft + crit, concealment_miss, damage = self._resolve_hit_damage( + attacker, target, weapon, roll=roll, ac=ac, base_bonus=base_bonus, + penalty=penalty, flank=flank, high_ground=high_ground, + attack_mod=attack_mod, pa_amt=pa_amt, fim_penalty=fim_penalty, + dist_ft=dist_ft, denies_dex=denies_dex, ) - damage = sum(c.formula.roll(self._rng) for c in weapon.damage) + weapon.damage_bonus - damage += resolve_modifiers(dmg_mods) - damage += pa_amt * 2 if weapon.kind == "melee" else 0 - if crit: - damage *= weapon.crit_mult - damage = self._apply_dr(damage, weapon, target) - weapon_types = frozenset( - t for comp in weapon.damage for t in comp.types + if not concealment_miss: + target.hp -= damage + damage = self._consume_up_close_and_deadly( + attacker, target, weapon, dist_ft, damage, hit=hit ) - damage = self._apply_energy_resistance( - damage, weapon_types, target - ) - target.hp -= damage - damage = self._consume_up_close_and_deadly( - attacker, target, weapon, dist_ft, damage, hit=hit - ) return AttackResult( hit=hit, crit=crit, damage=damage, roll=roll, total=total, ac=ac, penalty=penalty, flank_bonus=flank, high_ground_bonus=high_ground, - base_bonus=base_bonus, + base_bonus=base_bonus, melee_penalty=fim_penalty, + ) + + def _resolve_hit_damage( + self, + attacker: CombatantState, + target: CombatantState, + weapon: AttackSpec, + *, + roll: int, + ac: int, + base_bonus: int, + penalty: int, + flank: int, + high_ground: int, + attack_mod: int, + pa_amt: int, + fim_penalty: int, + dist_ft: int, + denies_dex: bool, + ) -> tuple[bool, bool, int]: + """Resolve a successful attack roll: crit confirm, concealment miss + chance, sneak attack dice, then damage (PF1e order). + + Returns ``(crit, concealment_miss, damage)``. Sneak precision dice are + added after the crit multiplier and before DR / energy resistance. + """ + crit = False + if roll != _NATURAL_ONE and roll >= weapon.crit_range: + confirm = self._rng.d20() + confirm_total = ( + confirm + base_bonus + penalty + flank + high_ground + + attack_mod - pa_amt + fim_penalty + ) + crit = confirm != _NATURAL_ONE and confirm_total >= ac + concealment = self._concealment_chance(target) + if concealment > 0: + pct = self._rng.d100() + missed = pct <= concealment + self._log( + f"round {self._current_round} {attacker.combatant.id}: " + f"concealment vs {target.combatant.id}: " + f"d100({pct}) {'<=' if missed else '>'} {concealment}% " + f"— attack {'misses' if missed else 'lands'}" + ) + if missed: + return False, True, 0 + sneak_total = 0 + sneak_dice = self._sneak_attack_dice( + attacker, target, weapon, dist_ft=dist_ft, + denies_dex=denies_dex, concealment=concealment, + ) + if sneak_dice > 0: + sneak_total = sum(self._rng.roll_die(6) for _ in range(sneak_dice)) + self._log( + f"round {self._current_round} {attacker.combatant.id}: " + f"sneak attack vs {target.combatant.id}: " + f"{sneak_dice}d6 = {sneak_total} precision damage" + ) + dmg_mods = self._stat_modifiers( + attacker, "damage", weapon_name=weapon.name, weapon=weapon, dist_ft=dist_ft + ) + damage = ( + sum(c.formula.roll(self._rng) for c in weapon.damage) + weapon.damage_bonus + ) + damage += resolve_modifiers(dmg_mods) + damage += pa_amt * 2 if weapon.kind == "melee" else 0 + if crit: + damage *= weapon.crit_mult + damage += sneak_total + damage = self._apply_dr(damage, weapon, target) + weapon_types = frozenset(t for comp in weapon.damage for t in comp.types) + damage = self._apply_energy_resistance(damage, weapon_types, target) + return crit, False, damage + + def _concealment_chance(self, target: CombatantState) -> int: + """Highest miss chance (%) among the target's active concealment conditions.""" + chances = [ac.condition.miss_chance for ac in target.active_conditions] + chances += [cond.miss_chance for cond in target.conditions] + return max((c for c in chances if c > 0), default=0) + + def _sneak_attack_dice( + self, + attacker: CombatantState, + target: CombatantState, + weapon: AttackSpec, + *, + dist_ft: int, + denies_dex: bool, + concealment: int, + ) -> 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. + """ + if concealment > 0 or not self._has_feat(attacker, _SNEAK_ATTACK_FEATURE): + return 0 + dice = (attacker.combatant.level + 1) // 2 + is_melee = weapon.kind == "melee" + if is_melee and self._flanking_bonus(attacker, target) > 0: + return dice + if denies_dex and ( + is_melee + or (self._is_ranged_attack(weapon) and dist_ft <= _POINT_BLANK_RANGE_FT) + ): + return dice + return 0 + + def _firing_into_melee_penalty( + self, attacker: CombatantState, target: CombatantState + ) -> int: + """PF1e firing into melee: -4 on a ranged attack whose target is + engaged in melee, reduced to -2 when the target or every threatening + opponent stands 10+ ft from the attacker. Precise Shot removes it. + + The target is engaged when an active enemy of the target (not the + attacker itself) threatens the target's occupied cells with a melee + weapon. + """ + if self._has_feat(attacker, _PRECISE_SHOT_FEATURE): + return 0 + threats = [ + s + for s in self._states + if s is not attacker + and s.active + and s.side != target.side + and self.threatened_squares(s) & target.occupied_cells() + ] + if not threats: + return 0 + if self.creature_distance(attacker, target) * _SQUARE_FT >= _FIRING_MELEE_SAFE_FT: + return _FIRING_MELEE_REDUCED_PENALTY + for threat in threats: + dist = self.creature_distance(attacker, threat) * _SQUARE_FT + if dist >= _FIRING_MELEE_SAFE_FT: + return _FIRING_MELEE_REDUCED_PENALTY + return _FIRING_MELEE_PENALTY + + def _engaged_in_melee(self, state: CombatantState) -> bool: + """True when an active enemy with a melee weapon threatens this + combatant's occupied cells (same threat machinery as AoO/flanking).""" + occupied = state.occupied_cells() + return any( + self.threatened_squares(s) & occupied + for s in self._states + if s is not state and s.active and s.side != state.side ) def _consume_up_close_and_deadly( @@ -3355,10 +3511,11 @@ class CombatEngine: high_ground_str = ( f"+{result.high_ground_bonus}(high)" if result.high_ground_bonus else "" ) + melee_str = f"{result.melee_penalty}(melee)" if result.melee_penalty else "" bonus_str = ( - f"{result.base_bonus}{result.penalty:+d}{flank_str}{high_ground_str}" + f"{result.base_bonus}{result.penalty:+d}{flank_str}{high_ground_str}{melee_str}" if result.penalty - else f"{result.base_bonus}{flank_str}{high_ground_str}" + else f"{result.base_bonus}{flank_str}{high_ground_str}{melee_str}" ) line = ( f"round {self._current_round} {attacker.combatant.id}: {label} " diff --git a/src/pf1e_simulator/conditions.py b/src/pf1e_simulator/conditions.py index c4c27ac..858bcca 100644 --- a/src/pf1e_simulator/conditions.py +++ b/src/pf1e_simulator/conditions.py @@ -37,6 +37,7 @@ class Condition: cant_aoo: bool = False must_flee: bool = False speed_mult: float = 1.0 + miss_chance: int = 0 # ── Fear conditions ────────────────────────────────────────────────────────── @@ -253,6 +254,19 @@ STAGGERED = Condition( name="staggered", ) +# ── Concealment conditions ─────────────────────────────────────────────────── +# Blurred (blur spell): concealment — 20% miss chance on attacks (CRB) +BLURRED = Condition( + name="blurred", + miss_chance=20, +) + +# Displaced (displacement spell): total concealment — 50% miss chance (CRB) +DISPLACED = Condition( + name="displaced", + miss_chance=50, +) + # ── Registry ───────────────────────────────────────────────────────────────── CONDITIONS_BY_NAME: dict[str, Condition] = { c.name: c @@ -260,6 +274,6 @@ CONDITIONS_BY_NAME: dict[str, Condition] = { SHAKEN, SICKENED, FATIGUED, EXHAUSTED, ENTANGLED, DAZZLED, BLINDED, DEAFENED, FLAT_FOOTED, PRONE, STUNNED, PARALYZED, NAUSEATED, DAZED, STAGGERED, FRIGHTENED, PANICKED, COWERING, - ASLEEP, GRAPPLED, PINNED, + ASLEEP, GRAPPLED, PINNED, BLURRED, DISPLACED, ] } diff --git a/src/pf1e_simulator/rng.py b/src/pf1e_simulator/rng.py index 6b61589..6ce35f9 100644 --- a/src/pf1e_simulator/rng.py +++ b/src/pf1e_simulator/rng.py @@ -32,6 +32,10 @@ class Rng(Protocol): """Return roll_die(20).""" ... + def d100(self) -> int: + """Return roll_die(100).""" + ... + def choice_index(self, n: int) -> int: """Return a uniform integer in 0..n-1 for deterministic tie-breaks.""" ... @@ -49,6 +53,9 @@ class SeededRng: def d20(self) -> int: return self.roll_die(20) + def d100(self) -> int: + return self.roll_die(100) + def choice_index(self, n: int) -> int: return self._rng.randrange(n) @@ -56,7 +63,7 @@ class SeededRng: class ScriptedRng: """Rng that pops predetermined values from a queue. - Each call to roll_die, d20, or choice_index consumes one value. + Each call to roll_die, d20, d100, or choice_index consumes one value. Raises RngExhaustedError when the queue is empty. """ @@ -71,6 +78,9 @@ class ScriptedRng: def d20(self) -> int: return self.roll_die(20) + def d100(self) -> int: + return self.roll_die(100) + def choice_index(self, _n: int) -> int: if not self._queue: raise RngExhaustedError(_EXHAUSTED_MSG) diff --git a/tests/test_accuracy_rules.py b/tests/test_accuracy_rules.py new file mode 100644 index 0000000..29b4ecd --- /dev/null +++ b/tests/test_accuracy_rules.py @@ -0,0 +1,432 @@ +"""Concealment miss chance, sneak attack, and firing into melee (Package P2).""" +from __future__ import annotations + +from pf1e_simulator.abilities import AbilitySpec +from pf1e_simulator.combat import 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.models import ( + AbilityScores, + ACProfile, + AttackSpec, + Combatant, + DamageComponent, + Saves, +) +from pf1e_simulator.rng import ScriptedRng +from tests.test_observation_enriched import _snapshot, make_grid, make_state + + +def _melee(name: str, attack_bonus: int = 5) -> AttackSpec: + return AttackSpec( + id=f"{name}-sword", + name="short sword", + kind="melee", + attack_bonus=attack_bonus, + damage=[DamageComponent(formula=parse_dice("1d6"), types=["slashing"])], + ) + + +def _bow(name: str, attack_bonus: int = 5) -> AttackSpec: + return AttackSpec( + id=f"{name}-bow", + name="short bow", + kind="ranged", + attack_bonus=attack_bonus, + damage=[DamageComponent(formula=parse_dice("1d6"), types=["piercing"])], + range_increment_ft=60, + ) + + +def _combatant( + id_: str, + *, + level: int = 1, + ac: int = 10, + attacks: tuple[AttackSpec, ...] = (), + features: tuple[str, ...] = (), +) -> Combatant: + return Combatant( + id=id_, + name=id_, + level=level, + abilities=AbilityScores( + str_score=10, dex_score=10, con_score=10, + int_score=10, wis_score=10, cha_score=10, + ), + ac=ACProfile(total=ac, touch=ac, flat_footed=ac), + bab=1, + initiative_mod=0, + speed_land_ft=30, + hp_max=100, + saves=Saves(fort=0, ref=0, will=0), + attacks=list(attacks) or [_melee(id_)], + features=[AbilitySpec(name=f, category="class") for f in features], + ) + + +def _engine( + combatants: list[Combatant], + positions: list[tuple[int, int]], + sides: list[str], + rng: list[int], +) -> CombatEngine: + states = [ + make_state(c, side, pos) + for c, side, pos in zip(combatants, sides, positions, strict=True) + ] + return CombatEngine(ScriptedRng(rng), make_grid(), states) + + +def _add_condition(state: CombatantState, name: str, rounds: int = 5) -> None: + state.active_conditions.append( + ActiveCondition( + condition=CONDITIONS_BY_NAME[name], + rounds_left=rounds, + source="test", + ) + ) + + +# ── Rule A: concealment miss chance ────────────────────────────────────────── + + +def test_concealment_blur_misses_on_low_d100() -> None: + eng = _engine( + [_combatant("atk"), _combatant("tgt")], + [(0, 0), (0, 1)], + ["players", "monsters"], + [15, 10], + ) + a, t = eng._states + _add_condition(t, "blurred") + result = eng.resolve_attack(a, t, a.combatant.attacks[0]) + assert result.hit is True + assert result.damage == 0 + assert t.hp == 100 + assert any("concealment" in line for line in eng._transcript) + + +def test_concealment_blur_lands_on_high_d100() -> None: + eng = _engine( + [_combatant("atk"), _combatant("tgt")], + [(0, 0), (0, 1)], + ["players", "monsters"], + [15, 50, 4], + ) + a, t = eng._states + _add_condition(t, "blurred") + result = eng.resolve_attack(a, t, a.combatant.attacks[0]) + assert result.hit is True + assert result.damage == 4 + assert t.hp == 96 + + +def test_total_concealment_triggers_at_50() -> None: + eng = _engine( + [_combatant("atk"), _combatant("tgt")], + [(0, 0), (0, 1)], + ["players", "monsters"], + [15, 50], + ) + a, t = eng._states + _add_condition(t, "displaced") + result = eng.resolve_attack(a, t, a.combatant.attacks[0]) + assert result.hit is True + assert result.damage == 0 + assert t.hp == 100 + + +def test_concealment_denies_sneak_attack() -> None: + sneak = _combatant("atk", level=3, features=("Sneak Attack",)) + eng = _engine( + [sneak, _combatant("tgt")], + [(0, 0), (0, 1)], + ["players", "monsters"], + [15, 90, 3], + ) + a, t = eng._states + _add_condition(t, "flat_footed") + _add_condition(t, "blurred") + result = eng.resolve_attack(a, t, a.combatant.attacks[0]) + assert result.damage == 3 + assert t.hp == 97 + assert not any("sneak" in line for line in eng._transcript) + + +def test_concealment_miss_does_not_consume_deed() -> None: + eng = _engine( + [ + _combatant("atk", attacks=(_bow("atk", attack_bonus=15),)), + _combatant("tgt"), + ], + [(0, 0), (0, 2)], + ["players", "monsters"], + [15, 5], + ) + a, t = eng._states + a.deed_up_close_and_deadly = True + _add_condition(t, "blurred") + result = eng.resolve_attack(a, t, a.combatant.attacks[0]) + assert result.damage == 0 + assert t.hp == 100 + assert a.deed_up_close_and_deadly is True + + +# ── Rule B: sneak attack ───────────────────────────────────────────────────── + + +def test_sneak_flanked_melee_dice_count() -> None: + rogue = _combatant("rogue", level=5, features=("Sneak Attack",)) + eng = _engine( + [rogue, _combatant("tgt"), _combatant("ally")], + [(0, 0), (0, 1), (0, 2)], + ["players", "monsters", "players"], + [15, 2, 3, 4, 5], + ) + a, t, _ally = eng._states + result = eng.resolve_attack(a, t, a.combatant.attacks[0]) + assert result.total == 22 + assert result.damage == 14 + assert t.hp == 86 + assert any("sneak attack" in line and "3d6" in line for line in eng._transcript) + + +def test_sneak_not_multiplied_on_crit() -> None: + rogue = _combatant("rogue", level=3, features=("Sneak Attack",)) + weapon = AttackSpec( + id="rogue-sword", + name="short sword", + kind="melee", + attack_bonus=5, + damage=[DamageComponent(formula=parse_dice("1d6"), types=["slashing"])], + crit_range=20, + crit_mult=2, + ) + eng = _engine( + [rogue, _combatant("tgt")], + [(0, 0), (0, 1)], + ["players", "monsters"], + [20, 20, 6, 6, 2], + ) + a, t = eng._states + _add_condition(t, "flat_footed") + result = eng.resolve_attack(a, t, weapon) + assert result.crit is True + assert result.damage == 16 + assert t.hp == 84 + + +def test_sneak_ranged_within_30ft() -> None: + rogue = _combatant( + "rogue", level=1, features=("Sneak Attack",), attacks=(_bow("rogue"),) + ) + eng = _engine( + [rogue, _combatant("tgt")], + [(0, 0), (0, 4)], + ["players", "monsters"], + [15, 4, 3], + ) + a, t = eng._states + _add_condition(t, "flat_footed") + result = eng.resolve_attack(a, t, a.combatant.attacks[0]) + assert result.damage == 7 + assert t.hp == 93 + + +def test_sneak_ranged_beyond_30ft_denied() -> None: + rogue = _combatant( + "rogue", level=1, features=("Sneak Attack",), attacks=(_bow("rogue"),) + ) + eng = _engine( + [rogue, _combatant("tgt")], + [(0, 0), (0, 7)], + ["players", "monsters"], + [15, 3], + ) + a, t = eng._states + _add_condition(t, "flat_footed") + result = eng.resolve_attack(a, t, a.combatant.attacks[0]) + assert result.damage == 3 + assert t.hp == 97 + assert not any("sneak" in line for line in eng._transcript) + + +def test_sneak_denied_dex_melee() -> None: + rogue = _combatant("rogue", level=1, features=("Sneak Attack",)) + eng = _engine( + [rogue, _combatant("tgt")], + [(0, 0), (0, 1)], + ["players", "monsters"], + [15, 2, 3], + ) + a, t = eng._states + _add_condition(t, "flat_footed") + result = eng.resolve_attack(a, t, a.combatant.attacks[0]) + assert result.damage == 5 + assert t.hp == 95 + + +def test_no_sneak_without_feature() -> None: + eng = _engine( + [_combatant("atk"), _combatant("tgt")], + [(0, 0), (0, 1)], + ["players", "monsters"], + [15, 3], + ) + a, t = eng._states + _add_condition(t, "flat_footed") + result = eng.resolve_attack(a, t, a.combatant.attacks[0]) + assert result.damage == 3 + assert not any("sneak" in line for line in eng._transcript) + + +# ── Rule C: firing into melee / Precise Shot ───────────────────────────────── + + +def _firing_setup(rng: list[int], *, precise: bool = False) -> CombatEngine: + archer = _combatant( + "archer", + ac=99, + attacks=(_bow("archer", attack_bonus=8),), + features=("Precise Shot",) if precise else (), + ) + tgt = _combatant("tgt", ac=15, attacks=(_bow("tgt"),)) + guard = _combatant("guard", ac=99) + return _engine( + [archer, tgt, guard], + [(0, 0), (0, 1), (1, 1)], + ["players", "monsters", "players"], + rng, + ) + + +def test_firing_into_melee_minus4() -> None: + eng = _firing_setup([10]) + a, t, _g = eng._states + result = eng.resolve_attack(a, t, a.combatant.attacks[0]) + assert result.total == 14 + assert result.melee_penalty == -4 + assert result.hit is False + assert t.hp == 100 + + +def test_firing_into_melee_minus2_when_target_10ft_away() -> None: + archer = _combatant("archer", ac=99, attacks=(_bow("archer", attack_bonus=8),)) + tgt = _combatant("tgt", ac=15, attacks=(_bow("tgt"),)) + guard = _combatant("guard", ac=99) + eng = _engine( + [archer, tgt, guard], + [(0, 0), (0, 2), (0, 3)], + ["players", "monsters", "players"], + [9, 3], + ) + a, t, _g = eng._states + result = eng.resolve_attack(a, t, a.combatant.attacks[0]) + assert result.total == 15 + assert result.melee_penalty == -2 + assert result.hit is True + assert result.damage == 3 + + +def test_precise_shot_negates_penalty() -> None: + eng = _firing_setup([10, 3], precise=True) + a, t, _g = eng._states + result = eng.resolve_attack(a, t, a.combatant.attacks[0]) + assert result.total == 18 + assert result.melee_penalty == 0 + assert result.hit is True + assert result.damage == 3 + + +def test_melee_attack_unaffected_by_firing_rule() -> None: + eng = _engine( + [_combatant("atk"), _combatant("tgt"), _combatant("ally")], + [(0, 0), (0, 2), (0, 3)], + ["players", "monsters", "players"], + [15, 3], + ) + a, t, _al = eng._states + result = eng.resolve_attack(a, t, a.combatant.attacks[0]) + assert result.melee_penalty == 0 + assert result.total == 20 + + +def test_firing_into_melee_shown_in_transcript() -> None: + eng = _firing_setup([10]) + a, t, _g = eng._states + eng._single_attack(a, t, a.combatant.attacks[0]) + line = [ln for ln in eng._transcript if "short bow" in ln][-1] + assert "8-4(melee)=14" in line + assert "MISS" in line + + +def test_firing_into_melee_applies_to_crit_confirm() -> None: + archer = _combatant( + "archer", + ac=99, + attacks=( + AttackSpec( + id="archer-bow", + name="short bow", + kind="ranged", + attack_bonus=8, + damage=[ + DamageComponent(formula=parse_dice("1d6"), types=["piercing"]) + ], + crit_range=20, + range_increment_ft=60, + ), + ), + ) + tgt = _combatant("tgt", ac=15, attacks=(_bow("tgt"),)) + guard = _combatant("guard", ac=99) + # d20=20 hits (nat 20); confirm d20=1 -> 1+8-4=5 < 15 -> no crit. + eng = _engine( + [archer, tgt, guard], + [(0, 0), (0, 1), (1, 1)], + ["players", "monsters", "players"], + [20, 1, 3], + ) + a, t, _g = eng._states + result = eng.resolve_attack(a, t, a.combatant.attacks[0]) + assert result.hit is True + assert result.crit is False + assert result.damage == 3 + + +# ── Snapshot keys ──────────────────────────────────────────────────────────── + + +def test_snapshot_sneak_dice_and_engaged() -> None: + rogue = _combatant("rogue", level=5, features=("Sneak Attack",)) + brute = _combatant("brute") + distant = _combatant("distant", attacks=(_bow("distant"),)) + eng = _engine( + [rogue, brute, distant], + [(0, 0), (0, 1), (0, 7)], + ["players", "monsters", "players"], + [], + ) + obs = _snapshot(eng, eng._states[0]) + by_id = {c["id"]: c for c in obs["combatants"]} + assert by_id["rogue"]["sneak_dice"] == 3 + assert by_id["brute"]["sneak_dice"] == 0 + assert by_id["rogue"]["engaged_in_melee"] is True + assert by_id["brute"]["engaged_in_melee"] is True + assert by_id["distant"]["engaged_in_melee"] is False + + +def test_condition_definitions_carry_miss_chance() -> None: + eng = _engine( + [_combatant("a"), _combatant("b")], + [(0, 0), (0, 3)], + ["players", "monsters"], + [], + ) + obs = _snapshot(eng, eng._states[0]) + defs = dict(obs["condition_definitions"]) + assert defs["blurred"]["miss_chance"] == 20 + assert defs["displaced"]["miss_chance"] == 50 + assert defs["shaken"]["miss_chance"] == 0 diff --git a/tests/test_combat.py b/tests/test_combat.py index fed575a..cd54660 100644 --- a/tests/test_combat.py +++ b/tests/test_combat.py @@ -794,7 +794,7 @@ def test_ranged_attack_ignores_flanking() -> None: "initiative: aly d20=9+0=9", "initiative: tgt d20=8+0=8", "round 1 tgt: AoO vs arc d20=15+2=17 AC 13 -> HIT 3 damage (6->3)", - "round 1 arc: short bow vs tgt d20=12+2=14 AC 15 -> MISS", + "round 1 arc: short bow vs tgt d20=12+2-2(melee)=12 AC 15 -> MISS", "round 1 aly: short sword vs tgt d20=10+0=10 AC 15 -> MISS", "round 1 tgt: short sword vs arc d20=5+2=7 AC 13 -> MISS", "battle over: draw after 1 rounds", diff --git a/tests/test_observation_enriched.py b/tests/test_observation_enriched.py index ada79f7..c3bc978 100644 --- a/tests/test_observation_enriched.py +++ b/tests/test_observation_enriched.py @@ -1232,7 +1232,7 @@ def test_observation_enriched_condition_definitions_present() -> None: """Given: an Observation built over any combat When: the top-level dict is inspected Then: condition_definitions is a sorted tuple of (name, {mods, flags, - speed_mult}) covering every entry of CONDITIONS_BY_NAME.""" + speed_mult, miss_chance}) covering every entry of CONDITIONS_BY_NAME.""" hero = make_combatant("hero") foe = make_combatant("foe") hero_state = make_state(hero, "players", (0, 0)) @@ -1246,10 +1246,11 @@ def test_observation_enriched_condition_definitions_present() -> None: # Sorted by name for determinism. assert [n for n, _ in defs] == sorted(CONDITIONS_BY_NAME) for _name, body in defs: - assert set(body) == {"mods", "flags", "speed_mult"} + assert set(body) == {"mods", "flags", "speed_mult", "miss_chance"} assert isinstance(body["mods"], list) assert isinstance(body["flags"], list) assert isinstance(body["speed_mult"], float) + assert isinstance(body["miss_chance"], int) def test_observation_enriched_condition_definitions_shaken() -> None: