feat(combat): apply cumulative -2 range penalties per increment

This commit is contained in:
2026-08-17 22:49:50 +02:00
parent b748980423
commit f67cd017e4
3 changed files with 145 additions and 21 deletions
+30 -7
View File
@@ -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: