"""Deterministic combat engine: attacks, crits, DR, hp states, initiative, rounds. Phase 0 documented deviations from PF1e (conventions): - Action economy: a normal turn grants one standard action + one move action (or one full-round action), plus swift/free/immediate. The policy returns an ordered sequence of `Action` per turn; the engine executes them in order, stopping early if the actor drops or one side is wiped. `default_policy` returns `(full_attack,)` when already in range, `(charge,)` when a straight-line charge path exists, `(5foot_step, full_attack)` when a single step brings the target into range, or `(move, attack)` otherwise (move at full speed along the Dijkstra path, then single attack). Immediate actions (off-turn, consume next swift) are deferred; flanking, full attack, charge, withdraw, and 5-foot step are modeled; attacks of opportunity are resolved inline (see below). - 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 (full-round action ``full_attack``): at BAB +6/+11/+16, additional attacks at -5/-10/-15 from the weapon's attack bonus; iteratives are only computed for weapons with ``count == 1`` (natural multi-attacks do not gain iteratives). Multiple natural weapons in a single full attack 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: a move action travels up to the combatant's speed in cells along the true shortest path (Dijkstra cost field from the target, 5-10-5 diagonals), stopping adjacent to the nearest enemy; 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. - Flanking: +2 on melee attack rolls when an active ally with a melee weapon threatens the target from the opposite border or corner. Ranged attacks do not benefit from flanking; allies without melee weapons do not threaten. - Attacks of opportunity: a combatant threatens the 8 adjacent squares if active and wielding a melee weapon. Two triggers are modeled: (1) moving out of a threatened square — movement is resolved step-by-step, and each enemy that threatens the current square gets one AoO before the mover leaves it; (2) making a ranged attack while in a threatened square — each threatening enemy gets one AoO before each ranged swing. One AoO per combatant per round (tracked in ``_aoo_used``, cleared at round start); AoOs always strike (PF1e allows declining, the simulator does not). A 5-foot step never provokes AoOs (it is not a move action); the withdraw action protects only the starting square (see below). A 5-foot step and any other movement (move, charge, withdraw) are mutually exclusive within the same turn (``moved_this_turn`` flag, cleared in ``_take_turn``). - Charge: a full-round action that moves in a straight line (Bresenham) up to 2x speed toward the closest square from which the charger can melee the target, then makes a single melee attack at +2. The charger takes -2 AC until the start of their next turn (tracked as a StatModifier in ``effects``, cleared in ``_take_turn``). The straight line must be clear of difficult terrain, obstacles, and creatures; minimum 2 cells (10 ft). ``default_policy`` chooses a charge when the enemy is out of reach but a valid charge path exists. - Withdraw: a full-round action that moves up to 2x speed away from the nearest enemy. The starting square is not threatened — no AoO when leaving it. Subsequent squares provoke AoOs normally (resolved step-by-step, as for move). The path is a greedy ascent of the Dijkstra cost field from the threat. ``default_policy`` does not choose withdraw (it is only available via a custom policy). - 5-foot step: a free action that moves one square toward the nearest enemy without provoking AoOs. Mutually exclusive with any other movement (move, charge, withdraw) in the same turn — tracked via ``moved_this_turn``, cleared at the start of each turn in ``_take_turn``. ``default_policy`` uses ``(5foot_step, full_attack)`` when a single step brings the target into weapon range. - 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, field from typing import TYPE_CHECKING from pf1e_simulator.effects import StatModifier, resolve_modifiers if TYPE_CHECKING: from typing import Literal from pf1e_simulator.conditions import Condition 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 _FLANK_BONUS = 2 # PF1e: +2 melee attack when ally threatens opposite side _ITERATIVE_PENALTY = 5 # PF1e: each BAB iterative is at -5 from the previous _MAX_ITERATIVES = 4 # PF1e: BAB +16 gives 4 attacks (at +16, +11, +6, +1) _CHARGE_ATTACK_BONUS = 2 # PF1e: +2 attack roll on a charge _CHARGE_AC_PENALTY = 2 # PF1e: -2 AC until start of next turn after charging _CHARGE_MIN_CELLS = 2 # PF1e: charge must move at least 10 ft (2 squares) _STEP_DELTAS: tuple[Pos, ...] = ( (-1, -1), (-1, 0), (-1, 1), (0, -1), (0, 1), (1, -1), (1, 0), (1, 1), ) def _bresenham_line(start: Pos, end: Pos) -> list[Pos]: """Integer Bresenham line from start to end (exclusive of start, inclusive of end).""" r0, c0 = start r1, c1 = end dr = abs(r1 - r0) dc = abs(c1 - c0) sr = 1 if r1 > r0 else -1 sc = 1 if c1 > c0 else -1 err = dr - dc path: list[Pos] = [] r, c = r0, c0 while (r, c) != (r1, c1): e2 = 2 * err if e2 > -dc: err -= dc r += sr if e2 < dr: err += dr c += sc path.append((r, c)) return path @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 effects: list[StatModifier] = field(default_factory=list) conditions: list[Condition] = field(default_factory=list) moved_this_turn: bool = False power_attack: bool = False @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 flank_bonus: int = 0 base_bonus: int = 0 @dataclass(frozen=True) class SaveResult: """Outcome of one saving throw roll.""" success: bool roll: int total: int dc: int @dataclass(frozen=True) class Action: """What a policy wants a combatant to do this turn. Kinds map to PF1e action types: ``attack`` is a standard action (single attack, highest BAB only — no iteratives), ``move`` is a move action, ``full_attack`` is a full-round action (BAB iteratives), ``charge`` is a special full-round action (2x speed, straight line, +2 attack, -2 AC), ``5foot_step`` is a free action (one square, no AoO, mutually exclusive with move/charge/withdraw), ``swift`` and ``free`` are minor actions, ``immediate`` is an off-turn reaction (deferred). ``wait`` is a no-op. The engine executes a policy-returned sequence per turn. """ kind: Literal[ "attack", "move", "wait", "swift", "free", "immediate", "full_round", "full_attack", "charge", "withdraw", "5foot_step", ] 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} self._current_round = 0 self._aoo_used: set[str] = set() def _take_turn(self, state: CombatantState) -> bool: """Execute one combatant's full turn; return True if the battle is over.""" state.effects.clear() state.moved_this_turn = False state.power_attack = False actions = self._policy(self, state) before = len(self._transcript) for action in actions: if not state.active: break self._execute(state, action) if self._winner_side() is not None: break if state.active and len(self._transcript) == before: self._log(f"round {self._current_round} {state.combatant.id}: wait") return self._winner_side() is not None 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: self._current_round = round_no self._aoo_used.clear() for state in order: if not state.active: continue if self._take_turn(state): 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 _flanking_bonus(self, attacker: CombatantState, target: CombatantState) -> int: """+2 if an active ally threatens the target from the opposite side (CRB flanking).""" ar, ac = attacker.pos tr, tc = target.pos opposite = (2 * tr - ar, 2 * tc - ac) for ally in self._states: if ally is attacker or ally is target: continue if not ally.active or ally.side != attacker.side: continue if ally.pos != opposite: continue if any(w.kind == "melee" for w in ally.combatant.attacks): return _FLANK_BONUS return 0 def _stat_modifiers( self, state: CombatantState, target: str, *, weapon_name: str | None = None ) -> list[StatModifier]: """Collect all StatModifiers for ``target`` from effects, conditions, and features. ``weapon_name`` filters out modifiers with a ``weapon_filter`` that doesn't match (e.g. Weapon Focus (Pistol) only applies to "Pistol"). Modifiers without a ``weapon_filter`` are always included. """ mods = [e for e in state.effects if e.target == target] for cond in state.conditions: mods.extend(m for m in cond.modifiers if m.target == target) for feat in state.combatant.features: for m in feat.effects: if m.target != target: continue if m.weapon_filter is not None and m.weapon_filter != weapon_name: continue mods.append(m) return mods def _has_feat(self, state: CombatantState, feat_name: str) -> bool: """True if the combatant has a feature with the given name.""" return any(f.name == feat_name for f in state.combatant.features) def _power_attack_amt(self, attacker: CombatantState) -> int: """Power Attack exchange amount: X = BAB//4 + 1 (min 1). Returns 0 if inactive.""" if not attacker.power_attack or not self._has_feat(attacker, "Power Attack"): return 0 return max(1, attacker.combatant.bab // 4 + 1) def resolve_attack( self, attacker: CombatantState, target: CombatantState, weapon: AttackSpec, *, bonus_override: int | None = None, ) -> 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) flank = self._flanking_bonus(attacker, target) if weapon.kind == "melee" else 0 base_bonus = bonus_override if bonus_override is not None else weapon.attack_bonus atk_mods = self._stat_modifiers(attacker, "attack", weapon_name=weapon.name) attack_mod = resolve_modifiers(atk_mods) pa_amt = self._power_attack_amt(attacker) if weapon.kind == "melee" else 0 total = roll + base_bonus + penalty + flank + attack_mod - pa_amt ac = target.combatant.ac.total + resolve_modifiers(self._stat_modifiers(target, "ac")) 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() confirm_total = confirm + base_bonus + penalty + flank + attack_mod - pa_amt crit = confirm != _NATURAL_ONE and confirm_total >= ac dmg_mods = self._stat_modifiers(attacker, "damage", weapon_name=weapon.name) damage = sum(c.formula.roll(self._rng) for c in weapon.damage) + weapon.damage_bonus damage += resolve_modifiers(dmg_mods) damage += pa_amt * 2 if weapon.kind == "melee" else 0 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, flank_bonus=flank, base_bonus=base_bonus, ) 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 resolve_save( self, state: CombatantState, save_type: Literal["fort", "ref", "will"], dc: int ) -> SaveResult: """Roll a saving throw (fort/ref/will) against ``dc``. PF1e: natural 1 = automatic failure, natural 20 = automatic success. Effect modifiers and condition modifiers (StatModifier with matching target) are applied via ``resolve_modifiers`` — same bonus-type stacking rules as attacks. """ roll = self._rng.d20() base = getattr(state.combatant.saves, save_type) bonus = resolve_modifiers(self._stat_modifiers(state, save_type)) total = roll + base + bonus success = roll != _NATURAL_ONE and (roll == _NATURAL_TWENTY or total >= dc) return SaveResult(success=success, roll=roll, total=total, dc=dc) 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 _threatened_squares(self, state: CombatantState) -> frozenset[Pos]: """Squares this combatant threatens with a melee weapon (8 adjacent for 5 ft reach).""" if not state.active: return frozenset() if not any(w.kind == "melee" for w in state.combatant.attacks): return frozenset() r, c = state.pos return frozenset((r + dr, c + dc) for dr, dc in _STEP_DELTAS) def _enemies_threatening(self, state: CombatantState, pos: Pos) -> list[CombatantState]: """Active enemies whose threatened squares include pos (in self._states order).""" return [ s for s in self._states if s is not state and s.active and s.side != state.side and pos in self._threatened_squares(s) ] def _resolve_aoo(self, attacker: CombatantState, target: CombatantState) -> bool: """Resolve one attack of opportunity (single melee attack at normal bonus). AoOs are always taken (PF1e allows declining, but the simulator always strikes). Returns True if the target dropped. """ self._aoo_used.add(attacker.combatant.id) weapon = next((w for w in attacker.combatant.attacks if w.kind == "melee"), None) if weapon is None: return False return self._resolve_swing( attacker, target, weapon, label="AoO" ) def _check_provoked_aoo(self, attacker: CombatantState, weapon: AttackSpec) -> bool: """Check for ranged-AoO: firing a ranged weapon in a threatened square provokes. Returns True if the attacker dropped (caller must stop attacking). """ if weapon.kind != "ranged": return False for enemy in self._enemies_threatening(attacker, attacker.pos): if enemy.combatant.id in self._aoo_used: continue self._resolve_aoo(enemy, attacker) if not attacker.active: return True return False def can_step5_to_attack(self, state: CombatantState, target: CombatantState) -> bool: """True if a 5-foot step toward ``target`` would put ``state`` in weapon range.""" path = self._move_path(state, target) if not path: return False orig = state.pos state.pos = path[0] weapon = self.weapon_for(state, target) state.pos = orig return weapon is not None def can_charge(self, state: CombatantState, target: CombatantState) -> AttackSpec | None: """Return a melee weapon if ``state`` can charge ``target`` this turn, else None. PF1e charge requirements (CRB): - Must have line of sight to the target at the start of the turn. - Must move at least 10 ft (2 squares) and at most double speed. - Must move in a straight line (Bresenham) to the closest attackable square. - The path must not pass through blocking terrain, difficult terrain, or creatures. - Must end adjacent to the target (within melee reach). - Only a single melee attack is allowed. """ if not has_line_of_effect(self._grid, state.pos, target.pos): return None speed_cells = state.combatant.speed_land_ft // _SQUARE_FT max_cells = 2 * speed_cells blocked = frozenset( s.pos for s in self._states if s is not state and s is not target and s.active ) for weapon in state.combatant.attacks: if weapon.kind != "melee": continue reach_cells = weapon.reach_ft // _SQUARE_FT end = self._charge_end(state, target, reach_cells, max_cells, blocked) if end is not None: return weapon return None def _charge_end( self, state: CombatantState, target: CombatantState, reach_cells: int, max_cells: int, blocked: frozenset[Pos], ) -> Pos | None: """Find the closest square from which ``state`` can melee ``target`` via a charge. Scans squares within reach of the target, picks the one whose Bresenham line from ``state.pos`` is the shortest valid charge path (>=2 cells, <=max_cells, no blocking terrain or creatures, all passable). Ties break toward lower distance from state. """ candidates: list[tuple[int, Pos]] = [] for end in self._charge_candidates(target, reach_cells, blocked, state.pos): path = _bresenham_line(state.pos, end) if len(path) < _CHARGE_MIN_CELLS or len(path) > max_cells: continue if not self._charge_path_clear(path, blocked): continue candidates.append((len(path), end)) if not candidates: return None candidates.sort(key=lambda x: (x[0], self._grid.distance(state.pos, x[1]))) return candidates[0][1] def _charge_candidates( self, target: CombatantState, reach_cells: int, blocked: frozenset[Pos], start: Pos, ) -> list[Pos]: """Squares within reach of target, passable, unoccupied, not the start.""" tr, tc = target.pos result: list[Pos] = [] for dr in range(-reach_cells, reach_cells + 1): for dc in range(-reach_cells, reach_cells + 1): if abs(dr) > reach_cells or abs(dc) > reach_cells: continue if dr == 0 and dc == 0: continue end = (tr + dr, tc + dc) if end == start: continue if not self._grid.in_bounds(end) or not self._grid.passable(end): continue if end in blocked: continue if self._grid.distance(end, target.pos) > reach_cells: continue result.append(end) return result def _charge_path_clear(self, path: list[Pos], blocked: frozenset[Pos]) -> bool: """True if every square on the charge path is passable and clear.""" for pos in path: if not self._grid.in_bounds(pos) or not self._grid.passable(pos): return False if pos in blocked: return False terrain = self._grid.terrain_at(pos) if terrain.move_cost is not None and terrain.move_cost > 1: return False return True def _charge( self, attacker: CombatantState, target: CombatantState, weapon: AttackSpec ) -> None: """Execute a charge: move in a straight line, then make a single melee attack at +2. Sets a -2 AC penalty on the attacker that lasts until the start of their next turn (cleared in ``_take_turn``). Movement during a charge provokes AoOs step-by-step as for any move action. """ reach_cells = weapon.reach_ft // _SQUARE_FT speed_cells = attacker.combatant.speed_land_ft // _SQUARE_FT max_cells = 2 * speed_cells blocked = frozenset( s.pos for s in self._states if s is not attacker and s is not target and s.active ) end = self._charge_end(attacker, target, reach_cells, max_cells, blocked) if end is None: return path = _bresenham_line(attacker.pos, end) start = attacker.pos actual_path: list[Pos] = [] for step in path: for enemy in self._enemies_threatening(attacker, attacker.pos): if enemy.combatant.id in self._aoo_used: continue self._resolve_aoo(enemy, attacker) if not attacker.active: break if not attacker.active: break attacker.pos = step actual_path.append(step) if actual_path: attacker.moved_this_turn = True coords = "->".join(f"({p[0]},{p[1]})" for p in (start, *actual_path)) self._log( f"round {self._current_round} {attacker.combatant.id}: charge {coords}" ) if not attacker.active: return attacker.effects.append(StatModifier(target="ac", value=-_CHARGE_AC_PENALTY)) bonus = weapon.attack_bonus + _CHARGE_ATTACK_BONUS self._resolve_swing( attacker, target, weapon, label="charge", bonus_override=bonus ) def _execute(self, state: CombatantState, action: Action) -> None: target = next((s for s in self._states if s.combatant.id == action.target_id), None) if action.kind in ("attack", "full_attack"): self._execute_attack(state, target, action.kind) elif action.kind == "charge": self._execute_charge(state, target) elif action.kind == "withdraw": self._withdraw(state, target) elif action.kind == "5foot_step": if target is None: return self._step5(state, target) elif action.kind == "move": if target is None: return self._move(state, target) elif action.kind == "wait": self._log(f"round {self._current_round} {state.combatant.id}: wait") def _execute_attack( self, state: CombatantState, target: CombatantState | None, kind: str ) -> None: if target is None or not target.active: return weapon = self.weapon_for(state, target) if weapon is None: return if kind == "attack": self._single_attack(state, target, weapon) elif kind == "full_attack": self._full_attack(state, target, weapon) else: self._attack(state, target, weapon) def _execute_charge(self, state: CombatantState, target: CombatantState | None) -> None: if target is None or not target.active: return weapon = self.can_charge(state, target) if weapon is None: return self._charge(state, target, weapon) def _resolve_swing( self, attacker: CombatantState, target: CombatantState, weapon: AttackSpec, *, label: str, bonus_override: int | None = None, ) -> bool: """Resolve one swing, log it, update stats. Return True if target dropped.""" live = self._stats[attacker.combatant.id] target_live = self._stats[target.combatant.id] hp_before = target.hp result = self.resolve_attack(attacker, target, weapon, bonus_override=bonus_override) 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" flank_str = f"+{result.flank_bonus}(flank)" if result.flank_bonus else "" bonus_str = ( f"{result.base_bonus}{result.penalty:+d}{flank_str}" if result.penalty else f"{result.base_bonus}{flank_str}" ) line = ( f"round {self._current_round} {attacker.combatant.id}: {label} " f"vs {target.combatant.id} " f"d20={result.roll}+{bonus_str}={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 {self._current_round} {attacker.combatant.id}: " f"{target.combatant.id} dead" ) return True if not target.active: self._log( f"round {self._current_round} {attacker.combatant.id}: " f"{target.combatant.id} down" ) return True return False def _attack( self, attacker: CombatantState, target: CombatantState, weapon: AttackSpec ) -> None: for swing in range(1, weapon.count + 1): if not attacker.active: return if self._check_provoked_aoo(attacker, weapon): return label = weapon.name if weapon.count == 1 else f"{weapon.name} #{swing}" if self._resolve_swing(attacker, target, weapon, label=label): return def _single_attack( self, attacker: CombatantState, target: CombatantState, weapon: AttackSpec ) -> None: if not attacker.active: return if self._check_provoked_aoo(attacker, weapon): return self._resolve_swing(attacker, target, weapon, label=weapon.name) def _full_attack( self, attacker: CombatantState, target: CombatantState, weapon: AttackSpec ) -> None: if weapon.count > 1: self._attack(attacker, target, weapon) return n = min( _MAX_ITERATIVES, 1 + max(0, (attacker.combatant.bab - 1) // _ITERATIVE_PENALTY), ) for i in range(n): if not attacker.active: return if self._check_provoked_aoo(attacker, weapon): return label = weapon.name if n == 1 else f"{weapon.name} #{i + 1}" bonus = weapon.attack_bonus - _ITERATIVE_PENALTY * i if self._resolve_swing( attacker, target, weapon, label=label, bonus_override=bonus ): return def _step5(self, state: CombatantState, target: CombatantState) -> None: if state.moved_this_turn: return path = self._move_path(state, target) if not path: return start = state.pos state.pos = path[0] state.moved_this_turn = True self._log( f"round {self._current_round} {state.combatant.id}: " f"5ft step ({start[0]},{start[1]})->({path[0][0]},{path[0][1]})" ) def _move(self, state: CombatantState, target: CombatantState) -> None: if state.moved_this_turn: return path = self._move_path(state, target) if not path: return start = state.pos actual_path: list[Pos] = [] for step in path: for enemy in self._enemies_threatening(state, state.pos): if enemy.combatant.id in self._aoo_used: continue self._resolve_aoo(enemy, state) if not state.active: break if not state.active: break state.pos = step actual_path.append(step) if actual_path: state.moved_this_turn = True coords = "->".join(f"({p[0]},{p[1]})" for p in (start, *actual_path)) self._log(f"round {self._current_round} {state.combatant.id}: move {coords}") def _move_path(self, state: CombatantState, target: CombatantState) -> list[Pos]: """Full movement path toward the target, up to the combatant's speed.""" speed_cells = state.combatant.speed_land_ft // _SQUARE_FT if speed_cells <= 0: return [] 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 [] path: list[Pos] = [] current = state.pos budget = speed_cells while budget > 0: current_cost = to_target[current] best: tuple[int, int, Pos] | None = None row, col = current for d_row, d_col in _STEP_DELTAS: nxt = (row + d_row, col + d_col) if nxt in blocked: continue nxt_cost = to_target.get(nxt) if nxt_cost is None or nxt_cost >= current_cost: continue step = self._grid.step_cost(current, nxt, 0) if step > budget: continue score = step + nxt_cost if best is None or score < best[0]: best = (score, step, nxt) if best is None: break current = best[2] path.append(current) budget -= best[1] return path def _flee_path(self, state: CombatantState, threat: CombatantState) -> list[Pos]: """Movement path away from ``threat``, up to double speed. Uses the Dijkstra cost field from the threat and greedily picks the step that maximizes distance (cost) from the threat each cell. """ max_cells = 2 * (state.combatant.speed_land_ft // _SQUARE_FT) if max_cells <= 0: return [] blocked = frozenset(s.pos for s in self._states if s is not state and s.active) from_threat = self._grid.reachable(threat.pos, None, blocked) if state.pos not in from_threat: return [] path: list[Pos] = [] current = state.pos budget = max_cells while budget > 0: best = self._best_flee_step(current, from_threat, blocked, budget) if best is None: break _, step_cost, nxt = best current = nxt path.append(current) budget -= step_cost return path def _best_flee_step( self, current: Pos, from_threat: dict[Pos, int], blocked: frozenset[Pos], budget: int, ) -> tuple[int, int, Pos] | None: """Pick the adjacent square that maximizes distance from the threat.""" current_cost = from_threat[current] best: tuple[int, int, Pos] | None = None row, col = current for d_row, d_col in _STEP_DELTAS: nxt = (row + d_row, col + d_col) if nxt in blocked or not self._grid.passable(nxt): continue if not self._grid.diagonal_allowed(current, nxt): continue nxt_cost = from_threat.get(nxt) if nxt_cost is None or nxt_cost <= current_cost: continue step = self._grid.step_cost(current, nxt, 0) if step > budget: continue score = -(nxt_cost * 100) + step if best is None or score < best[0]: best = (score, step, nxt) return best def _withdraw(self, state: CombatantState, threat: CombatantState | None) -> None: """Full-round action: move up to 2x speed away from the nearest enemy. The starting square is not threatened — no AoO when leaving it. Subsequent squares provoke AoOs normally (step-by-step, as for move). """ if threat is None: return path = self._flee_path(state, threat) if not path: return start = state.pos actual_path: list[Pos] = [] for i, step in enumerate(path): if i > 0: for enemy in self._enemies_threatening(state, state.pos): if enemy.combatant.id in self._aoo_used: continue self._resolve_aoo(enemy, state) if not state.active: break if not state.active: break state.pos = step actual_path.append(step) if actual_path: state.moved_this_turn = True coords = "->".join(f"({p[0]},{p[1]})" for p in (start, *actual_path)) self._log( f"round {self._current_round} {state.combatant.id}: withdraw {coords}" ) def _log(self, line: str) -> None: self._transcript.append(line) Policy = Callable[[CombatEngine, CombatantState], tuple[Action, ...]] def default_policy(engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]: """Full-attack the nearest enemy; charge, 5ft-step, or approach if out of range. Decision order: 1. Full-attack if a weapon is usable against the nearest enemy this turn (no move needed). 2. Charge if a straight-line charge path exists (2x speed, +2 attack, -2 AC). 3. 5-foot step then full-attack if a single step brings the target into weapon range. 4. Move toward the target then single attack (standard + move economy). 5. Wait if nothing is possible. """ 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="full_attack", target_id=target.combatant.id),) charge_weapon = engine.can_charge(state, target) if charge_weapon is not None: return (Action(kind="charge", target_id=target.combatant.id),) if engine.can_step5_to_attack(state, target): return ( Action(kind="5foot_step", target_id=target.combatant.id), Action(kind="full_attack", target_id=target.combatant.id), ) return ( Action(kind="move", target_id=target.combatant.id), Action(kind="attack", target_id=target.combatant.id), )