feat(combat): apply cumulative -2 range penalties per increment
This commit is contained in:
@@ -16,8 +16,10 @@ Phase 0 documented deviations from PF1e (conventions):
|
||||
cannot be attacked, and the policy moves to gain sight instead.
|
||||
- Cover (corner rule) grants +4 AC on hit and crit-confirm rolls; melee and
|
||||
ranged reuse the same corner rule.
|
||||
- Ranged: only the first range increment is enforced in Phase 0 (no distance
|
||||
penalty, no soft cover from creatures).
|
||||
- Ranged: cumulative -2 per full range increment beyond the first, up to 10
|
||||
range increments; the penalty applies to attack and crit-confirm rolls.
|
||||
Thrown weapons (5 increments max) are not distinguished; soft cover from
|
||||
creatures is not modeled.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -41,6 +43,8 @@ _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
|
||||
_COVER_AC_BONUS = 4 # PF1e: partial cover grants +4 AC
|
||||
_RANGE_INCREMENT_PENALTY = 2 # PF1e: -2 per full range increment beyond the first
|
||||
_MAX_RANGE_INCREMENTS = 10 # PF1e: projectile weapons shoot up to 10 increments
|
||||
|
||||
_STEP_DELTAS: tuple[Pos, ...] = (
|
||||
(-1, -1),
|
||||
@@ -96,6 +100,7 @@ class AttackResult:
|
||||
roll: int
|
||||
total: int
|
||||
ac: int
|
||||
penalty: int = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -193,17 +198,28 @@ class CombatEngine:
|
||||
if (
|
||||
weapon.kind == "ranged"
|
||||
and weapon.range_increment_ft is not None
|
||||
and dist_ft <= weapon.range_increment_ft
|
||||
and dist_ft <= weapon.range_increment_ft * _MAX_RANGE_INCREMENTS
|
||||
):
|
||||
return weapon
|
||||
return 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 != "ranged" or weapon.range_increment_ft is None:
|
||||
return 0
|
||||
increments = dist_ft // weapon.range_increment_ft
|
||||
if dist_ft % weapon.range_increment_ft != 0:
|
||||
increments += 1
|
||||
return -_RANGE_INCREMENT_PENALTY * max(0, increments - 1)
|
||||
|
||||
def resolve_attack(
|
||||
self, attacker: CombatantState, target: CombatantState, weapon: AttackSpec
|
||||
) -> AttackResult:
|
||||
"""Roll one attack (with crit confirm and damage) and apply it."""
|
||||
roll = self._rng.d20()
|
||||
total = roll + weapon.attack_bonus
|
||||
dist_ft = self._grid.distance(attacker.pos, target.pos) * _SQUARE_FT
|
||||
penalty = self.range_penalty(weapon, dist_ft)
|
||||
total = roll + weapon.attack_bonus + penalty
|
||||
ac = target.combatant.ac.total
|
||||
if has_cover(self._grid, attacker.pos, target.pos, ranged=weapon.kind == "ranged"):
|
||||
ac += _COVER_AC_BONUS
|
||||
@@ -213,13 +229,15 @@ class CombatEngine:
|
||||
if hit:
|
||||
if roll != _NATURAL_ONE and roll >= weapon.crit_range:
|
||||
confirm = self._rng.d20()
|
||||
crit = confirm != _NATURAL_ONE and confirm + weapon.attack_bonus >= ac
|
||||
crit = confirm != _NATURAL_ONE and confirm + weapon.attack_bonus + penalty >= ac
|
||||
damage = sum(c.formula.roll(self._rng) for c in weapon.damage) + weapon.damage_bonus
|
||||
if crit:
|
||||
damage *= weapon.crit_mult
|
||||
damage = self._apply_dr(damage, weapon, target)
|
||||
target.hp -= damage
|
||||
return AttackResult(hit=hit, crit=crit, damage=damage, roll=roll, total=total, ac=ac)
|
||||
return AttackResult(
|
||||
hit=hit, crit=crit, damage=damage, roll=roll, total=total, ac=ac, penalty=penalty
|
||||
)
|
||||
|
||||
def _apply_dr(self, damage: int, weapon: AttackSpec, target: CombatantState) -> int:
|
||||
dr: DamageReduction | None = target.combatant.dr
|
||||
@@ -291,9 +309,14 @@ class CombatEngine:
|
||||
target_live.damage_taken += result.damage
|
||||
outcome = "CRIT" if result.crit else "HIT" if result.hit else "MISS"
|
||||
label = weapon.name if weapon.count == 1 else f"{weapon.name} #{swing}"
|
||||
bonus = (
|
||||
f"{weapon.attack_bonus}{result.penalty:+d}"
|
||||
if result.penalty
|
||||
else f"{weapon.attack_bonus}"
|
||||
)
|
||||
line = (
|
||||
f"round {round_no} {attacker.combatant.id}: {label} vs {target.combatant.id} "
|
||||
f"d20={result.roll}+{weapon.attack_bonus}={result.total} "
|
||||
f"d20={result.roll}+{bonus}={result.total} "
|
||||
f"AC {result.ac} -> {outcome}"
|
||||
)
|
||||
if result.hit:
|
||||
|
||||
Reference in New Issue
Block a user