"""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). - A weapon with `count` > 1 ("2x Talons") resolves `count` independent attacks in one attack action; remaining swings are lost once the target drops (down or dead) mid-routine. BAB iterative attacks are not modeled. - 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. Soft cover: active creatures between attacker and target also grant +4 AC on ranged attacks (CRB soft cover); melee attacks ignore 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. """ 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 _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), (-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 penalty: int = 0 @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 * _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() 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 occupied = frozenset( s.pos for s in self._states if s.active and s is not attacker and s is not target ) if has_cover( self._grid, attacker.pos, target.pos, ranged=weapon.kind == "ranged", occupied=occupied, ): 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 + 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, penalty=penalty ) 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: live = self._stats[attacker.combatant.id] target_live = self._stats[target.combatant.id] for swing in range(1, weapon.count + 1): hp_before = target.hp result = self.resolve_attack(attacker, target, weapon) live.hits += int(result.hit) live.crits += int(result.crit) live.damage_dealt += result.damage 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}+{bonus}={result.total} " f"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") return if not target.active: self._log(f"round {round_no} {attacker.combatant.id}: {target.combatant.id} down") return 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)