Files
pf1e-simulator/src/pf1e_simulator/combat.py
T
ctan e17c571d7e 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)
2026-08-17 22:49:50 +02:00

351 lines
12 KiB
Python

"""Deterministic combat engine: attacks, crits, DR, hp states, initiative, rounds.
Phase 0 documented deviations from PF1e (conventions):
- One action per turn: move OR attack (no move+attack, no AoO, no flanking).
- Natural 1 always misses; natural 20 always hits and threatens a crit.
- A confirmed crit multiplies the total damage (dice + flat bonus) by crit_mult.
- DR applies once, after crit multiplication; any bypassing type defeats DR.
- Death when hp < min(-10, -CON); hp <= 0 cannot act.
- 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.
- 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
from collections.abc import Callable
from dataclasses import asdict, dataclass
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from typing import Literal
from pf1e_simulator.grid import Grid
from pf1e_simulator.map import Pos
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),
(-1, 0),
(-1, 1),
(0, -1),
(0, 1),
(1, -1),
(1, 0),
(1, 1),
)
@dataclass(frozen=True)
class CombatantStats:
"""Aggregated outcomes for one combatant over a battle."""
hits: int = 0
crits: int = 0
damage_dealt: int = 0
damage_taken: int = 0
@dataclass
class CombatantState:
"""Mutable runtime state of one combatant on the grid."""
combatant: Combatant
side: str
pos: Pos
hp: int
@property
def active(self) -> bool:
return self.hp > 0
@property
def dead(self) -> bool:
return self.hp < self.death_threshold
@property
def death_threshold(self) -> int:
return min(_DEATH_FLOOR, -self.combatant.abilities.con_score)
@dataclass(frozen=True)
class AttackResult:
"""Outcome of one attack roll, including the raw rolls for the log."""
hit: bool
crit: bool
damage: int
roll: int
total: int
ac: int
@dataclass(frozen=True)
class Action:
"""What a policy wants a combatant to do this turn."""
kind: Literal["attack", "move", "wait"]
target_id: str | None = None
@dataclass(frozen=True)
class CombatResult:
"""Outcome of a full battle: winner, rounds, log, and per-combatant stats."""
winner: str | None
rounds: int
transcript: tuple[str, ...]
stats: dict[str, CombatantStats]
@dataclass
class _LiveStats:
hits: int = 0
crits: int = 0
damage_dealt: int = 0
damage_taken: int = 0
class CombatEngine:
"""Runs one battle to completion on a grid with a single RNG stream."""
def __init__(
self,
rng: Rng,
grid: Grid,
states: list[CombatantState],
*,
round_cap: int = 100,
policy: Policy | None = None,
) -> None:
self._rng = rng
self._grid = grid
self._states = states
self._round_cap = round_cap
self._policy = policy if policy is not None else default_policy
self._transcript: list[str] = []
self._stats: dict[str, _LiveStats] = {s.combatant.id: _LiveStats() for s in states}
def run(self) -> CombatResult:
"""Run the battle and return the final result and transcript."""
order = self._roll_initiative()
round_no = 1
while round_no <= self._round_cap:
for state in order:
if not state.active:
continue
action = self._policy(self, state)
self._execute(state, action, round_no)
if self._winner_side() is not None:
break
if self._winner_side() is not None:
break
round_no += 1
winner = self._winner_side()
if winner is not None:
self._log(f"battle over: {winner} win in {round_no} rounds")
else:
self._log(f"battle over: draw after {self._round_cap} rounds")
stats = {cid: CombatantStats(**asdict(live)) for cid, live in self._stats.items()}
return CombatResult(
winner=winner,
rounds=round_no if winner is not None else self._round_cap,
transcript=tuple(self._transcript),
stats=stats,
)
def nearest_enemy(self, state: CombatantState) -> CombatantState | None:
"""Closest active enemy by grid distance; ties keep list order."""
enemies = [s for s in self._states if s.side != state.side and s.active]
if not enemies:
return None
return min(
enemies,
key=lambda s: (self._grid.distance(state.pos, s.pos), self._states.index(s)),
)
def weapon_for(self, attacker: CombatantState, target: CombatantState) -> AttackSpec | None:
"""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:
return weapon
if (
weapon.kind == "ranged"
and weapon.range_increment_ft is not None
and dist_ft <= weapon.range_increment_ft
):
return weapon
return None
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
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
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
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)
def _apply_dr(self, damage: int, weapon: AttackSpec, target: CombatantState) -> int:
dr: DamageReduction | None = target.combatant.dr
if dr is None:
return damage
types = {t for component in weapon.damage for t in component.types}
if types & dr.bypass:
return damage
return max(0, damage - dr.amount)
def _roll_initiative(self) -> list[CombatantState]:
rolls: dict[str, tuple[int, int]] = {}
for state in self._states:
roll = self._rng.d20()
rolls[state.combatant.id] = (roll, roll + state.combatant.initiative_mod)
order = sorted(
self._states,
key=lambda s: (
rolls[s.combatant.id][1],
s.combatant.initiative_mod,
self._states.index(s),
),
reverse=True,
)
for state in order:
roll, total = rolls[state.combatant.id]
self._log(
f"initiative: {state.combatant.id} "
f"d20={roll}+{state.combatant.initiative_mod}={total}"
)
return order
def _winner_side(self) -> str | None:
active_sides = {s.side for s in self._states if s.active}
if len(active_sides) == 1:
return active_sides.pop()
return None
def _execute(self, state: CombatantState, action: Action, round_no: int) -> None:
target = next((s for s in self._states if s.combatant.id == action.target_id), None)
if action.kind == "attack":
if target is None or not target.active:
self._log(f"round {round_no} {state.combatant.id}: wait")
return
weapon = self.weapon_for(state, target)
if weapon is None:
self._log(f"round {round_no} {state.combatant.id}: wait")
return
self._attack(state, target, weapon, round_no)
elif action.kind == "move":
if target is None:
self._log(f"round {round_no} {state.combatant.id}: wait")
return
self._move(state, target, round_no)
else:
self._log(f"round {round_no} {state.combatant.id}: wait")
def _attack(
self, attacker: CombatantState, target: CombatantState, weapon: AttackSpec, round_no: int
) -> None:
hp_before = target.hp
result = self.resolve_attack(attacker, target, weapon)
live = self._stats[attacker.combatant.id]
live.hits += int(result.hit)
live.crits += int(result.crit)
live.damage_dealt += result.damage
self._stats[target.combatant.id].damage_taken += result.damage
outcome = "CRIT" if result.crit else "HIT" if result.hit else "MISS"
line = (
f"round {round_no} {attacker.combatant.id}: {weapon.name} vs {target.combatant.id} "
f"d20={result.roll}+{weapon.attack_bonus}={result.total} AC {result.ac} -> {outcome}"
)
if result.hit:
line += f" {result.damage} damage ({hp_before}->{target.hp})"
self._log(line)
if target.dead:
self._log(f"round {round_no} {attacker.combatant.id}: {target.combatant.id} dead")
elif not target.active:
self._log(f"round {round_no} {attacker.combatant.id}: {target.combatant.id} down")
def _move(self, state: CombatantState, target: CombatantState, round_no: int) -> None:
step = self._step_toward(state, target)
if step is None:
self._log(f"round {round_no} {state.combatant.id}: wait")
return
self._log(
f"round {round_no} {state.combatant.id}: move "
f"({state.pos[0]},{state.pos[1]})->({step[0]},{step[1]})"
)
state.pos = step
def _step_toward(self, state: CombatantState, target: CombatantState) -> Pos | None:
speed_cells = state.combatant.speed_land_ft // _SQUARE_FT
if speed_cells <= 0:
return None
blocked = frozenset(s.pos for s in self._states if s is not state and s.active)
to_target = self._grid.reachable(target.pos, None, blocked)
if state.pos not in to_target:
return None
best: tuple[int, Pos] | None = None
row, col = state.pos
for d_row, d_col in _STEP_DELTAS:
nxt = (row + d_row, col + d_col)
cost = to_target.get(nxt)
if cost is None:
continue
step = self._grid.step_cost(state.pos, nxt, 0)
if step > speed_cells:
continue
score = step + cost
if best is None or score < best[0]:
best = (score, nxt)
if best is None:
return None
return best[1]
def _log(self, line: str) -> None:
self._transcript.append(line)
Policy = Callable[[CombatEngine, CombatantState], Action]
def default_policy(engine: CombatEngine, state: CombatantState) -> Action:
"""Attack the nearest active enemy when a weapon is in range, else approach."""
target = engine.nearest_enemy(state)
if target is None:
return Action(kind="wait")
if engine.weapon_for(state, target) is not None:
return Action(kind="attack", target_id=target.combatant.id)
return Action(kind="move", target_id=target.combatant.id)