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
+185 -28
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
# 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} "
+15 -1
View File
@@ -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,
]
}
+11 -1
View File
@@ -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)