feat(combat): deterministic engine with attacks, crits, DR, hp states, initiative, rounds, basic policies
This commit is contained in:
@@ -0,0 +1,333 @@
|
||||
"""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: greedy single step toward the nearest enemy minimizing
|
||||
accumulated cost + remaining grid distance; ties keep delta order.
|
||||
- Ranged attacks ignore cover and range penalties in Phase 0.
|
||||
"""
|
||||
|
||||
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
|
||||
|
||||
_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
|
||||
|
||||
_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 of the attacker usable against the target at this range."""
|
||||
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, 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
|
||||
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(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)
|
||||
costs = self._grid.reachable(state.pos, speed_cells, blocked)
|
||||
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 = costs.get(nxt)
|
||||
if cost is None:
|
||||
continue
|
||||
score = cost + self._grid.distance(nxt, target.pos)
|
||||
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)
|
||||
Reference in New Issue
Block a user