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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user