feat(combat): wire corner-rule LoS/cover into attack resolution

- weapon_for: gate attacks on has_line_of_effect (no LoE -> policy moves)
- resolve_attack: +4 AC cover bonus on hit and crit-confirm (ranged flag)
- tests: cover bonus (ranged pillar, melee wall corner), no-LoE move/attack,
  no-LoE unreachable wait; 11 resolve_attack call sites updated
- README: quick-start and verified example re-measured (55.7% 1x2), rules
  and architecture updated (los.py wired)
This commit is contained in:
2026-08-17 22:49:50 +02:00
parent a21e234b41
commit e17c571d7e
3 changed files with 436 additions and 18 deletions
+16 -4
View File
@@ -9,7 +9,12 @@ Phase 0 documented deviations from PF1e (conventions):
- Initiative ties: higher initiative_mod first, then list order (no re-roll).
- Movement: one step per move action toward the nearest enemy, following the
true shortest path (Dijkstra cost field from the target); ties keep delta order.
- Ranged attacks ignore cover and range penalties in Phase 0.
- Line of effect gates all attacks: a target fully behind blocking terrain
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).
"""
from __future__ import annotations
@@ -26,10 +31,13 @@ if TYPE_CHECKING:
from pf1e_simulator.models import AttackSpec, Combatant, DamageReduction
from pf1e_simulator.rng import Rng
from pf1e_simulator.los import has_cover, has_line_of_effect
_SQUARE_FT = 5 # Phase 0 maps use 5-ft squares
_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
_STEP_DELTAS: tuple[Pos, ...] = (
(-1, -1),
@@ -172,7 +180,9 @@ class CombatEngine:
)
def weapon_for(self, attacker: CombatantState, target: CombatantState) -> AttackSpec | None:
"""First weapon of the attacker usable against the target at this range."""
"""First weapon usable against the target at this range and with clear LoE."""
if not has_line_of_effect(self._grid, attacker.pos, target.pos):
return None
dist_ft = self._grid.distance(attacker.pos, target.pos) * _SQUARE_FT
for weapon in attacker.combatant.attacks:
if weapon.kind in ("melee", "touch") and dist_ft <= weapon.reach_ft:
@@ -186,12 +196,14 @@ class CombatEngine:
return None
def resolve_attack(
self, target: CombatantState, weapon: AttackSpec
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
ac = target.combatant.ac.total
if has_cover(self._grid, attacker.pos, target.pos, ranged=weapon.kind == "ranged"):
ac += _COVER_AC_BONUS
hit = roll == _NATURAL_TWENTY or (roll != _NATURAL_ONE and total >= ac)
crit = False
damage = 0
@@ -266,7 +278,7 @@ class CombatEngine:
self, attacker: CombatantState, target: CombatantState, weapon: AttackSpec, round_no: int
) -> None:
hp_before = target.hp
result = self.resolve_attack(target, weapon)
result = self.resolve_attack(attacker, target, weapon)
live = self._stats[attacker.combatant.id]
live.hits += int(result.hit)
live.crits += int(result.crit)