"""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 modeled via an opt-in ``reaction_policy`` hook: each resolved attack, movement step, spell cast, and damage application emits a ``ReactionEvent``; the first combatant whose policy returns an ``Action(kind="immediate")`` executes it immediately, and its next turn's swift action is consumed. 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 by default (tracked in ``_aoo_used``, cleared at round start); the Combat Reflexes feat raises the allowance to ``max(1, 1 + DEX mod)`` per round. 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, Iterable from dataclasses import asdict, dataclass, field from pathlib import Path from typing import TYPE_CHECKING from pf1e_simulator.conditions import CONDITIONS_BY_NAME, Condition from pf1e_simulator.effects import ActiveBuff, ActiveCondition, StatModifier, resolve_modifiers from pf1e_simulator.loaders.combatant import CombatantLoadError, load_combatant from pf1e_simulator.models import PREDEFINED_SKILLS from pf1e_simulator.spells import ( AreaEffect, BuffEffect, ConditionEffect, DamageEffect, HealEffect, SpellEffect, SummonEffect, save_descriptors_for_effects, spell_range_ft, spell_save_descriptors, ) 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.spells import SpellSpec from pf1e_simulator.los import ( has_cover_cells, has_line_of_effect_cells, ) _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 _MAX_TOUCH_RANGE_INCREMENTS = 5 # PF1e: thrown/firearm touch weapons shoot up to 5 increments _FLANK_BONUS = 2 # PF1e: +2 melee attack when ally threatens opposite side _HIGH_GROUND_BONUS = 1 # PF1e: +1 melee attack when attacker stands higher than target _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) _WEAPON_DURABILITY = 10 # Accumulated sunder damage that destroys a weapon. _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) _UP_CLOSE_RANGE_FT = 30 # PF1e: Up Close and Deadly works within 30 ft # PF1e: Point-Blank Shot grants +1/+1 within 30 ft with ranged weapons _POINT_BLANK_RANGE_FT = 30 _SAVE_DESCRIPTOR_CONDITIONS = frozenset({ "fear", "cold", "electricity", "fey", "divination", "poison", "disease", "mind_affecting", }) _SIZE_MODIFIERS: dict[str, int] = { "fine": -8, "diminutive": -4, "tiny": -2, "small": -1, "medium": 0, "large": 1, "huge": 2, "gargantuan": 4, "colossal": 8, } # PF1e natural reach for tall creatures (simplification: no long/quadruped # distinction). Unknown sizes fall back to 5 ft (the historical behavior). _SIZE_NATURAL_REACH_FT: dict[str, int] = { "Small": 5, "Medium": 5, "Large": 10, "Huge": 15, "Gargantuan": 20, "Colossal": 30, } # Number of grid cells per side of a creature's footprint (square footprint). # Unknown sizes fall back to 1 (the historical single-cell behavior). SIZE_CELLS: dict[str, int] = { "Small": 1, "Medium": 1, "Large": 2, "Huge": 3, "Gargantuan": 4, "Colossal": 6, } # Row-major scan order of the 8 adjacent cells, used by reposition. _NEIGHBOR_OFFSETS: tuple[tuple[int, int], ...] = ( (-1, -1), (-1, 0), (-1, 1), (0, -1), (0, 1), (1, -1), (1, 0), (1, 1), ) def _save_label(result: SaveResult | None) -> str: if result is None: return "NO SAVE" return "SAVED" if result.success else "FAILED" _STEP_DELTAS: tuple[Pos, ...] = ( (-1, -1), (-1, 0), (-1, 1), (0, -1), (0, 1), (1, -1), (1, 0), (1, 1), ) def _effective_reach_ft(state: CombatantState, weapon: AttackSpec) -> int: """Effective melee reach in feet: max of natural size reach and weapon reach.""" return max(_SIZE_NATURAL_REACH_FT.get(state.combatant.size, 5), weapon.reach_ft) 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 # Old fields for backwards compatibility with tests effects: list[StatModifier] = field(default_factory=list) conditions: list[Condition] = field(default_factory=list) # New duration-tracked fields active_buffs: list[ActiveBuff] = field(default_factory=list) active_conditions: list[ActiveCondition] = field(default_factory=list) resources_left: dict[str, int] = field(default_factory=dict) spell_slots_left: dict[int, int] = field(default_factory=dict) moved_this_turn: bool = False power_attack: bool = False grappled_with: str | None = None deed_up_close_and_deadly: bool = False immediate_used: bool = False summon_duration: int = 0 summon_caster_id: str | None = None # Runtime weapon durability (not in the frozen data model): accumulated # sunder damage per weapon id, and the set of weapon ids rendered unusable. weapon_damage: dict[str, int] = field(default_factory=dict) weapon_broken: set[str] = field(default_factory=set) # Runtime weapon theft (not in the frozen data model): weapon ids taken by # steal maneuvers. weapon_stolen: set[str] = field(default_factory=set) def usable_attacks(self) -> list[AttackSpec]: return [ w for w in self.combatant.attacks if w.id not in self.weapon_broken and w.id not in self.weapon_stolen ] def occupied_cells(self) -> frozenset[Pos]: """The set of grid cells this creature occupies (its footprint). ``pos`` is the anchor (top-left corner); the footprint is an n x n square where n = SIZE_CELLS[size]. A 1x1 creature occupies {pos}. """ n = SIZE_CELLS.get(self.combatant.size, 1) r, c = self.pos return frozenset( (r + dr, c + dc) for dr in range(n) for dc in range(n) ) def __post_init__(self) -> None: if not self.spell_slots_left: self.spell_slots_left = dict(self.combatant.spell_slots) def spend_resource(self, name: str, n: int = 1) -> bool: """Spend n points of a resource. Returns True if successful, False if insufficient.""" current = self.resources_left.get(name, 0) if current < n: return False self.resources_left[name] = current - n return True def spend_spell_slot(self, level: int) -> bool: """Spend one spell slot of ``level``. Returns True if a slot was spent.""" current = self.spell_slots_left.get(level, 0) if current <= 0: return False self.spell_slots_left[level] = current - 1 return True @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 high_ground_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", "cast_spell", "maneuver", "channel_energy", "hex", "deed", "spell_combat", "inspire_courage", ] target_id: str | None = None spell_name: str | None = None maneuver: str | None = None # "trip", "disarm", "bull_rush", "sunder", # "grapple", "escape", "overrun", "dirty_trick", "steal", "reposition", "drag" hex_name: str | None = None # "evil_eye", "slumber", "cackle" deed_name: str | None = None # "up_close_and_deadly", "quick_clear" class_ability: str | None = None # "deed"|"spell_combat"|"inspire_courage"|"hex"|"channel" channel_mode: Literal["heal", "harm"] | None = None # channel energy polarity for this use @dataclass(frozen=True) class ReactionEvent: """An off-turn event a ``reaction_policy`` may react to. ``kind`` describes what just happened: ``attack_resolved`` (a swing was resolved, ``actor`` is the attacker, ``target`` the defender, ``damage`` the damage dealt), ``movement_step`` (``actor`` moved one cell), ``spell_cast`` (``actor`` finished casting at ``target``), or ``damage_taken`` (``target`` lost ``damage`` hp from a non-attack source such as a spell or channel energy). ``action`` is reserved for future use (the triggering action is not threaded through the engine today). """ kind: Literal["attack_resolved", "movement_step", "spell_cast", "damage_taken"] actor: CombatantState target: CombatantState | None = None damage: int = 0 action: Action | 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 @dataclass class _ManeuverRoll: """Roll context shared by all combat-maneuver handlers.""" roll: int attacker_cmb: int total: int target_cmd: int success: bool 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, spell_registry: dict[str, SpellSpec] | None = None, reaction_policy: ReactionPolicy | 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._spell_registry = spell_registry or {} self._reaction_policy = reaction_policy self._in_reaction = False self._transcript: list[str] = [] self._stats: dict[str, _LiveStats] = {s.combatant.id: _LiveStats() for s in states} self._current_round = 0 self._aoo_used: dict[str, int] = {} self._initiative_keys: dict[str, tuple[int, int, int]] = {} self._pending_summons: list[CombatantState] = [] @property def states(self) -> list[CombatantState]: """The live list of combatant states in initiative order.""" return self._states @property def grid(self) -> Grid: """The combat grid.""" return self._grid @property def spell_registry(self) -> dict[str, SpellSpec]: """The spell registry used for spell lookups.""" return self._spell_registry def _take_turn(self, state: CombatantState) -> bool: """Execute one combatant's full turn; return True if the battle is over.""" self._decrement_durations(state) # Check cant_act flag from conditions if self.has_condition_flag(state, "cant_act"): self._log(f"round {self._current_round} {state.combatant.id}: wait (cannot act)") return self._winner_side() is not None self._apply_fear_aura(state) state.moved_this_turn = False state.power_attack = False actions = self._policy(self, state) if state.immediate_used: actions = _without_swift_bonus(actions) state.immediate_used = False if self._has_condition_named(state, "staggered"): actions = _staggered_filter(actions) elif self.has_condition_flag(state, "cant_attack"): actions = _nauseated_filter(actions) 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 has_condition_flag(self, state: CombatantState, flag: str) -> bool: """Check if any active condition has the given behavioral flag set to True.""" for ac in state.active_conditions: if getattr(ac.condition, flag, False): return True # Also check old conditions field for backwards compatibility return any(getattr(cond, flag, False) for cond in state.conditions) def _has_condition_named(self, state: CombatantState, name: str) -> bool: """Check if the state has an active or old condition with the given name.""" return ( any(ac.condition.name == name for ac in state.active_conditions) or any(cond.name == name for cond in state.conditions) ) def _modifier_condition_holds( self, _state: CombatantState, condition_name: str, *, weapon: AttackSpec | None = None, dist_ft: int | None = None, save_descriptors: frozenset[str] = frozenset(), ) -> bool: """Evaluate whether a StatModifier's runtime condition holds. Unknown/unresolvable conditions return False (modifier excluded). Supported: "target_within_30ft_ranged" (ranged-style weapon, <= 30 ft), "axe" (weapon name contains "axe"), and save-descriptor names (fear, cold, ...) that are present in the save's descriptor set. """ if condition_name == "target_within_30ft_ranged": return ( weapon is not None and dist_ft is not None and self._is_ranged_attack(weapon) and dist_ft <= _POINT_BLANK_RANGE_FT ) if condition_name == "axe": return weapon is not None and "axe" in weapon.name.lower() if condition_name in _SAVE_DESCRIPTOR_CONDITIONS: return condition_name in save_descriptors return False def _get_speed_mult(self, state: CombatantState) -> float: """Get the minimum speed multiplier from all active conditions.""" speed_mult = 1.0 for ac in state.active_conditions: if ac.condition.speed_mult != 1.0: speed_mult = min(speed_mult, ac.condition.speed_mult) for cond in state.conditions: if cond.speed_mult != 1.0: speed_mult = min(speed_mult, cond.speed_mult) return speed_mult def _decrement_durations(self, state: CombatantState) -> None: """Decrement duration rounds for buffs and conditions; remove expired ones.""" state.active_buffs[:] = [ ActiveBuff(modifiers=b.modifiers, rounds_left=b.rounds_left - 1, source=b.source) for b in state.active_buffs if b.rounds_left > 1 ] state.active_conditions[:] = [ ActiveCondition(condition=c.condition, rounds_left=c.rounds_left - 1, source=c.source) for c in state.active_conditions if c.rounds_left > 1 ] def run(self) -> CombatResult: """Run the battle and return the final result and transcript.""" self._roll_initiative() round_no = 1 while round_no <= self._round_cap: self._current_round = round_no self._aoo_used.clear() self._expire_summons() if self._run_round(): 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 _run_round(self) -> bool: """Execute one round; return True if the battle ended.""" order = self._build_round_order() for state in order: if not state.active: continue if self._take_turn(state): return True if self._drive_pending_summons(): return True self._pending_summons.clear() return self._winner_side() is not None def _drive_pending_summons(self) -> bool: """Give summoned creatures their turn immediately; return True if battle ended.""" while self._pending_summons: summoned = self._pending_summons.pop(0) if ( summoned in self._states and summoned.active and self._take_turn(summoned) ): return True return False def _blocked(self, *exclude: CombatantState) -> frozenset[Pos]: """Union of occupied cells of all active states except those in ``exclude``.""" return frozenset( cell for s in self._states if s.active and all(s is not e for e in exclude) for cell in s.occupied_cells() ) def creature_distance(self, a: CombatantState, b: CombatantState) -> int: """Minimum grid distance between any occupied cell of ``a`` and ``b``.""" cells_b = b.occupied_cells() return min( self._grid.distance(cell_a, cell_b) for cell_a in a.occupied_cells() for cell_b in cells_b ) def _elevation(self, state: CombatantState) -> int: """Height of the creature's anchor cell (0 when no elevation layer).""" return self._grid.elevation_at(state.pos) def _cells_free(self, cells: Iterable[Pos], mover: CombatantState) -> bool: """True if every cell is in bounds, passable, and unoccupied by others.""" blocked = self._blocked(mover) return all( self._grid.passable(cell) and cell not in blocked for cell in cells ) def _footprint_at(self, anchor: Pos, state: CombatantState) -> frozenset[Pos]: """The cells ``state`` would occupy if its anchor were at ``anchor``.""" n = SIZE_CELLS.get(state.combatant.size, 1) r, c = anchor return frozenset( (r + dr, c + dc) for dr in range(n) for dc in range(n) ) def _footprint_cost( self, footprint: frozenset[Pos], cost_field: dict[Pos, int] ) -> int | None: """Minimum cost over footprint cells present in the cost field.""" costs = [cost_field[cell] for cell in footprint if cell in cost_field] return min(costs) if costs else None 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.creature_distance(state, s), 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_cells( self._grid, attacker.occupied_cells(), target.occupied_cells() ): return None dist_ft = self.creature_distance(attacker, target) * _SQUARE_FT for weapon in attacker.usable_attacks(): if weapon.kind in ("melee", "touch"): effective_reach_ft = _effective_reach_ft(attacker, weapon) if weapon.reach_ft > _SQUARE_FT: if _SQUARE_FT < dist_ft <= effective_reach_ft: return weapon elif dist_ft <= effective_reach_ft: return weapon max_increments = ( _MAX_TOUCH_RANGE_INCREMENTS if weapon.kind == "touch" else _MAX_RANGE_INCREMENTS ) if ( weapon.kind in ("ranged", "touch") and weapon.range_increment_ft is not None and dist_ft <= weapon.range_increment_ft * max_increments ): return weapon return None def _is_ranged_attack(self, weapon: AttackSpec) -> bool: """Ranged-style attack: projectile, or touch thrown/fired from a distance. Melee touch attacks (no range increment) are not ranged-style. """ return weapon.kind == "ranged" or ( weapon.kind == "touch" and weapon.range_increment_ft is not 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 not in ("ranged", "touch") 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). The opposite side is the mirror of every attacker cell across every target cell; an ally occupying any mirrored cell flanks. """ opposite = { (2 * tr - ar, 2 * tc - ac) for ar, ac in attacker.occupied_cells() for tr, tc in target.occupied_cells() } 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 not (ally.occupied_cells() & 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, weapon: AttackSpec | None = None, dist_ft: int | None = None, save_descriptors: frozenset[str] = frozenset(), ) -> list[StatModifier]: """Collect all StatModifiers for ``target`` from all sources. ``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. Modifiers whose ``condition`` is set are only included when the named runtime condition holds for ``state`` (evaluated via ``_modifier_condition_holds``, which consumes the weapon/range/save context threaded from the action being resolved). Unsupported condition names are treated as NOT holding (modifier excluded). """ mods: list[StatModifier] = [] # Old effects field (backwards compatibility) mods.extend(m for m in state.effects if m.target == target) # Old conditions field (backwards compatibility) for cond in state.conditions: mods.extend(m for m in cond.modifiers if m.target == target) # New active buffs for buff in state.active_buffs: mods.extend(m for m in buff.modifiers if m.target == target) # New active conditions for ac in state.active_conditions: mods.extend(m for m in ac.condition.modifiers if m.target == target) # Features for feat in state.combatant.features: mods.extend( m for m in feat.effects if m.target == target and (m.weapon_filter is None or m.weapon_filter == weapon_name) ) if target == "ac": if self._has_condition_named(state, "prone"): mods.append(StatModifier(target="ac", value=-4)) if self._has_condition_named(state, "paralyzed"): mods.append(StatModifier(target="ac", value=-4)) return [ m for m in mods if m.condition is None or self._modifier_condition_holds( state, m.condition, weapon=weapon, dist_ft=dist_ft, save_descriptors=save_descriptors, ) ] 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 _is_paralyzing_touch(self, attacker: CombatantState, weapon: AttackSpec) -> bool: """True if a hit with this weapon should apply the Lich's paralyzing touch. Robust check: the attacker has the "Paralyzing Touch" feature and the weapon is a touch attack (or is literally named "Paralyzing Touch"). """ if not self._has_feat(attacker, "Paralyzing Touch"): return False return weapon.kind == "touch" or weapon.name == "Paralyzing Touch" _FEAR_AURA_DC = 22 _FEAR_AURA_RANGE_FT = 30 _FEAR_AURA_ROUNDS = 1 def _apply_fear_aura(self, state: CombatantState) -> None: """Apply the Lich's Fear Aura: enemies within 30 ft save Will (DC 22) or become shaken. Triggered at the start of the aura-bearer's turn (simplification of the PF1e "on range entry" trigger — the lich acts first each round, so this covers entry into range). Only enemies within range who are not already shaken are tested, so the effect does not re-roll on the same enemy each turn. Shaken lasts a fixed 1 round. """ if not self._has_feat(state, "Fear Aura"): return for enemy in self._states: if ( not enemy.active or enemy.side == state.side or self._has_condition_named(enemy, "shaken") ): continue dist_ft = self.creature_distance(state, enemy) * _SQUARE_FT if dist_ft > self._FEAR_AURA_RANGE_FT: continue save = self.resolve_save( enemy, "will", self._FEAR_AURA_DC, save_descriptors=frozenset({"fear"}) ) if save.success: self._log( f"round {self._current_round} {state.combatant.id}: fear aura on " f"{enemy.combatant.id} -> SAVED" ) continue enemy.active_conditions.append( ActiveCondition( condition=CONDITIONS_BY_NAME["shaken"], rounds_left=self._FEAR_AURA_ROUNDS, source=f"monster ability: fear aura by {state.combatant.id}", ) ) self._log( f"round {self._current_round} {state.combatant.id}: fear aura on " f"{enemy.combatant.id} -> shaken ({self._FEAR_AURA_ROUNDS}r)" ) def _aoo_allowance(self, state: CombatantState) -> int: """Number of AoOs allowed per round for a combatant. Base is 1. Combat Reflexes adds the DEX modifier on top (clamped to >= 1 so a non-positive DEX mod never reduces the normal single AoO). """ if not self._has_feat(state, "Combat Reflexes"): return 1 return max(1, 1 + ((state.combatant.abilities.dex_score - 10) // 2)) 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.creature_distance(attacker, target) * _SQUARE_FT penalty = self.range_penalty(weapon, dist_ft) flank = self._flanking_bonus(attacker, target) if weapon.kind == "melee" else 0 high_ground = ( _HIGH_GROUND_BONUS if weapon.kind == "melee" and self._elevation(attacker) > self._elevation(target) 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, weapon=weapon, dist_ft=dist_ft ) if self._is_ranged_attack(weapon) and self._has_condition_named(attacker, "prone"): atk_mods.append(StatModifier(target="attack", value=-4)) 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 + high_ground + attack_mod - pa_amt # Check if target denies DEX to AC (flat-footed) denies_dex = self.has_condition_flag(target, "denies_dex_to_ac") full_ac_base = ( target.combatant.ac.flat_footed if denies_dex else target.combatant.ac.total ) # Touch resolves vs touch AC; firearms beyond their first increment use full AC. if weapon.kind == "touch": if ( weapon.firearm and weapon.range_increment_ft is not None and dist_ft > weapon.range_increment_ft ): base_ac = full_ac_base elif denies_dex: base_ac = ( target.combatant.ac.touch - max(0, target.combatant.abilities.dex_mod) ) else: base_ac = target.combatant.ac.touch else: base_ac = full_ac_base ac = base_ac + resolve_modifiers(self._stat_modifiers(target, "ac")) occupied = frozenset( cell for s in self._states if s.active and s is not attacker and s is not target for cell in s.occupied_cells() ) if has_cover_cells( self._grid, attacker.occupied_cells(), target.occupied_cells(), ranged=self._is_ranged_attack(weapon), 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 + high_ground + attack_mod - pa_amt ) crit = confirm != _NATURAL_ONE and confirm_total >= ac dmg_mods = self._stat_modifiers( attacker, "damage", weapon_name=weapon.name, weapon=weapon, dist_ft=dist_ft ) 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, high_ground_bonus=high_ground, 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 _apply_dr_for_types( self, damage: int, damage_types: frozenset[str], target: CombatantState ) -> int: dr: DamageReduction | None = target.combatant.dr if dr is None: return damage if damage_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, *, save_descriptors: frozenset[str] = frozenset(), ) -> 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. ``save_descriptors`` (e.g. {"fear"}, {"poison"}) gates conditional modifiers like Bitter or Born Damned. """ roll = self._rng.d20() base = getattr(state.combatant.saves, save_type) bonus = resolve_modifiers( self._stat_modifiers(state, save_type, save_descriptors=save_descriptors) ) 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 resolve_skill( self, state: CombatantState, skill: str, dc: int ) -> SaveResult: """Roll a skill check against ``dc``.""" roll = self._rng.d20() bonus = self._skill_static_bonus(state, skill) total = roll + bonus # Natural 1/20 are not auto-fail/success for skills success = total >= dc return SaveResult(success=success, roll=roll, total=total, dc=dc) def _skill_static_bonus(self, state: CombatantState, skill: str) -> int: """Return a skill's static bonus (no d20). Shared by resolve_skill.""" skill_spec = PREDEFINED_SKILLS.get(skill) if skill_spec is None: msg = f"Unknown skill: {skill}" raise ValueError(msg) skill_rank = state.combatant.skills.get(skill, None) rank = skill_rank.rank if skill_rank else 0 # Class skill bonus class_bonus = 3 if skill_rank and skill_rank.class_skill else 0 # Armor check penalty armor_penalty = skill_rank.armor_penalty if skill_rank else 0 if armor_penalty and skill_spec.armor_check_penalty: armor_penalty = -abs(armor_penalty) else: armor_penalty = 0 # Ability modifier ability_name = skill_spec.ability ability_score = getattr(state.combatant.abilities, f"{ability_name}_score") ability_mod = (ability_score - 10) // 2 # Effect/condition modifiers mod_bonus = resolve_modifiers(self._stat_modifiers(state, skill)) return rank + class_bonus + ability_mod + armor_penalty + mod_bonus 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) self._initiative_keys = { s.combatant.id: ( rolls[s.combatant.id][1], s.combatant.initiative_mod, self._states.index(s), ) for s in self._states } order = sorted( self._states, key=lambda s: self._initiative_keys[s.combatant.id], 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 _build_round_order(self) -> list[CombatantState]: """Build initiative order for this round from current ``_states``. Summoned creatures (not present at the start of combat) inherit their caster's initiative key so they act immediately after the caster via stable sort. """ def sort_key(s: CombatantState) -> tuple[int, int, int]: key = self._initiative_keys.get(s.combatant.id) if key is not None: return key caster_id = s.summon_caster_id if caster_id and caster_id in self._initiative_keys: ck = self._initiative_keys[caster_id] return (ck[0], ck[1], ck[2]) return (0, 0, -999) return sorted(self._states, key=sort_key, reverse=True) def _expire_summons(self) -> None: """Decrement summon durations and remove expired summoned creatures.""" expired: list[CombatantState] = [] for state in list(self._states): if state.summon_duration > 0: state.summon_duration -= 1 if state.summon_duration <= 0: expired.append(state) for state in expired: self._log( f"round {self._current_round} {state.combatant.id}: summon expired" ) self._states.remove(state) if state in self._pending_summons: self._pending_summons.remove(state) 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. Non-reach weapons (reach_ft == 5) threaten the disk of effective reach; reach weapons (reach_ft > 5) threaten only the ring at exactly their reach (PF1e reach dead zone). The union over usable melee weapons is returned. """ if not state.active: return frozenset() melee = [w for w in state.usable_attacks() if w.kind == "melee"] if not melee: return frozenset() threatened: set[Pos] = set() for weapon in melee: threatened |= self._weapon_threatened_squares(state, weapon) return frozenset(threatened) def _weapon_threatened_squares(self, state: CombatantState, weapon: AttackSpec) -> set[Pos]: """Squares one melee weapon threatens: disk for non-reach, ring for reach. The union over every occupied cell of the creature's footprint; the creature's own cells are never threatened. """ threatened: set[Pos] = set() if weapon.reach_ft > _SQUARE_FT: ring = weapon.reach_ft // _SQUARE_FT for r, c in state.occupied_cells(): threatened |= self._ring_cells((r, c), ring) else: reach_cells = _effective_reach_ft(state, weapon) // _SQUARE_FT for r, c in state.occupied_cells(): threatened |= self._disk_cells((r, c), reach_cells) threatened -= state.occupied_cells() return threatened def _ring_cells(self, center: Pos, ring: int) -> set[Pos]: """Cells at exactly ``ring`` squares from ``center`` (5-10-5 distance).""" r, c = center return { (r + dr, c + dc) for dr in range(-ring, ring + 1) for dc in range(-ring, ring + 1) if self._grid.distance(center, (r + dr, c + dc)) == ring } def _disk_cells(self, center: Pos, radius: int) -> set[Pos]: """Cells within ``radius`` squares of ``center`` (5-10-5 distance).""" r, c = center return { (r + dr, c + dc) for dr in range(-radius, radius + 1) for dc in range(-radius, radius + 1) if (dr, dc) != (0, 0) and self._grid.distance(center, (r + dr, c + dc)) <= radius } 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. """ if self.has_condition_flag(attacker, "cant_aoo"): return False self._aoo_used[attacker.combatant.id] = self._aoo_used.get(attacker.combatant.id, 0) + 1 weapon = next((w for w in attacker.usable_attacks() if w.kind == "melee"), None) if weapon is None: return False return self._resolve_swing( attacker, target, weapon, label="AoO" ) def _check_move_aoo(self, mover: CombatantState) -> bool: """Resolve AoOs provoked by leaving any threatened cell of the mover's footprint. Each enemy threatening a cell the mover occupies strikes once (capped by the per-round AoO allowance). Returns True if the mover dropped. """ for cell in mover.occupied_cells(): for enemy in self._enemies_threatening(mover, cell): if self._aoo_used.get(enemy.combatant.id, 0) >= self._aoo_allowance(enemy): continue self._resolve_aoo(enemy, mover) if not mover.active: return True return False def _check_provoked_aoo(self, attacker: CombatantState, weapon: AttackSpec) -> bool: """Check for ranged-AoO: firing a ranged-style weapon in a threatened square provokes. Ranged-style means projectile weapons and touch thrown/fired weapons (touch with a range increment); melee touch (no increment) does not provoke. Returns True if the attacker dropped (caller must stop attacking). """ if not self._is_ranged_attack(weapon): return False for cell in attacker.occupied_cells(): for enemy in self._enemies_threatening(attacker, cell): if self._aoo_used.get(enemy.combatant.id, 0) >= self._aoo_allowance(enemy): continue self._resolve_aoo(enemy, attacker) if not attacker.active: return True return False def _check_casting_aoo(self, caster: CombatantState, spell_level: int) -> bool: """Check for AoO from casting a spell in a threatened square. Returns True if the caster dropped or failed concentration (spell fizzles). """ for cell in caster.occupied_cells(): for enemy in self._enemies_threatening(caster, cell): if self._aoo_used.get(enemy.combatant.id, 0) >= self._aoo_allowance(enemy): continue hp_before = caster.hp self._resolve_aoo(enemy, caster) if not caster.active: self._log( f"round {self._current_round} {caster.combatant.id}: " f"casting interrupted by AoO from {enemy.combatant.id}" ) return True # Concentration check if damaged dmg = hp_before - caster.hp if dmg > 0 and not self._check_concentration(caster, spell_level, dmg): dc = self._concentration_dc(caster, spell_level, dmg) self._log( f"round {self._current_round} {caster.combatant.id}: " f"concentration check failed (DC {dc}), spell fizzles" ) return True return False def _concentration_dc( self, caster: CombatantState, spell_level: int, damage_taken: int ) -> int: """Concentration DC for the caster's current situation. - injured while casting: => 10 + damage_taken + spell_level - grappled or pinned: => 10 + grappler's CMB + spell_level - casting defensively: => 15 + 2 * spell_level """ if caster.grappled_with is not None: grappler = next( (s for s in self._states if s.combatant.id == caster.grappled_with), None ) cmb = self._maneuver_cmb(grappler) if grappler is not None else 0 return 10 + cmb + spell_level if damage_taken > 0: return 10 + damage_taken + spell_level return 15 + 2 * spell_level def _check_concentration( self, caster: CombatantState, spell_level: int, damage_taken: int ) -> bool: """Concentration check: DC depends on situation. Roll = d20 + caster_level + casting ability mod (+4 Combat Casting). """ dc = self._concentration_dc(caster, spell_level, damage_taken) has_combat_casting = any(f.name == "Combat Casting" for f in caster.combatant.features) bonus = 4 if has_combat_casting else 0 roll = self._rng.d20() # Use caster level + ability mod for concentration check caster_level = caster.combatant.level # For concentration, the check is d20 + caster_level + ability_mod (usually Con) con_mod = (caster.combatant.abilities.con_score - 10) // 2 total = roll + caster_level + con_mod + bonus return total >= dc 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_cells( self._grid, state.occupied_cells(), target.occupied_cells() ): return None speed_cells = state.combatant.speed_land_ft // _SQUARE_FT max_cells = 2 * speed_cells blocked = self._blocked(state, target) for weapon in state.usable_attacks(): if weapon.kind != "melee": continue reach_cells = _effective_reach_ft(state, weapon) // _SQUARE_FT min_reach_cells = weapon.reach_ft // _SQUARE_FT if weapon.reach_ft > _SQUARE_FT else 0 end = self._charge_end( state, target, reach_cells, max_cells, blocked, min_reach_cells=min_reach_cells ) 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], *, min_reach_cells: int = 0, ) -> Pos | None: """Find the closest anchor from which ``state`` can melee ``target`` via a charge. Scans anchors 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, footprint fits at the end, footprint-to-footprint distance within the weapon's reach band). Ties break toward lower distance from state. """ target_cells = target.occupied_cells() candidates: list[tuple[int, Pos]] = [] for end in self._charge_candidates(target, reach_cells, blocked, state.pos): footprint = self._footprint_at(end, state) if not self._cells_free(footprint, state): continue dist = min( self._grid.distance(cell_a, cell_b) for cell_a in footprint for cell_b in target_cells ) if dist > reach_cells or dist < min_reach_cells: continue 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, state): 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]: """Anchors within reach of some target cell, passable, unoccupied, not the start.""" result: set[Pos] = set() for tr, tc in target.occupied_cells(): 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 result.add(end) return sorted(result) def _charge_path_clear( self, path: list[Pos], state: CombatantState ) -> bool: """True if the footprint fits at every anchor on the charge path.""" for pos in path: if not self._cells_free(self._footprint_at(pos, state), state): 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 = _effective_reach_ft(attacker, weapon) // _SQUARE_FT min_reach_cells = weapon.reach_ft // _SQUARE_FT if weapon.reach_ft > _SQUARE_FT else 0 speed_cells = attacker.combatant.speed_land_ft // _SQUARE_FT max_cells = 2 * speed_cells blocked = self._blocked(attacker, target) end = self._charge_end( attacker, target, reach_cells, max_cells, blocked, min_reach_cells=min_reach_cells ) if end is None: return path = _bresenham_line(attacker.pos, end) start = attacker.pos actual_path: list[Pos] = [] for step in path: if self._check_move_aoo(attacker): break if not attacker.active: break attacker.pos = step actual_path.append(step) self._check_reactions( ReactionEvent(kind="movement_step", actor=attacker) ) if not attacker.active: break 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}" ) attacker.active_buffs.append( ActiveBuff( modifiers=(StatModifier(target="ac", value=-_CHARGE_AC_PENALTY),), rounds_left=1, source="charge", ) ) 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) kind = action.kind if kind == "cast_spell": self._execute_cast_spell(state, target, action.spell_name) elif kind == "maneuver": self._execute_maneuver(state, target, action.maneuver) elif kind in ("channel_energy", "hex", "deed", "spell_combat", "inspire_courage"): self._execute_class_ability(state, target, action) elif kind == "charge": self._execute_charge(state, target) elif kind == "withdraw": self._withdraw(state, target) elif kind in ("attack", "full_attack"): self._execute_attack(state, target, kind) elif kind == "immediate": self._execute_immediate(state, target, action) else: self._execute_movement(state, target, kind) def _execute_class_ability( self, state: CombatantState, target: CombatantState | None, action: Action ) -> None: """Dispatch a class-ability action by kind, reading its carrier field.""" kind = action.kind if kind == "channel_energy": self._execute_channel_energy( state, target, action.channel_mode or "heal" ) elif kind == "hex": self._execute_hex(state, target, action.hex_name) elif kind == "deed": self._execute_deed(state, target, action.deed_name) elif kind == "spell_combat": self._execute_spell_combat(state, target, action.spell_name) elif kind == "inspire_courage": self._execute_inspire_courage(state, target) def _execute_movement( self, state: CombatantState, target: CombatantState | None, kind: str ) -> None: """Execute a movement-style action (5foot_step, move, wait).""" if kind == "5foot_step": if target is None: return self._step5(state, target) elif kind == "move": if target is None: return self._move(state, target) elif kind == "wait": self._log(f"round {self._current_round} {state.combatant.id}: wait") def _execute_immediate( self, state: CombatantState, target: CombatantState | None, action: Action ) -> None: """Execute an immediate action (off-turn or in-turn). The immediate action consumes the combatant's next swift action (``immediate_used``), which suppresses swift/free bonus actions on the following turn. The effect is resolved by the action's carrier fields: a spell, a maneuver, a class ability, or a single attack on ``target``. """ state.immediate_used = True if action.spell_name is not None: self._execute_cast_spell(state, target, action.spell_name) elif action.maneuver is not None: self._execute_maneuver(state, target, action.maneuver) elif action.class_ability is not None: self._execute_class_ability(state, target, action) elif target is not None: self._execute_attack(state, target, "attack") def _check_reactions(self, event: ReactionEvent) -> None: """Offer an event to the reaction policy; execute the first immediate. One immediate per event: the first active combatant (in state order) whose policy returns an action gets to act. Reactions never cascade — while an immediate is executing, further events are ignored. """ if self._reaction_policy is None or self._in_reaction: return for state in self._states: if not state.active or state.immediate_used: continue action = self._reaction_policy(self, state, event) if action is None: continue self._in_reaction = True self._execute(state, action) self._in_reaction = False return def _execute_cast_spell( self, state: CombatantState, target: CombatantState | None, spell_name: str | None, ) -> None: if spell_name is None: return self._cast_spell(state, target, spell_name) def _execute_maneuver( self, state: CombatantState, target: CombatantState | None, maneuver: str | None ) -> None: """Execute a combat maneuver (trip, disarm, bull_rush, sunder, grapple, escape, overrun, dirty_trick, steal, reposition, drag).""" if target is None or not target.active: return if self.has_condition_flag(state, "cant_attack"): self._log(f"round {self._current_round} {state.combatant.id}: wait (cannot attack)") return if maneuver == "escape": self._maneuver_escape(state, target) return if maneuver not in ( "trip", "disarm", "bull_rush", "sunder", "grapple", "overrun", "dirty_trick", "steal", "reposition", "drag", ): return attacker_cmb = self._maneuver_cmb(state) target_cmd = self._maneuver_cmd(target) roll = self._rng.d20() total = roll + attacker_cmb m = _ManeuverRoll( roll=roll, attacker_cmb=attacker_cmb, total=total, target_cmd=target_cmd, success=roll != 1 and total >= target_cmd, ) handlers: dict[str, Callable[[], None]] = { "trip": lambda: self._maneuver_trip(state, target, m), "disarm": lambda: self._maneuver_disarm(state, target, m), "bull_rush": lambda: self._maneuver_bull_rush(state, target, m), "sunder": lambda: self._maneuver_sunder(state, target, m), "grapple": lambda: self._maneuver_grapple(state, target, m), "overrun": lambda: self._maneuver_overrun(state, target, m), "dirty_trick": lambda: self._maneuver_dirty_trick(state, target, m), "steal": lambda: self._maneuver_steal(state, target, m), "reposition": lambda: self._maneuver_reposition(state, target, m), "drag": lambda: self._maneuver_drag(state, target, m), } handlers[maneuver]() def _maneuver_cmb(self, state: CombatantState) -> int: return ( state.combatant.cmb or (state.combatant.bab + state.combatant.abilities.str_mod + self._size_mod(state)) ) def _maneuver_cmd(self, target: CombatantState) -> int: target_cmb = self._maneuver_cmb(target) return target.combatant.cmd or (10 + target_cmb + target.combatant.abilities.dex_mod) def _maneuver_trip( self, state: CombatantState, target: CombatantState, m: _ManeuverRoll ) -> None: if not m.success: self._maneuver_fail(state, target, "trip", m) return target.active_conditions.append( ActiveCondition( condition=CONDITIONS_BY_NAME["prone"], rounds_left=1, source="maneuver: trip", ) ) self._log( f"round {self._current_round} {state.combatant.id}: trip vs " f"{target.combatant.id} d20={m.roll}+{m.attacker_cmb}={m.total} " f"CMD {m.target_cmd} -> SUCCESS (prone)" ) def _maneuver_disarm( self, state: CombatantState, target: CombatantState, m: _ManeuverRoll ) -> None: if not m.success: self._maneuver_fail(state, target, "disarm", m) return target.active_conditions.append( ActiveCondition( condition=Condition( name="disarmed", modifiers=(StatModifier(target="attack", value=-4),), ), rounds_left=1, source="maneuver: disarm", ) ) self._log( f"round {self._current_round} {state.combatant.id}: disarm vs " f"{target.combatant.id} d20={m.roll}+{m.attacker_cmb}={m.total} " f"CMD {m.target_cmd} -> SUCCESS (disarmed)" ) def _maneuver_bull_rush( self, state: CombatantState, target: CombatantState, m: _ManeuverRoll ) -> None: if not m.success: self._maneuver_fail(state, target, "bull_rush", m) return pushed = self._bull_rush_push(state, target) outcome = "SUCCESS (pushed)" if pushed else "SUCCESS (no space)" self._log( f"round {self._current_round} {state.combatant.id}: bull rush vs " f"{target.combatant.id} d20={m.roll}+{m.attacker_cmb}={m.total} " f"CMD {m.target_cmd} -> {outcome}" ) def _maneuver_sunder( self, state: CombatantState, target: CombatantState, m: _ManeuverRoll ) -> None: if not m.success: self._maneuver_fail(state, target, "sunder", m) return weapon = next((w for w in target.usable_attacks() if w.kind == "melee"), None) if weapon is None: self._log( f"round {self._current_round} {state.combatant.id}: sunder vs " f"{target.combatant.id} d20={m.roll}+{m.attacker_cmb}={m.total} " f"CMD {m.target_cmd} -> SUCCESS (no weapon to damage)" ) return dmg = state.combatant.abilities.str_mod + 2 target.weapon_damage[weapon.id] = target.weapon_damage.get(weapon.id, 0) + dmg if target.weapon_damage[weapon.id] >= _WEAPON_DURABILITY: target.weapon_broken.add(weapon.id) target.active_conditions.append( ActiveCondition( condition=Condition( name="disarmed", modifiers=(StatModifier(target="attack", value=-4),), ), rounds_left=1, source="maneuver: sunder", ) ) self._log( f"round {self._current_round} {state.combatant.id}: sunder vs " f"{target.combatant.id} d20={m.roll}+{m.attacker_cmb}={m.total} " f"CMD {m.target_cmd} -> SUCCESS (weapon destroyed)" ) return self._log( f"round {self._current_round} {state.combatant.id}: sunder vs " f"{target.combatant.id} d20={m.roll}+{m.attacker_cmb}={m.total} " f"CMD {m.target_cmd} -> SUCCESS ({dmg} damage to weapon)" ) def _maneuver_grapple( self, state: CombatantState, target: CombatantState, m: _ManeuverRoll ) -> None: if not m.success: self._maneuver_fail(state, target, "grapple", m) return target.active_conditions.append( ActiveCondition( condition=CONDITIONS_BY_NAME["grappled"], rounds_left=10, source=f"maneuver: grapple by {state.combatant.id}", ) ) target.grappled_with = state.combatant.id state.grappled_with = target.combatant.id self._log( f"round {self._current_round} {state.combatant.id}: grapple vs " f"{target.combatant.id} d20={m.roll}+{m.attacker_cmb}={m.total} " f"CMD {m.target_cmd} -> SUCCESS (grappled)" ) def _maneuver_fail( self, state: CombatantState, target: CombatantState, maneuver: str, m: _ManeuverRoll, ) -> None: self._log( f"round {self._current_round} {state.combatant.id}: {maneuver} vs " f"{target.combatant.id} d20={m.roll}+{m.attacker_cmb}={m.total} " f"CMD {m.target_cmd} -> FAILED" ) def _maneuver_overrun( self, state: CombatantState, target: CombatantState, m: _ManeuverRoll ) -> None: if not m.success: self._maneuver_fail(state, target, "overrun", m) return target.active_conditions.append( ActiveCondition( condition=CONDITIONS_BY_NAME["prone"], rounds_left=1, source="maneuver: overrun", ) ) beyond = self._beyond_cell(state.pos, target.pos) if self._cells_free(self._footprint_at(beyond, state), state): state.pos = beyond outcome = "SUCCESS (prone, moved through)" else: outcome = "SUCCESS (prone)" self._log( f"round {self._current_round} {state.combatant.id}: overrun vs " f"{target.combatant.id} d20={m.roll}+{m.attacker_cmb}={m.total} " f"CMD {m.target_cmd} -> {outcome}" ) def _maneuver_dirty_trick( self, state: CombatantState, target: CombatantState, m: _ManeuverRoll ) -> None: if not m.success: self._maneuver_fail(state, target, "dirty_trick", m) return target.active_conditions.append( ActiveCondition( condition=CONDITIONS_BY_NAME["dazzled"], rounds_left=1, source="maneuver: dirty trick", ) ) self._log( f"round {self._current_round} {state.combatant.id}: dirty trick vs " f"{target.combatant.id} d20={m.roll}+{m.attacker_cmb}={m.total} " f"CMD {m.target_cmd} -> SUCCESS (dazzled)" ) def _maneuver_steal( self, state: CombatantState, target: CombatantState, m: _ManeuverRoll ) -> None: if not m.success: self._maneuver_fail(state, target, "steal", m) return weapon = next((w for w in target.usable_attacks() if w.kind == "melee"), None) if weapon is None: outcome = "SUCCESS (nothing to steal)" else: target.weapon_stolen.add(weapon.id) outcome = f"SUCCESS (stole {weapon.id})" self._log( f"round {self._current_round} {state.combatant.id}: steal vs " f"{target.combatant.id} d20={m.roll}+{m.attacker_cmb}={m.total} " f"CMD {m.target_cmd} -> {outcome}" ) def _maneuver_reposition( self, state: CombatantState, target: CombatantState, m: _ManeuverRoll ) -> None: if not m.success: self._maneuver_fail(state, target, "reposition", m) return new_pos: Pos | None = None for dr, dc in _NEIGHBOR_OFFSETS: pos = (target.pos[0] + dr, target.pos[1] + dc) if pos == state.pos: continue if not (0 <= pos[0] < self._grid.height and 0 <= pos[1] < self._grid.width): continue if self._cells_free(self._footprint_at(pos, target), target): new_pos = pos break if new_pos is not None: target.pos = new_pos outcome = f"SUCCESS (moved to {new_pos})" else: outcome = "SUCCESS (no space)" self._log( f"round {self._current_round} {state.combatant.id}: reposition vs " f"{target.combatant.id} d20={m.roll}+{m.attacker_cmb}={m.total} " f"CMD {m.target_cmd} -> {outcome}" ) def _maneuver_drag( self, state: CombatantState, target: CombatantState, m: _ManeuverRoll ) -> None: if not m.success: self._maneuver_fail(state, target, "drag", m) return ar, ac = state.pos tr, tc = target.pos adjacent = max(abs(tr - ar), abs(tc - ac)) == 1 beyond = self._beyond_cell(state.pos, target.pos) if adjacent and self._cells_free(self._footprint_at(beyond, target), target): target_old = target.pos target.pos = beyond state.pos = target_old outcome = "SUCCESS (dragged)" else: outcome = "SUCCESS (no space)" self._log( f"round {self._current_round} {state.combatant.id}: drag vs " f"{target.combatant.id} d20={m.roll}+{m.attacker_cmb}={m.total} " f"CMD {m.target_cmd} -> {outcome}" ) def _maneuver_escape( self, state: CombatantState, target: CombatantState ) -> None: escape_cmb = self._maneuver_cmb(state) escape_skill = ( self._skill_static_bonus(state, "escape_artist") if hasattr(state.combatant, "skills") and "escape_artist" in state.combatant.skills else 0 ) escape_bonus = max(escape_cmb, escape_skill) target_cmd = self._maneuver_cmd(target) roll = self._rng.d20() total = roll + escape_bonus success = roll != 1 and total >= target_cmd if success: target.active_conditions = [ ac for ac in target.active_conditions if ac.condition.name != "grappled" ] target.conditions = [c for c in target.conditions if c.name != "grappled"] state.active_conditions = [ ac for ac in state.active_conditions if ac.condition.name != "grappled" ] state.conditions = [c for c in state.conditions if c.name != "grappled"] target.grappled_with = None state.grappled_with = None else: self._log( f"round {self._current_round} {state.combatant.id}: escape vs " f"{target.combatant.id} d20={roll}+{escape_bonus}={total} " f"CMD {target_cmd} -> FAILED" ) def _execute_channel_energy( self, state: CombatantState, target: CombatantState | None, channel_mode: Literal["heal", "harm"], ) -> None: """Execute channel energy (positive energy, burst 30ft, 2d6). Heal mode heals all living creatures in the burst; harm mode damages all undead creatures in the burst — the target's side does not matter (PF1e Channel Energy: positive energy heals living creatures and damages undead creatures within 30 feet). """ if not state.spend_resource("channel_energy", 1): self._log( f"round {self._current_round} {state.combatant.id}: " f"channel energy failed (no resources)" ) return if target is None or not target.active: return # Channel energy affects all creatures within 30ft (6 cells) radius_cells = 30 // _SQUARE_FT # 6 cells affected = self._creatures_in_range(state.pos, radius_cells) if not affected: self._log( f"round {self._current_round} {state.combatant.id}: " f"channel energy - no targets in range" ) return wanted_type = "living" if channel_mode == "heal" else "undead" hit = [s for s in affected if s.combatant.creature_type == wanted_type] # 2d6 damage/healing dmg = 1 + self._rng.roll_die(11) # 2d6 for affected_state in hit: if channel_mode == "heal": self._channel_heal(state, affected_state, dmg) else: self._channel_damage(state, affected_state, dmg) self._log( f"round {self._current_round} {state.combatant.id}: " f"channel energy ({channel_mode}) {dmg} -> {len(hit)} targets" ) def _channel_heal( self, state: CombatantState, affected_state: CombatantState, dmg: int ) -> None: old_hp = affected_state.hp affected_state.hp = min(affected_state.hp + dmg, affected_state.combatant.hp_max) actual = affected_state.hp - old_hp self._stats[state.combatant.id].damage_dealt += actual self._log( f"round {self._current_round} {state.combatant.id}: " f"channel energy heals {affected_state.combatant.id} +{actual} HP" ) def _channel_damage( self, state: CombatantState, affected_state: CombatantState, dmg: int ) -> None: hp_before = affected_state.hp affected_state.hp -= dmg self._stats[state.combatant.id].damage_dealt += dmg self._stats[affected_state.combatant.id].damage_taken += dmg self._log( f"round {self._current_round} {state.combatant.id}: " f"channel energy damages {affected_state.combatant.id} {dmg} " f"({hp_before}->{affected_state.hp})" ) if affected_state.dead: self._log( f"round {self._current_round} {state.combatant.id}: " f"{affected_state.combatant.id} dead" ) self._check_reactions( ReactionEvent( kind="damage_taken", actor=state, target=affected_state, damage=dmg, ) ) def _execute_hex( self, state: CombatantState, target: CombatantState | None, hex_name: str | None ) -> None: """Execute a witch hex (evil_eye, slumber, cackle).""" if not state.spend_resource("hexes", 1): self._log( f"round {self._current_round} {state.combatant.id}: hex failed (no resources)" ) return if target is None or not target.active: return if hex_name not in ("evil_eye", "slumber", "cackle"): self._log( f"round {self._current_round} {state.combatant.id}: unknown hex {hex_name}" ) return hex_dc = 10 + state.combatant.level // 2 + (state.combatant.abilities.int_score - 10) // 2 if hex_name == "evil_eye": self._hex_evil_eye(state, target, hex_dc) elif hex_name == "slumber": self._hex_slumber(state, target, hex_dc) elif hex_name == "cackle": self._hex_cackle(state, target) def _hex_evil_eye( self, state: CombatantState, target: CombatantState, hex_dc: int ) -> None: """Evil Eye: shaken (-2 AC or saves), 1 round, Will negates.""" save_result = self.resolve_save(target, "will", hex_dc) if save_result.success: self._log( f"round {self._current_round} {state.combatant.id}: evil eye on " f"{target.combatant.id} -> SAVED" ) return target.active_conditions.append( ActiveCondition( condition=CONDITIONS_BY_NAME["shaken"], rounds_left=1, source=f"hex: evil_eye by {state.combatant.id}", ) ) self._log( f"round {self._current_round} {state.combatant.id}: evil eye on " f"{target.combatant.id} -> shaken (1r)" ) def _hex_slumber( self, state: CombatantState, target: CombatantState, hex_dc: int ) -> None: """Slumber: asleep/disabled if Will fails.""" save_result = self.resolve_save(target, "will", hex_dc) if save_result.success: self._log( f"round {self._current_round} {state.combatant.id}: slumber on " f"{target.combatant.id} -> SAVED" ) return target.active_conditions.append( ActiveCondition( condition=CONDITIONS_BY_NAME["asleep"], rounds_left=1, source=f"hex: slumber by {state.combatant.id}", ) ) self._log( f"round {self._current_round} {state.combatant.id}: slumber on " f"{target.combatant.id} -> asleep (1r)" ) def _hex_cackle(self, state: CombatantState, target: CombatantState) -> None: """Cackle: prolong one hex by 1 round (extends all hex conditions).""" extended = False for ac in list(target.active_conditions): if ac.source.startswith("hex:"): target.active_conditions.remove(ac) target.active_conditions.append( ActiveCondition( condition=ac.condition, rounds_left=ac.rounds_left + 1, source=ac.source, ) ) extended = True if extended: self._log( f"round {self._current_round} {state.combatant.id}: cackle extends " f"hex on {target.combatant.id}" ) else: self._log( f"round {self._current_round} {state.combatant.id}: cackle - no hex " f"to extend on {target.combatant.id}" ) def _execute_deed( self, state: CombatantState, target: CombatantState | None, deed_name: str | None ) -> None: """Execute a gunslinger deed (up_close_and_deadly, quick_clear).""" if deed_name not in ("up_close_and_deadly", "quick_clear"): return if deed_name == "up_close_and_deadly": # Up Close and Deadly: +1d6 damage within 30ft if target is None or not target.active: return dist_ft = self.creature_distance(state, target) * _SQUARE_FT if dist_ft > _UP_CLOSE_RANGE_FT: self._log( f"round {self._current_round} {state.combatant.id}: deed Up Close " f"and Deadly - target out of range" ) return if not state.spend_resource("grit", 1): self._log( f"round {self._current_round} {state.combatant.id}: deed failed (no grit)" ) return # Apply +1d6 damage to next attack this round state.deed_up_close_and_deadly = True self._log( f"round {self._current_round} {state.combatant.id}: " f"deed Up Close and Deadly activated (+1d6 dmg within 30ft)" ) elif deed_name == "quick_clear": # Quick Clear: swift action, clears misfire if not state.spend_resource("grit", 1): self._log( f"round {self._current_round} {state.combatant.id}: deed failed (no grit)" ) return self._log( f"round {self._current_round} {state.combatant.id}: " f"deed Quick Clear - firearm cleared" ) # In a full implementation, this would clear the misfire condition def _execute_spell_combat( self, state: CombatantState, target: CombatantState | None, spell_name: str | None ) -> None: """Execute Spell Combat (cast spell + full attack in same round).""" if target is None or not target.active or spell_name is None: return if not state.spend_resource("arcane_pool", 1): # Arcane pool not tracked yet, allow for now pass # Cast the spell self._cast_spell(state, target, spell_name) # Then make a full attack self._execute_attack(state, target, "full_attack") def _execute_inspire_courage( self, state: CombatantState, target: CombatantState | None ) -> None: """Execute Inspire Courage (morale +1 attack/damage, 30ft radius, costs bardic_rounds).""" if not state.spend_resource("bardic_rounds", 1): self._log( f"round {self._current_round} {state.combatant.id}: inspire courage " f"failed (no bardic rounds)" ) return if target is None: target = state # Apply to all allies within 30ft (6 cells) radius_cells = 30 // _SQUARE_FT affected: list[CombatantState] = [] for state_other in self._states: if not state_other.active: continue if state_other.side == state.side: dist = self.creature_distance(state, state_other) if dist <= radius_cells: affected.append(state_other) if not affected: self._log( f"round {self._current_round} {state.combatant.id}: inspire courage - " f"no allies in range" ) return # Apply +1 morale bonus to attack and damage for ally in affected: ally.active_buffs.append( ActiveBuff( modifiers=( StatModifier(target="attack", value=1, bonus_type="morale"), StatModifier(target="damage", value=1, bonus_type="morale"), ), rounds_left=1, source=f"inspire courage by {state.combatant.id}", ) ) self._log( f"round {self._current_round} {state.combatant.id}: " f"inspire courage +1 morale attack/damage to {len(affected)} allies" ) def _execute_attack( self, state: CombatantState, target: CombatantState | None, kind: str ) -> None: if self.has_condition_flag(state, "cant_attack"): self._log(f"round {self._current_round} {state.combatant.id}: wait (cannot attack)") return if target is None: 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) def _execute_charge(self, state: CombatantState, target: CombatantState | None) -> None: if target is None or not target.active: return if self.has_condition_flag(state, "cant_charge"): self._log(f"round {self._current_round} {state.combatant.id}: wait (cannot charge)") return weapon = self.can_charge(state, target) if weapon is None: return self._charge(state, target, weapon) def _cast_spell( self, caster: CombatantState, target: CombatantState | None, spell_name: str ) -> None: spell = self._spell_registry.get(spell_name) if spell is None: self._log( f"round {self._current_round} {caster.combatant.id}: " f"cast {spell_name} -> UNKNOWN SPELL" ) return if target is None or not target.active: return dist_ft = self.creature_distance(caster, target) * _SQUARE_FT max_range = spell_range_ft(spell, caster.combatant.level) if dist_ft > max_range: self._log( f"round {self._current_round} {caster.combatant.id}: " f"cast {spell_name} -> OUT OF RANGE" ) return if not has_line_of_effect_cells( self._grid, caster.occupied_cells(), target.occupied_cells() ): self._log( f"round {self._current_round} {caster.combatant.id}: " f"cast {spell_name} -> NO LINE OF EFFECT" ) return # Check for AoO from casting in threatened square (before resolving spell) if self._check_casting_aoo(caster, spell.level): return caster.spend_spell_slot(spell.level) # DC = 10 + spell level + caster's casting ability modifier ability_name = caster.combatant.casting_ability.lower() ability_score = getattr(caster.combatant.abilities, f"{ability_name}_score") ability_mod = (ability_score - 10) // 2 dc = 10 + spell.level + ability_mod save_result = ( self.resolve_save( target, spell.save, dc, save_descriptors=spell_save_descriptors(spell) ) if spell.save != "none" else None ) caster_live = self._stats[caster.combatant.id] target_live = self._stats[target.combatant.id] # Apply metamagic feats to spell effects metamagic_feats = caster.combatant.metamagic_feats metamagic_effects = self._apply_metamagic(spell, metamagic_feats) hp_before = target.hp parts = self._apply_spell_effects_from_list( metamagic_effects, target, save_result, caster_live, target_live, caster=caster, spell_name=spell.name, empower="Empower Spell" in metamagic_feats, maximize="Maximize Spell" in metamagic_feats, dc=dc, ) save_str = _save_label(save_result) self._log( f"round {self._current_round} {caster.combatant.id}: " f"cast {spell_name} vs {target.combatant.id} " f"DC{dc} {save_str} " f"-> {'; '.join(parts)} " f"({hp_before}->{target.hp})" ) if target.dead: self._log( f"round {self._current_round} {caster.combatant.id}: " f"{target.combatant.id} dead" ) self._check_reactions( ReactionEvent(kind="spell_cast", actor=caster, target=target) ) def _apply_spell_effects( self, spell: SpellSpec, target: CombatantState, save_result: SaveResult | None, caster_live: _LiveStats, target_live: _LiveStats, ) -> list[str]: """Legacy wrapper for compatibility.""" return self._apply_spell_effects_from_list( spell.effects, target, save_result, caster_live, target_live, caster=target, spell_name=spell.name ) def _apply_spell_effects_from_list( self, effects: tuple[SpellEffect, ...], target: CombatantState, save_result: SaveResult | None, caster_live: _LiveStats, target_live: _LiveStats, *, caster: CombatantState, spell_name: str, empower: bool = False, maximize: bool = False, dc: int = 15, ) -> list[str]: saved = save_result is not None and save_result.success return [ self._apply_single_spell_effect( effect, target, caster_live, target_live, caster=caster, spell_name=spell_name, empower=empower, maximize=maximize, saved=saved, dc=dc, ) for effect in effects ] def _apply_single_spell_effect( self, effect: SpellEffect, target: CombatantState, caster_live: _LiveStats, target_live: _LiveStats, caster: CombatantState, *, spell_name: str, empower: bool = False, maximize: bool = False, saved: bool, dc: int = 15, ) -> str: if isinstance(effect, AreaEffect): return self._apply_area_effect( effect, target, caster_live, target_live, caster=caster, spell_name=spell_name, saved=saved, empower=empower, maximize=maximize, dc=dc, ) if isinstance(effect, SummonEffect): return self._apply_summon_effect(effect, caster, caster_live, spell_name) if isinstance(effect, DamageEffect): return self._apply_damage_effect( effect, target, caster_live, target_live, saved=saved, empower=empower, maximize=maximize, caster=caster, ) if isinstance(effect, ConditionEffect): return self._apply_condition_effect(effect, target, saved=saved, spell_name=spell_name) if isinstance(effect, HealEffect): return self._apply_heal_effect(effect, target, empower=empower, maximize=maximize) return self._apply_buff_effect(effect, target, spell_name=spell_name) def _apply_damage_effect( self, effect: DamageEffect, target: CombatantState, caster_live: _LiveStats, target_live: _LiveStats, *, saved: bool, empower: bool, maximize: bool, caster: CombatantState, ) -> str: raw = effect.formula.roll(self._rng) if maximize: raw = effect.formula.max_value() if saved and effect.half_on_save: dmg = max(0, raw // 2) elif saved: dmg = 0 else: dmg = raw if empower: dmg = int(dmg * 1.5) target.hp -= dmg caster_live.damage_dealt += dmg target_live.damage_taken += dmg self._check_reactions( ReactionEvent( kind="damage_taken", actor=caster, target=target, damage=dmg ) ) return f"{dmg} damage" def _apply_condition_effect( self, effect: ConditionEffect, target: CombatantState, *, saved: bool, spell_name: str, ) -> str: if saved and effect.save_negates: return f"{effect.condition_name} negated" cond = CONDITIONS_BY_NAME.get(effect.condition_name) if cond is None: return "" target.active_conditions.append( ActiveCondition( condition=cond, rounds_left=effect.duration_rounds, source=f"spell: {spell_name}", ) ) return f"{effect.condition_name} ({effect.duration_rounds}r)" def _apply_heal_effect( self, effect: HealEffect, target: CombatantState, *, empower: bool, maximize: bool, ) -> str: heal = effect.formula.roll(self._rng) if maximize: heal = effect.formula.max_value() if empower: heal = int(heal * 1.5) old_hp = target.hp target.hp = min(target.hp + heal, target.combatant.hp_max) actual = target.hp - old_hp return f"+{actual} HP" def _apply_buff_effect( self, effect: BuffEffect, target: CombatantState, *, spell_name: str ) -> str: target.active_buffs.append( ActiveBuff( modifiers=effect.modifiers, rounds_left=effect.duration_rounds, source=f"spell: {spell_name}", ) ) return f"buff {len(effect.modifiers)} mods ({effect.duration_rounds}r)" def _apply_area_effect( self, effect: AreaEffect, target: CombatantState, caster_live: _LiveStats, target_live: _LiveStats, *, caster: CombatantState, spell_name: str, saved: bool, empower: bool = False, maximize: bool = False, dc: int = 15, ) -> str: """Apply an area effect to all creatures within radius. The target parameter is used as the center point for the burst. """ radius_cells = effect.radius_ft // _SQUARE_FT center = target.pos affected = self._creatures_in_range(center, radius_cells) if not affected: return "no targets in area" total_damage = 0 total_conditions = 0 total_buffs = 0 for affected_state in affected: creature_saved = self._area_save( affected_state, target, effect, saved=saved, dc=dc ) for sub_effect in effect.effects: target_live = self._stats[affected_state.combatant.id] result = self._apply_single_spell_effect( sub_effect, affected_state, caster_live, target_live, caster=caster, spell_name=spell_name, saved=creature_saved, empower=empower, maximize=maximize, ) if "damage" in result: total_damage += int(result.split()[0]) elif "damage" not in result and result: if "negated" not in result and "buff" not in result: total_conditions += 1 elif "buff" in result: total_buffs += 1 return ( f"area: {len(affected)} targets, {total_damage} total damage, " f"{total_conditions} conditions, {total_buffs} buffs" ) def _creatures_in_range( self, center: tuple[int, int], radius_cells: int ) -> list[CombatantState]: """Return active creatures within radius_cells of center.""" affected: list[CombatantState] = [] for state in self._states: if not state.active: continue dist = min( self._grid.distance(center, cell) for cell in state.occupied_cells() ) if dist <= radius_cells: affected.append(state) return affected def _area_save( self, affected_state: CombatantState, target: CombatantState, effect: AreaEffect, *, saved: bool, dc: int = 15, ) -> bool: """Save result for one creature in an area effect.""" if not effect.save_for_half: return False if affected_state is target: return saved save_result = self.resolve_save( affected_state, "ref", dc, save_descriptors=save_descriptors_for_effects(effect.effects) ) return save_result.success def _apply_metamagic( self, spell: SpellSpec, metamagic_feats: list[str] ) -> tuple[SpellEffect, ...]: """Apply metamagic feats to spell effects on the fly.""" if not metamagic_feats: return spell.effects has_extend = "Extend Spell" in metamagic_feats return tuple( self._metamagic_transform_sub(effect, has_extend=has_extend) for effect in spell.effects ) def _metamagic_transform_sub( self, effect: SpellEffect, *, has_extend: bool ) -> SpellEffect: """Apply Extend Spell to one effect (recursing into AreaEffect sub-effects).""" if isinstance(effect, (DamageEffect, HealEffect)): return effect if isinstance(effect, ConditionEffect): duration = effect.duration_rounds * 2 if has_extend else effect.duration_rounds return ConditionEffect( condition_name=effect.condition_name, duration_rounds=duration, save_negates=effect.save_negates, ) if isinstance(effect, BuffEffect): duration = effect.duration_rounds * 2 if has_extend else effect.duration_rounds return BuffEffect(modifiers=effect.modifiers, duration_rounds=duration) if isinstance(effect, AreaEffect): return AreaEffect( radius_ft=effect.radius_ft, effects=tuple( self._metamagic_transform_sub(sub, has_extend=has_extend) for sub in effect.effects ), save_for_half=effect.save_for_half, ) return effect def _apply_summon_effect( self, effect: SummonEffect, state: CombatantState, _caster_live: _LiveStats, _spell_name: str, ) -> str: """Apply a summoning effect - create a summoned creature.""" # Load the summoned creature try: summoned_combatant = load_combatant(Path(effect.creature)) except CombatantLoadError: return f"summon failed: could not load {effect.creature}" # Find a valid adjacent position for the summoned creature center = state.pos spawn_pos = None for dr in (-1, 0, 1): for dc in (-1, 0, 1): if dr == 0 and dc == 0: continue pos = (center[0] + dr, center[1] + dc) if self._cells_free(self._footprint_at(pos, state), state): spawn_pos = pos break if spawn_pos: break if not spawn_pos: return "summon failed: no space" # Create summoned creature state summoned_state = CombatantState( combatant=summoned_combatant, side=state.side, pos=spawn_pos, hp=summoned_combatant.hp_max, ) # Insert into initiative order right after caster caster_idx = next(i for i, s in enumerate(self._states) if s is state) self._states.insert(caster_idx + 1, summoned_state) # Set expiration summoned_state.summon_duration = effect.duration_rounds summoned_state.summon_caster_id = state.combatant.id self._stats[summoned_state.combatant.id] = _LiveStats() self._pending_summons.append(summoned_state) self._log( f"round {self._current_round} {state.combatant.id}: " f"summon {summoned_combatant.id} at {spawn_pos} ({effect.duration_rounds}r)" ) return f"summoned {summoned_combatant.id}" 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 "" high_ground_str = ( f"+{result.high_ground_bonus}(high)" if result.high_ground_bonus else "" ) bonus_str = ( f"{result.base_bonus}{result.penalty:+d}{flank_str}{high_ground_str}" if result.penalty else f"{result.base_bonus}{flank_str}{high_ground_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})" if self._is_paralyzing_touch(attacker, weapon): duration = 1 + self._rng.roll_die(4) if not self._has_condition_named(target, "paralyzed"): target.active_conditions.append( ActiveCondition( condition=CONDITIONS_BY_NAME["paralyzed"], rounds_left=duration, source=f"monster ability: paralyzing touch by {attacker.combatant.id}", ) ) line += f" paralyzed ({duration}r)" 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 self._check_reactions( ReactionEvent( kind="attack_resolved", actor=attacker, target=target, damage=result.damage, ) ) 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 if self.has_condition_flag(state, "cant_move"): self._log(f"round {self._current_round} {state.combatant.id}: wait (cannot move)") return path = self._move_path(state, target) 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 if self.has_condition_flag(state, "cant_move"): self._log(f"round {self._current_round} {state.combatant.id}: wait (cannot move)") return path = self._move_path(state, target) start = state.pos actual_path: list[Pos] = [] for step in path: if self._check_move_aoo(state): break if not state.active: break state.pos = step actual_path.append(step) self._check_reactions(ReactionEvent(kind="movement_step", actor=state)) if not state.active: break 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. Greedy walk of the anchor over the Dijkstra cost field from the target, requiring the whole footprint to fit at each step. """ speed_mult = self._get_speed_mult(state) speed_cells = int(state.combatant.speed_land_ft // _SQUARE_FT * speed_mult) if speed_cells <= 0: return [] blocked = self._blocked(state) to_target = self._grid.reachable(target.pos, None, blocked) if self._footprint_cost(state.occupied_cells(), to_target) is None: return [] path: list[Pos] = [] current = state.pos budget = speed_cells while budget > 0: best = self._best_move_step(state, current, to_target, budget) if best is None: break _, step_cost, nxt = best current = nxt path.append(current) budget -= step_cost return path def _best_move_step( self, state: CombatantState, current: Pos, to_target: dict[Pos, int], budget: int, ) -> tuple[int, int, Pos] | None: """Pick the adjacent anchor that best descends the cost field toward the target.""" current_cost = self._footprint_cost(self._footprint_at(current, state), to_target) if current_cost is None: return None 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) footprint = self._footprint_at(nxt, state) if not self._cells_free(footprint, state): continue nxt_cost = self._footprint_cost(footprint, to_target) 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) return best 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. """ speed_mult = self._get_speed_mult(state) max_cells = int(2 * (state.combatant.speed_land_ft // _SQUARE_FT) * speed_mult) if max_cells <= 0: return [] blocked = self._blocked(state) from_threat = self._grid.reachable(threat.pos, None, blocked) if self._footprint_cost(state.occupied_cells(), from_threat) is None: return [] path: list[Pos] = [] current = state.pos budget = max_cells while budget > 0: best = self._best_flee_step(state, current, from_threat, 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, state: CombatantState, current: Pos, from_threat: dict[Pos, int], budget: int, ) -> tuple[int, int, Pos] | None: """Pick the adjacent anchor that maximizes distance from the threat.""" current_cost = self._footprint_cost(self._footprint_at(current, state), from_threat) if current_cost is None: return None 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) footprint = self._footprint_at(nxt, state) if not self._cells_free(footprint, state): continue if not self._grid.diagonal_allowed(current, nxt): continue nxt_cost = self._footprint_cost(footprint, from_threat) 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 and self._check_move_aoo(state): break if not state.active: break state.pos = step actual_path.append(step) self._check_reactions(ReactionEvent(kind="movement_step", actor=state)) if not state.active: break 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) def _size_mod(self, state: CombatantState) -> int: """Size modifier for CMB/CMD: Medium=0, Small=-1, Large=1, etc.""" return _SIZE_MODIFIERS.get(state.combatant.size.lower(), 0) def _bull_rush_push(self, attacker: CombatantState, target: CombatantState) -> bool: """Push target 5ft away from attacker. Returns True if push succeeded.""" # Calculate direction away from attacker ar, ac = attacker.pos tr, tc = target.pos dr = tr - ar dc = tc - ac # Normalize to single step if dr != 0: dr = 1 if dr > 0 else -1 if dc != 0: dc = 1 if dc > 0 else -1 new_pos = (tr + dr, tc + dc) if not self._cells_free(self._footprint_at(new_pos, target), target): return False target.pos = new_pos return True def _beyond_cell(self, attacker_pos: Pos, target_pos: Pos) -> Pos: """The 1-cell position on the far side of the target from the attacker.""" ar, ac = attacker_pos tr, tc = target_pos dr = 1 if tr - ar > 0 else (-1 if tr - ar < 0 else 0) dc = 1 if tc - ac > 0 else (-1 if tc - ac < 0 else 0) return (tr + dr, tc + dc) def _cell_free(self, pos: Pos, mover: CombatantState) -> bool: """True if pos is in bounds and not occupied by any active state but mover.""" if not (0 <= pos[0] < self._grid.height and 0 <= pos[1] < self._grid.width): return False return not any(s.pos == pos for s in self._states if s.active and s is not mover) def _find_heal_spell(engine: CombatEngine, spells_known: list[str]) -> str | None: """Find a heal spell in known spells.""" for name in spells_known: spell = engine.spell_registry.get(name) if spell and any(isinstance(e, HealEffect) for e in spell.effects): return name return None def _find_ally_needing_heal(engine: CombatEngine, caster: CombatantState) -> CombatantState | None: """Find an ally below 50% HP.""" for ally_state in engine.states: below_half = ally_state.hp < ally_state.combatant.hp_max // 2 if ally_state.side == caster.side and ally_state.active and below_half: return ally_state return None def _find_offensive_spell( engine: CombatEngine, spells_known: list[str], caster: CombatantState, target: CombatantState ) -> str | None: """Find an offensive spell that can reach the target.""" dist_ft = engine.creature_distance(caster, target) * _SQUARE_FT for name in spells_known: spell = engine.spell_registry.get(name) if not spell: continue # Check for damage effect has_damage = any(isinstance(e, DamageEffect) for e in spell.effects) if not has_damage: continue max_range = spell_range_ft(spell, caster.combatant.level) if dist_ft <= max_range and has_line_of_effect_cells( engine.grid, caster.occupied_cells(), target.occupied_cells() ): return name return None def _find_buff_spell(engine: CombatEngine, spells_known: list[str]) -> str | None: """Find a buff spell.""" for name in spells_known: spell = engine.spell_registry.get(name) if spell and any(isinstance(e, BuffEffect) for e in spell.effects): return name return None def _has_slot(engine: CombatEngine, caster: CombatantState, spell_name: str) -> bool: """True if the caster still has a spell slot of the given spell's level.""" spell = engine.spell_registry.get(spell_name) return spell is not None and caster.spell_slots_left.get(spell.level, 0) > 0 def _choose_spell_action( engine: CombatEngine, state: CombatantState, target: CombatantState ) -> tuple[Action, ...] | None: """Select and return a spell action if appropriate, else None.""" caster = state spells_known = caster.combatant.spells if not spells_known: return None # 1. Heal spell if ally below 50% HP heal_spell = _find_heal_spell(engine, spells_known) if heal_spell and _has_slot(engine, caster, heal_spell): ally = _find_ally_needing_heal(engine, caster) if ally and ally.hp < ally.combatant.hp_max // 2: return (Action(kind="cast_spell", target_id=ally.combatant.id, spell_name=heal_spell),) # 2. Offensive spell if enemy in range offensive_spell = _find_offensive_spell(engine, spells_known, caster, target) if offensive_spell and _has_slot(engine, caster, offensive_spell): return ( Action(kind="cast_spell", target_id=target.combatant.id, spell_name=offensive_spell), ) # 3. Buff spell buff_spell = _find_buff_spell(engine, spells_known) if buff_spell and _has_slot(engine, caster, buff_spell): return (Action(kind="cast_spell", target_id=caster.combatant.id, spell_name=buff_spell),) return None _FULL_ROUND_KINDS: frozenset[str] = frozenset({"full_attack", "charge", "withdraw", "full_round"}) _STANDARD_KINDS: frozenset[str] = frozenset({ "attack", "cast_spell", "maneuver", "hex", "deed", "spell_combat", "channel_energy", }) _SWIFT_BONUS_KINDS: frozenset[str] = frozenset({"deed", "inspire_courage", "swift"}) def _without_swift_bonus(actions: tuple[Action, ...]) -> tuple[Action, ...]: """Drop swift/free bonus actions (deed, inspire_courage, swift). An immediate action consumes the combatant's next swift action; the policy-returned sequence is filtered so no swift bonus fires that turn. """ return tuple(a for a in actions if a.kind not in _SWIFT_BONUS_KINDS) def _staggered_filter(actions: tuple[Action, ...]) -> tuple[Action, ...]: """Staggered: one move OR one standard action per round, no full-round. A leading ``full_attack`` (already in range) becomes a single standard ``attack``; charge/withdraw/full_round are dropped. """ for action in actions: if action.kind == "full_attack": return (Action(kind="attack", target_id=action.target_id),) if action.kind in _FULL_ROUND_KINDS: continue return (action,) return () def _nauseated_filter(actions: tuple[Action, ...]) -> tuple[Action, ...]: """Nauseated: single standard action only.""" for action in actions: if action.kind in _STANDARD_KINDS: return (action,) return () Policy = Callable[[CombatEngine, CombatantState], tuple[Action, ...]] ReactionPolicy = Callable[ [CombatEngine, CombatantState, ReactionEvent], Action | None ] def default_policy(engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]: """Full-attack the nearest enemy; cast spells; charge, 5ft-step, or approach if out of range. Decision order: 1. Channel energy (oni) if resource available and an ally is hurt or enemies are grouped. 2. Hex (nairda) if resource available and the target is within close range. 3. Heal spell if an ally is below 50% HP and a heal spell is available. 4. Offensive spell if an enemy is in range and spell slots available. 5. Buff spell if no immediate threat and spell slots available. 6. Full-attack if a weapon is usable against the nearest enemy this turn (no move needed). 7. Charge if a straight-line charge path exists (2x speed, +2 attack, -2 AC). 8. 5-foot step then full-attack if a single step brings the target into weapon range. 9. Move toward the target then single attack (standard + move economy). 10. Wait if nothing is possible. Class-ability bonus actions (deed for tammara, inspire_courage for misty) are prepended to the normal action sequence whenever their resource is available. Precedence for replacement (standard) abilities: channel_energy and hex are evaluated first and replace the whole turn when their resource is available and their condition is met; otherwise the normal spell/melee flow runs and the swift/free abilities are combined with it. This keeps each class able to act each turn while reserving standard-action class abilities for their triggers. Never casts a spell if threatened without Concentration check (handled in _cast_spell). """ target = engine.nearest_enemy(state) if target is None: return (Action(kind="wait"),) # Must flee: withdraw from the nearest enemy if engine.has_condition_flag(state, "must_flee"): return (Action(kind="withdraw", target_id=target.combatant.id),) # Channel energy (oni): standard action that heal/harms in a 30ft burst. channel_mode = _emit_channel_energy(engine, state) if channel_mode is not None: return ( Action( kind="channel_energy", target_id=target.combatant.id, channel_mode=channel_mode, ), ) # Hex (nairda): standard action when the target is within close range. if _emit_hex(engine, state, target): return (Action(kind="hex", target_id=target.combatant.id, hex_name="evil_eye"),) normal = _normal_actions(engine, state, target) # Misty spell_combat (main) + inspire_courage (swift): replaces the normal action. spell_combat = _spell_combat_actions(engine, state, target) if spell_combat is not None: return spell_combat # Deed (tammara) and inspire_courage (misty melee fallback): swift/free bonuses. swift_bonus = _swift_bonus_actions(engine, state, target) return (*swift_bonus, *normal) if swift_bonus else normal def _normal_actions( engine: CombatEngine, state: CombatantState, target: CombatantState ) -> tuple[Action, ...]: """The spell/melee action sequence (without swift/free class-ability add-ons).""" if state.combatant.spells: spell_action = _choose_spell_action(engine, state, target) if spell_action is not None: return spell_action return _melee_action(engine, state, target) _GROUPED_ENEMY_COUNT = 2 _HEX_RANGE_FT = 25 def _emit_channel_energy( engine: CombatEngine, state: CombatantState ) -> Literal["heal", "harm"] | None: """Channel mode when oni should channel (heal living / harm undead).""" if state.resources_left.get("channel_energy", 0) <= 0: return None if _find_ally_needing_heal(engine, state) is not None: return "heal" radius_cells = 30 // _SQUARE_FT grouped_undead_enemies = [ s for s in engine.states if s.side != state.side and s.active and s.combatant.creature_type == "undead" and engine.creature_distance(state, s) <= radius_cells ] return "harm" if len(grouped_undead_enemies) >= _GROUPED_ENEMY_COUNT else None def _emit_hex( engine: CombatEngine, state: CombatantState, target: CombatantState | None ) -> bool: """True when nairda should hex: resource available and target within close range.""" if state.resources_left.get("hexes", 0) <= 0: return False if target is None or not target.active: return False return engine.creature_distance(state, target) * _SQUARE_FT <= _HEX_RANGE_FT def _spell_combat_actions( engine: CombatEngine, state: CombatantState, target: CombatantState ) -> tuple[Action, ...] | None: """Misty's spell_combat (main) + inspire_courage (swift), or None if no offensive spell.""" if state.resources_left.get("bardic_rounds", 0) <= 0: return None offensive = _find_offensive_spell(engine, state.combatant.spells, state, target) if offensive is None: return None return ( Action(kind="inspire_courage", target_id=target.combatant.id), Action(kind="spell_combat", target_id=target.combatant.id, spell_name=offensive), ) def _swift_bonus_actions( engine: CombatEngine, state: CombatantState, target: CombatantState ) -> tuple[Action, ...]: """Swift/free class-ability actions (deed, inspire_courage) to prepend to the turn.""" bonus: list[Action] = [] # Deed (tammara): swift/free action, emitted alongside the normal action only # when the target is within Up Close and Deadly range (30 ft). if ( state.resources_left.get("grit", 0) > 0 and engine.creature_distance(state, target) * _SQUARE_FT <= _UP_CLOSE_RANGE_FT ): bonus.append( Action( kind="deed", target_id=target.combatant.id, deed_name="up_close_and_deadly", ) ) # Inspire courage (misty melee fallback): combined with the normal action. if state.resources_left.get("bardic_rounds", 0) > 0: bonus.append(Action(kind="inspire_courage", target_id=target.combatant.id)) return tuple(bonus) def _melee_action( engine: CombatEngine, state: CombatantState, target: CombatantState ) -> tuple[Action, ...]: """Choose a non-spell action: full-attack, charge, step, or approach.""" if engine.weapon_for(state, target) is not None: return (Action(kind="full_attack", target_id=target.combatant.id),) if engine.can_charge(state, target) 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), )