feat(combat): add concealment miss chance, sneak attack, and Precise Shot
This commit is contained in:
+185
-28
@@ -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} "
|
||||
|
||||
Reference in New Issue
Block a user