feat(combat): add concealment miss chance, sneak attack, and Precise Shot

This commit is contained in:
2026-09-10 10:22:50 +02:00
parent a1c43e83af
commit 97f4429c2b
7 changed files with 682 additions and 40 deletions
+35 -7
View File
@@ -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 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 que la RD), y compris aux dégâts de sorts et aux composantes énergétiques des
armes. 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 - 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` leur conjurateur via `_build_round_order`) puis expirent après leur `summon_duration`
(`_expire_summons` décrémente puis retire). (`_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, 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 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 et bloque toute attaque / manœuvre ; `staggered` restreint à une action
simple (pas d'attaque à outrance) ; `cant_move` bloque le déplacement ; 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é à `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. 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, - Manœuvres de combat : trip, disarm, bull_rush, sunder, grapple, escape,
overrun, dirty trick, steal, reposition et drag sont modélisées, chacune overrun, dirty trick, steal, reposition et drag sont modélisées, chacune
@@ -496,7 +521,10 @@ Règles modélisées :
spells (résumés enrichis du registre : name, level, school, range_ft, spells (résumés enrichis du registre : name, level, school, range_ft,
save, dc, summary, half_on_save, save_negates (E4)), save, dc, summary, half_on_save, save_negates (E4)),
spell_resistance (int), resistances {type: montant}, immunities 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, - Par paire combattant actif × cible : target_index, distance_ft, weapon_for,
range_penalty, can_charge, can_step5_to_attack, has_line_of_effect, range_penalty, can_charge, can_step5_to_attack, has_line_of_effect,
flanking, movement_cost_ft, target_threatens_me, enemies_threatening_me. 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), threatened (grille bool des cases menacées par les ennemis),
terrain_semantics (un dict par type de terrain : type, move_cost, terrain_semantics (un dict par type de terrain : type, move_cost,
blocks_los, cover), condition_definitions (C2 : tuple trié de (nom, blocks_los, cover), condition_definitions (C2 : tuple trié de (nom,
{mods, flags, speed_mult}) — définitions complètes des 21 conditions de {mods, flags, speed_mult, miss_chance}) — définitions complètes des 23
CONDITIONS_BY_NAME pour que la LLM interprète les drapeaux et conditions de CONDITIONS_BY_NAME pour que la LLM interprète les drapeaux,
modificateurs). les modificateurs et les chances de rater (concealment)).
Les helpers publics du moteur (L5) sont désormais embarqués EN DONNÉES dans Les helpers publics du moteur (L5) sont désormais embarqués EN DONNÉES dans
le snapshot (`threatened`, `terrain_semantics`, `movement_cost_ft`, le snapshot (`threatened`, `terrain_semantics`, `movement_cost_ft`,
`target_threatens_me`, `enemies_threatening_me`) — une `LLMPolicy` ne peut `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 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. mêmes règles. Fondation pour sorts, conditions, dons et capacités de classe.
- `conditions.py` — conditions PF1e : `Condition` (dataclass avec - `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, (shaken, sickened, fatigued, exhausted, entangled, dazzled, blinded,
deafened, flat-footed, prone, stunned, paralyzed, nauseated, dazed, deafened, flat-footed, prone, stunned, paralyzed, nauseated, dazed,
staggered, frightened, panicked, cowering). Les modificateurs sont intégrés staggered, frightened, panicked, cowering). Les modificateurs sont intégrés
+182 -25
View File
@@ -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 _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 # PF1e: Point-Blank Shot grants +1/+1 within 30 ft with ranged weapons
_POINT_BLANK_RANGE_FT = 30 _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({ _SAVE_DESCRIPTOR_CONDITIONS = frozenset({
"fear", "cold", "electricity", "fey", "divination", "poison", "disease", "fear", "cold", "electricity", "fey", "divination", "poison", "disease",
"mind_affecting", "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], "mods": [_stat_modifier_dict(m) for m in cond.modifiers],
"flags": flags, "flags": flags,
"speed_mult": cond.speed_mult, "speed_mult": cond.speed_mult,
"miss_chance": cond.miss_chance,
} }
@@ -395,6 +401,7 @@ class AttackResult:
flank_bonus: int = 0 flank_bonus: int = 0
high_ground_bonus: int = 0 high_ground_bonus: int = 0
base_bonus: int = 0 base_bonus: int = 0
melee_penalty: int = 0
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -845,6 +852,12 @@ class CombatEngine:
"spell_resistance": state.combatant.spell_resistance, "spell_resistance": state.combatant.spell_resistance,
"resistances": dict(state.combatant.resistances), "resistances": dict(state.combatant.resistances),
"immunities": tuple(state.combatant.immunities), "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( def _pair_snapshot(
@@ -1311,6 +1324,11 @@ class CombatEngine:
else 0 else 0
) )
base_bonus = bonus_override if bonus_override is not None else weapon.attack_bonus 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( atk_mods = self._stat_modifiers(
attacker, "attack", weapon_name=weapon.name, weapon=weapon, dist_ft=dist_ft 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)) atk_mods.append(StatModifier(target="attack", value=-4))
attack_mod = resolve_modifiers(atk_mods) attack_mod = resolve_modifiers(atk_mods)
pa_amt = self._power_attack_amt(attacker) if weapon.kind == "melee" else 0 pa_amt = self._power_attack_amt(attacker) if weapon.kind == "melee" else 0
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) # Check if target denies DEX to AC (flat-footed)
denies_dex = self.has_condition_flag(target, "denies_dex_to_ac") denies_dex = self.has_condition_flag(target, "denies_dex_to_ac")
full_ac_base = ( full_ac_base = (
@@ -1358,30 +1379,17 @@ class CombatEngine:
): ):
ac += _COVER_AC_BONUS ac += _COVER_AC_BONUS
hit = roll == _NATURAL_TWENTY or (roll != _NATURAL_ONE and total >= ac) hit = roll == _NATURAL_TWENTY or (roll != _NATURAL_ONE and total >= ac)
crit = False
damage = 0 damage = 0
concealment_miss = False
crit = False
if hit: if hit:
if roll != _NATURAL_ONE and roll >= weapon.crit_range: crit, concealment_miss, damage = self._resolve_hit_damage(
confirm = self._rng.d20() attacker, target, weapon, roll=roll, ac=ac, base_bonus=base_bonus,
confirm_total = ( penalty=penalty, flank=flank, high_ground=high_ground,
confirm + base_bonus + penalty + flank + high_ground + attack_mod - pa_amt attack_mod=attack_mod, pa_amt=pa_amt, fim_penalty=fim_penalty,
) dist_ft=dist_ft, denies_dex=denies_dex,
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
)
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
)
damage = self._apply_energy_resistance(
damage, weapon_types, target
) )
if not concealment_miss:
target.hp -= damage target.hp -= damage
damage = self._consume_up_close_and_deadly( damage = self._consume_up_close_and_deadly(
attacker, target, weapon, dist_ft, damage, hit=hit attacker, target, weapon, dist_ft, damage, hit=hit
@@ -1389,7 +1397,155 @@ class CombatEngine:
return AttackResult( return AttackResult(
hit=hit, crit=crit, damage=damage, roll=roll, total=total, ac=ac, hit=hit, crit=crit, damage=damage, roll=roll, total=total, ac=ac,
penalty=penalty, flank_bonus=flank, high_ground_bonus=high_ground, 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( def _consume_up_close_and_deadly(
@@ -3355,10 +3511,11 @@ class CombatEngine:
high_ground_str = ( high_ground_str = (
f"+{result.high_ground_bonus}(high)" if result.high_ground_bonus else "" 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 = ( 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 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 = ( line = (
f"round {self._current_round} {attacker.combatant.id}: {label} " f"round {self._current_round} {attacker.combatant.id}: {label} "
+15 -1
View File
@@ -37,6 +37,7 @@ class Condition:
cant_aoo: bool = False cant_aoo: bool = False
must_flee: bool = False must_flee: bool = False
speed_mult: float = 1.0 speed_mult: float = 1.0
miss_chance: int = 0
# ── Fear conditions ────────────────────────────────────────────────────────── # ── Fear conditions ──────────────────────────────────────────────────────────
@@ -253,6 +254,19 @@ STAGGERED = Condition(
name="staggered", 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 ───────────────────────────────────────────────────────────────── # ── Registry ─────────────────────────────────────────────────────────────────
CONDITIONS_BY_NAME: dict[str, Condition] = { CONDITIONS_BY_NAME: dict[str, Condition] = {
c.name: c c.name: c
@@ -260,6 +274,6 @@ CONDITIONS_BY_NAME: dict[str, Condition] = {
SHAKEN, SICKENED, FATIGUED, EXHAUSTED, ENTANGLED, DAZZLED, SHAKEN, SICKENED, FATIGUED, EXHAUSTED, ENTANGLED, DAZZLED,
BLINDED, DEAFENED, FLAT_FOOTED, PRONE, STUNNED, PARALYZED, BLINDED, DEAFENED, FLAT_FOOTED, PRONE, STUNNED, PARALYZED,
NAUSEATED, DAZED, STAGGERED, FRIGHTENED, PANICKED, COWERING, NAUSEATED, DAZED, STAGGERED, FRIGHTENED, PANICKED, COWERING,
ASLEEP, GRAPPLED, PINNED, ASLEEP, GRAPPLED, PINNED, BLURRED, DISPLACED,
] ]
} }
+11 -1
View File
@@ -32,6 +32,10 @@ class Rng(Protocol):
"""Return roll_die(20).""" """Return roll_die(20)."""
... ...
def d100(self) -> int:
"""Return roll_die(100)."""
...
def choice_index(self, n: int) -> int: def choice_index(self, n: int) -> int:
"""Return a uniform integer in 0..n-1 for deterministic tie-breaks.""" """Return a uniform integer in 0..n-1 for deterministic tie-breaks."""
... ...
@@ -49,6 +53,9 @@ class SeededRng:
def d20(self) -> int: def d20(self) -> int:
return self.roll_die(20) return self.roll_die(20)
def d100(self) -> int:
return self.roll_die(100)
def choice_index(self, n: int) -> int: def choice_index(self, n: int) -> int:
return self._rng.randrange(n) return self._rng.randrange(n)
@@ -56,7 +63,7 @@ class SeededRng:
class ScriptedRng: class ScriptedRng:
"""Rng that pops predetermined values from a queue. """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. Raises RngExhaustedError when the queue is empty.
""" """
@@ -71,6 +78,9 @@ class ScriptedRng:
def d20(self) -> int: def d20(self) -> int:
return self.roll_die(20) return self.roll_die(20)
def d100(self) -> int:
return self.roll_die(100)
def choice_index(self, _n: int) -> int: def choice_index(self, _n: int) -> int:
if not self._queue: if not self._queue:
raise RngExhaustedError(_EXHAUSTED_MSG) raise RngExhaustedError(_EXHAUSTED_MSG)
+432
View File
@@ -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
+1 -1
View File
@@ -794,7 +794,7 @@ def test_ranged_attack_ignores_flanking() -> None:
"initiative: aly d20=9+0=9", "initiative: aly d20=9+0=9",
"initiative: tgt d20=8+0=8", "initiative: tgt d20=8+0=8",
"round 1 tgt: AoO vs arc d20=15+2=17 AC 13 -> HIT 3 damage (6->3)", "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 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", "round 1 tgt: short sword vs arc d20=5+2=7 AC 13 -> MISS",
"battle over: draw after 1 rounds", "battle over: draw after 1 rounds",
+3 -2
View File
@@ -1232,7 +1232,7 @@ def test_observation_enriched_condition_definitions_present() -> None:
"""Given: an Observation built over any combat """Given: an Observation built over any combat
When: the top-level dict is inspected When: the top-level dict is inspected
Then: condition_definitions is a sorted tuple of (name, {mods, flags, 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") hero = make_combatant("hero")
foe = make_combatant("foe") foe = make_combatant("foe")
hero_state = make_state(hero, "players", (0, 0)) 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. # Sorted by name for determinism.
assert [n for n, _ in defs] == sorted(CONDITIONS_BY_NAME) assert [n for n, _ in defs] == sorted(CONDITIONS_BY_NAME)
for _name, body in defs: 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["mods"], list)
assert isinstance(body["flags"], list) assert isinstance(body["flags"], list)
assert isinstance(body["speed_mult"], float) assert isinstance(body["speed_mult"], float)
assert isinstance(body["miss_chance"], int)
def test_observation_enriched_condition_definitions_shaken() -> None: def test_observation_enriched_condition_definitions_shaken() -> None: