Files
pf1e-simulator/src/pf1e_simulator/combat.py
T

2353 lines
92 KiB
Python

"""Deterministic combat engine: attacks, crits, DR, hp states, initiative, rounds.
Phase 0 documented deviations from PF1e (conventions):
- Action economy: a normal turn grants one standard action + one move action
(or one full-round action), plus swift/free/immediate. The policy returns an
ordered sequence of `Action` per turn; the engine executes them in order,
stopping early if the actor drops or one side is wiped. `default_policy`
returns `(full_attack,)` when already in range, `(charge,)` when a
straight-line charge path exists, `(5foot_step, full_attack)` when a single
step brings the target into range, or `(move, attack)` otherwise (move at
full speed along the Dijkstra path, then single attack). Immediate actions
(off-turn, consume next swift) are deferred; flanking, full attack, charge,
withdraw, and 5-foot step are modeled; attacks of opportunity are resolved
inline (see below).
- A weapon with `count` > 1 ("2x Talons") resolves `count` independent attacks
in one attack action; remaining swings are lost once the target drops
(down or dead) mid-routine. BAB iterative attacks (full-round action
``full_attack``): at BAB +6/+11/+16, additional attacks at -5/-10/-15 from
the weapon's attack bonus; iteratives are only computed for weapons with
``count == 1`` (natural multi-attacks do not gain iteratives). Multiple
natural weapons in a single full attack are not modeled.
- Natural 1 always misses; natural 20 always hits and threatens a crit.
- A confirmed crit multiplies the total damage (dice + flat bonus) by crit_mult.
- DR applies once, after crit multiplication; any bypassing type defeats DR.
- Death when hp < min(-10, -CON); hp <= 0 cannot act.
- Initiative ties: higher initiative_mod first, then list order (no re-roll).
- Movement: a move action travels up to the combatant's speed in cells along
the true shortest path (Dijkstra cost field from the target, 5-10-5
diagonals), stopping adjacent to the nearest enemy; ties keep delta order.
- Line of effect gates all attacks: a target fully behind blocking terrain
cannot be attacked, and the policy moves to gain sight instead.
- Cover (corner rule) grants +4 AC on hit and crit-confirm rolls; melee and
ranged reuse the same corner rule. Soft cover: active creatures between
attacker and target also grant +4 AC on ranged attacks (CRB soft cover);
melee attacks ignore creatures.
- Flanking: +2 on melee attack rolls when an active ally with a melee weapon
threatens the target from the opposite border or corner. Ranged attacks do
not benefit from flanking; allies without melee weapons do not threaten.
- Attacks of opportunity: a combatant threatens the 8 adjacent squares if
active and wielding a melee weapon. Two triggers are modeled: (1) moving
out of a threatened square — movement is resolved step-by-step, and each
enemy that threatens the current square gets one AoO before the mover
leaves it; (2) making a ranged attack while in a threatened square — each
threatening enemy gets one AoO before each ranged swing. One AoO per
combatant per round (tracked in ``_aoo_used``, cleared at round start);
AoOs always strike (PF1e allows declining, the simulator does not). A
5-foot step never provokes AoOs (it is not a move action); the withdraw
action protects only the starting square (see below). A 5-foot step and
any other movement (move, charge, withdraw) are mutually exclusive within
the same turn (``moved_this_turn`` flag, cleared in ``_take_turn``).
- Charge: a full-round action that moves in a straight line (Bresenham) up to
2x speed toward the closest square from which the charger can melee the
target, then makes a single melee attack at +2. The charger takes -2 AC
until the start of their next turn (tracked as a StatModifier in
``effects``, cleared in ``_take_turn``). The straight line must be clear of difficult terrain,
obstacles, and creatures; minimum 2 cells (10 ft). ``default_policy``
chooses a charge when the enemy is out of reach but a valid charge path
exists.
- Withdraw: a full-round action that moves up to 2x speed away from the
nearest enemy. The starting square is not threatened — no AoO when leaving
it. Subsequent squares provoke AoOs normally (resolved step-by-step, as for
move). The path is a greedy ascent of the Dijkstra cost field from the
threat. ``default_policy`` does not choose withdraw (it is only available
via a custom policy).
- 5-foot step: a free action that moves one square toward the nearest enemy
without provoking AoOs. Mutually exclusive with any other movement (move,
charge, withdraw) in the same turn — tracked via ``moved_this_turn``,
cleared at the start of each turn in ``_take_turn``. ``default_policy``
uses ``(5foot_step, full_attack)`` when a single step brings the target
into weapon range.
- Ranged: cumulative -2 per full range increment beyond the first, up to 10
range increments; the penalty applies to attack and crit-confirm rolls.
Thrown weapons (5 increments max) are not distinguished.
"""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import asdict, dataclass, field
from 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,
spell_range_ft,
)
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, has_line_of_effect
_SQUARE_FT = 5 # Phase 0 maps use 5-ft squares
_DEATH_FLOOR = -10 # PF1e: dead when hp < -10 or -CON, whichever is lower
_NATURAL_ONE = 1 # PF1e: natural 1 always misses
_NATURAL_TWENTY = 20 # PF1e: natural 20 always hits and threatens
_COVER_AC_BONUS = 4 # PF1e: partial cover grants +4 AC
_RANGE_INCREMENT_PENALTY = 2 # PF1e: -2 per full range increment beyond the first
_MAX_RANGE_INCREMENTS = 10 # PF1e: projectile weapons shoot up to 10 increments
_FLANK_BONUS = 2 # PF1e: +2 melee attack when ally threatens opposite side
_ITERATIVE_PENALTY = 5 # PF1e: each BAB iterative is at -5 from the previous
_MAX_ITERATIVES = 4 # PF1e: BAB +16 gives 4 attacks (at +16, +11, +6, +1)
_CHARGE_ATTACK_BONUS = 2 # PF1e: +2 attack roll on a charge
_CHARGE_AC_PENALTY = 2 # PF1e: -2 AC until start of next turn after charging
_CHARGE_MIN_CELLS = 2 # PF1e: charge must move at least 10 ft (2 squares)
_UP_CLOSE_RANGE_FT = 30 # PF1e: Up Close and Deadly works within 30 ft
_SIZE_MODIFIERS: dict[str, int] = {
"fine": -8, "diminutive": -4, "tiny": -2, "small": -1,
"medium": 0, "large": 1, "huge": 2, "gargantuan": 4, "colossal": 8,
}
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 _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
summon_duration: int = 0
summon_caster_id: str | None = None
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
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"
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"
@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,
) -> 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._transcript: list[str] = []
self._stats: dict[str, _LiveStats] = {s.combatant.id: _LiveStats() for s in states}
self._current_round = 0
self._aoo_used: set[str] = set()
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
state.moved_this_turn = False
state.power_attack = False
actions = self._policy(self, state)
before = len(self._transcript)
for action in actions:
if not state.active:
break
self._execute(state, action)
if self._winner_side() is not None:
break
if state.active and len(self._transcript) == before:
self._log(f"round {self._current_round} {state.combatant.id}: wait")
return self._winner_side() is not None
def 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 _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 nearest_enemy(self, state: CombatantState) -> CombatantState | None:
"""Closest active enemy by grid distance; ties keep list order."""
enemies = [s for s in self._states if s.side != state.side and s.active]
if not enemies:
return None
return min(
enemies,
key=lambda s: (self._grid.distance(state.pos, s.pos), self._states.index(s)),
)
def weapon_for(self, attacker: CombatantState, target: CombatantState) -> AttackSpec | None:
"""First weapon usable against the target at this range and with clear LoE."""
if not has_line_of_effect(self._grid, attacker.pos, target.pos):
return None
dist_ft = self._grid.distance(attacker.pos, target.pos) * _SQUARE_FT
for weapon in attacker.combatant.attacks:
if weapon.kind in ("melee", "touch") and dist_ft <= weapon.reach_ft:
return weapon
if (
weapon.kind == "ranged"
and weapon.range_increment_ft is not None
and dist_ft <= weapon.range_increment_ft * _MAX_RANGE_INCREMENTS
):
return weapon
return None
def range_penalty(self, weapon: AttackSpec, dist_ft: int) -> int:
"""PF1e: -2 per full range increment beyond the first, zero within it."""
if weapon.kind != "ranged" or weapon.range_increment_ft is None:
return 0
increments = dist_ft // weapon.range_increment_ft
if dist_ft % weapon.range_increment_ft != 0:
increments += 1
return -_RANGE_INCREMENT_PENALTY * max(0, increments - 1)
def _flanking_bonus(self, attacker: CombatantState, target: CombatantState) -> int:
"""+2 if an active ally threatens the target from the opposite side (CRB flanking)."""
ar, ac = attacker.pos
tr, tc = target.pos
opposite = (2 * tr - ar, 2 * tc - ac)
for ally in self._states:
if ally is attacker or ally is target:
continue
if not ally.active or ally.side != attacker.side:
continue
if ally.pos != opposite:
continue
if any(w.kind == "melee" for w in ally.combatant.attacks):
return _FLANK_BONUS
return 0
def _stat_modifiers(
self, state: CombatantState, target: str, *, weapon_name: str | None = None
) -> list[StatModifier]:
"""Collect all StatModifiers for ``target`` from 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.
"""
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)
)
return mods
def _has_feat(self, state: CombatantState, feat_name: str) -> bool:
"""True if the combatant has a feature with the given name."""
return any(f.name == feat_name for f in state.combatant.features)
def _power_attack_amt(self, attacker: CombatantState) -> int:
"""Power Attack exchange amount: X = BAB//4 + 1 (min 1). Returns 0 if inactive."""
if not attacker.power_attack or not self._has_feat(attacker, "Power Attack"):
return 0
return max(1, attacker.combatant.bab // 4 + 1)
def resolve_attack(
self,
attacker: CombatantState,
target: CombatantState,
weapon: AttackSpec,
*,
bonus_override: int | None = None,
) -> AttackResult:
"""Roll one attack (with crit confirm and damage) and apply it."""
roll = self._rng.d20()
dist_ft = self._grid.distance(attacker.pos, target.pos) * _SQUARE_FT
penalty = self.range_penalty(weapon, dist_ft)
flank = self._flanking_bonus(attacker, target) if weapon.kind == "melee" else 0
base_bonus = bonus_override if bonus_override is not None else weapon.attack_bonus
atk_mods = self._stat_modifiers(attacker, "attack", weapon_name=weapon.name)
attack_mod = resolve_modifiers(atk_mods)
pa_amt = self._power_attack_amt(attacker) if weapon.kind == "melee" else 0
total = roll + base_bonus + penalty + flank + attack_mod - pa_amt
# Check if target denies DEX to AC (flat-footed)
if self.has_condition_flag(target, "denies_dex_to_ac"):
base_ac = target.combatant.ac.flat_footed
else:
base_ac = target.combatant.ac.total
ac = base_ac + resolve_modifiers(self._stat_modifiers(target, "ac"))
occupied = frozenset(
s.pos for s in self._states if s.active and s is not attacker and s is not target
)
if has_cover(
self._grid,
attacker.pos,
target.pos,
ranged=weapon.kind == "ranged",
occupied=occupied,
):
ac += _COVER_AC_BONUS
hit = roll == _NATURAL_TWENTY or (roll != _NATURAL_ONE and total >= ac)
crit = False
damage = 0
if hit:
if roll != _NATURAL_ONE and roll >= weapon.crit_range:
confirm = self._rng.d20()
confirm_total = confirm + base_bonus + penalty + flank + attack_mod - pa_amt
crit = confirm != _NATURAL_ONE and confirm_total >= ac
dmg_mods = self._stat_modifiers(attacker, "damage", weapon_name=weapon.name)
damage = sum(c.formula.roll(self._rng) for c in weapon.damage) + weapon.damage_bonus
damage += resolve_modifiers(dmg_mods)
damage += pa_amt * 2 if weapon.kind == "melee" else 0
if crit:
damage *= weapon.crit_mult
damage = self._apply_dr(damage, weapon, target)
target.hp -= damage
return AttackResult(
hit=hit, crit=crit, damage=damage, roll=roll, total=total, ac=ac,
penalty=penalty, flank_bonus=flank, base_bonus=base_bonus,
)
def _apply_dr(self, damage: int, weapon: AttackSpec, target: CombatantState) -> int:
dr: DamageReduction | None = target.combatant.dr
if dr is None:
return damage
types = {t for component in weapon.damage for t in component.types}
if types & dr.bypass:
return damage
return max(0, damage - dr.amount)
def _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
) -> SaveResult:
"""Roll a saving throw (fort/ref/will) against ``dc``.
PF1e: natural 1 = automatic failure, natural 20 = automatic success.
Effect modifiers and condition modifiers (StatModifier with matching
target) are applied via ``resolve_modifiers`` — same bonus-type
stacking rules as attacks.
"""
roll = self._rng.d20()
base = getattr(state.combatant.saves, save_type)
bonus = resolve_modifiers(self._stat_modifiers(state, save_type))
total = roll + base + bonus
success = roll != _NATURAL_ONE and (roll == _NATURAL_TWENTY or total >= dc)
return SaveResult(success=success, roll=roll, total=total, dc=dc)
def resolve_skill(
self, state: CombatantState, skill: str, dc: int
) -> SaveResult:
"""Roll a skill check against ``dc``.
"""
skill_spec = PREDEFINED_SKILLS.get(skill)
if skill_spec is None:
msg = f"Unknown skill: {skill}"
raise ValueError(msg)
roll = self._rng.d20()
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))
total = roll + rank + class_bonus + ability_mod + armor_penalty + mod_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 _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 (8 adjacent for 5 ft reach)."""
if not state.active:
return frozenset()
if not any(w.kind == "melee" for w in state.combatant.attacks):
return frozenset()
r, c = state.pos
return frozenset((r + dr, c + dc) for dr, dc in _STEP_DELTAS)
def _enemies_threatening(self, state: CombatantState, pos: Pos) -> list[CombatantState]:
"""Active enemies whose threatened squares include pos (in self._states order)."""
return [
s
for s in self._states
if s is not state
and s.active
and s.side != state.side
and pos in self._threatened_squares(s)
]
def _resolve_aoo(self, attacker: CombatantState, target: CombatantState) -> bool:
"""Resolve one attack of opportunity (single melee attack at normal bonus).
AoOs are always taken (PF1e allows declining, but the simulator always strikes).
Returns True if the target dropped.
"""
if self.has_condition_flag(attacker, "cant_aoo"):
return False
self._aoo_used.add(attacker.combatant.id)
weapon = next((w for w in attacker.combatant.attacks if w.kind == "melee"), None)
if weapon is None:
return False
return self._resolve_swing(
attacker, target, weapon, label="AoO"
)
def _check_provoked_aoo(self, attacker: CombatantState, weapon: AttackSpec) -> bool:
"""Check for ranged-AoO: firing a ranged weapon in a threatened square provokes.
Returns True if the attacker dropped (caller must stop attacking).
"""
if weapon.kind != "ranged":
return False
for enemy in self._enemies_threatening(attacker, attacker.pos):
if enemy.combatant.id in self._aoo_used:
continue
self._resolve_aoo(enemy, attacker)
if not attacker.active:
return True
return False
def _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 enemy in self._enemies_threatening(caster, caster.pos):
if enemy.combatant.id in self._aoo_used:
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(self._grid, state.pos, target.pos):
return None
speed_cells = state.combatant.speed_land_ft // _SQUARE_FT
max_cells = 2 * speed_cells
blocked = frozenset(
s.pos for s in self._states if s is not state and s is not target and s.active
)
for weapon in state.combatant.attacks:
if weapon.kind != "melee":
continue
reach_cells = weapon.reach_ft // _SQUARE_FT
end = self._charge_end(state, target, reach_cells, max_cells, blocked)
if end is not None:
return weapon
return None
def _charge_end(
self,
state: CombatantState,
target: CombatantState,
reach_cells: int,
max_cells: int,
blocked: frozenset[Pos],
) -> Pos | None:
"""Find the closest square from which ``state`` can melee ``target`` via a charge.
Scans squares within reach of the target, picks the one whose Bresenham
line from ``state.pos`` is the shortest valid charge path (>=2 cells,
<=max_cells, no blocking terrain or creatures, all passable).
Ties break toward lower distance from state.
"""
candidates: list[tuple[int, Pos]] = []
for end in self._charge_candidates(target, reach_cells, blocked, state.pos):
path = _bresenham_line(state.pos, end)
if len(path) < _CHARGE_MIN_CELLS or len(path) > max_cells:
continue
if not self._charge_path_clear(path, blocked):
continue
candidates.append((len(path), end))
if not candidates:
return None
candidates.sort(key=lambda x: (x[0], self._grid.distance(state.pos, x[1])))
return candidates[0][1]
def _charge_candidates(
self,
target: CombatantState,
reach_cells: int,
blocked: frozenset[Pos],
start: Pos,
) -> list[Pos]:
"""Squares within reach of target, passable, unoccupied, not the start."""
tr, tc = target.pos
result: list[Pos] = []
for dr in range(-reach_cells, reach_cells + 1):
for dc in range(-reach_cells, reach_cells + 1):
if abs(dr) > reach_cells or abs(dc) > reach_cells:
continue
if dr == 0 and dc == 0:
continue
end = (tr + dr, tc + dc)
if end == start:
continue
if not self._grid.in_bounds(end) or not self._grid.passable(end):
continue
if end in blocked:
continue
if self._grid.distance(end, target.pos) > reach_cells:
continue
result.append(end)
return result
def _charge_path_clear(self, path: list[Pos], blocked: frozenset[Pos]) -> bool:
"""True if every square on the charge path is passable and clear."""
for pos in path:
if not self._grid.in_bounds(pos) or not self._grid.passable(pos):
return False
if pos in blocked:
return False
terrain = self._grid.terrain_at(pos)
if terrain.move_cost is not None and terrain.move_cost > 1:
return False
return True
def _charge(
self, attacker: CombatantState, target: CombatantState, weapon: AttackSpec
) -> None:
"""Execute a charge: move in a straight line, then make a single melee attack at +2.
Sets a -2 AC penalty on the attacker that lasts until the start of their next turn
(cleared in ``_take_turn``). Movement during a charge provokes AoOs step-by-step
as for any move action.
"""
reach_cells = weapon.reach_ft // _SQUARE_FT
speed_cells = attacker.combatant.speed_land_ft // _SQUARE_FT
max_cells = 2 * speed_cells
blocked = frozenset(
s.pos for s in self._states if s is not attacker and s is not target and s.active
)
end = self._charge_end(attacker, target, reach_cells, max_cells, blocked)
if end is None:
return
path = _bresenham_line(attacker.pos, end)
start = attacker.pos
actual_path: list[Pos] = []
for step in path:
for enemy in self._enemies_threatening(attacker, attacker.pos):
if enemy.combatant.id in self._aoo_used:
continue
self._resolve_aoo(enemy, attacker)
if not attacker.active:
break
if not attacker.active:
break
attacker.pos = step
actual_path.append(step)
if actual_path:
attacker.moved_this_turn = True
coords = "->".join(f"({p[0]},{p[1]})" for p in (start, *actual_path))
self._log(
f"round {self._current_round} {attacker.combatant.id}: charge {coords}"
)
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)
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)
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_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)."""
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"):
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),
}
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
dmg = state.combatant.abilities.str_mod + 2
target.hp -= dmg
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_escape(
self, state: CombatantState, target: CombatantState
) -> None:
escape_cmb = self._maneuver_cmb(state)
escape_skill = (
self.resolve_skill(state, "escape_artist", 0).total
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
) -> None:
"""Execute channel energy (burst 30ft, 2d6 heal/damage, spends channel energy)."""
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
center = state.pos
affected = self._creatures_in_range(center, radius_cells)
if not affected:
self._log(
f"round {self._current_round} {state.combatant.id}: "
f"channel energy - no targets in range"
)
return
# Determine if healing or harming based on target relationship
is_harm = target.side != state.side
# 2d6 damage/healing
dmg = 1 + self._rng.roll_die(11) # 2d6
for affected_state in affected:
is_ally = affected_state.side == state.side
if is_ally and not is_harm:
self._channel_heal(state, affected_state, dmg)
elif not is_ally and is_harm:
self._channel_damage(state, affected_state, dmg)
self._log(
f"round {self._current_round} {state.combatant.id}: "
f"channel energy ({'harm' if is_harm else 'heal'}) {dmg} -> {len(affected)} 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"
)
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 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 not state.spend_resource("grit", 1):
self._log(
f"round {self._current_round} {state.combatant.id}: deed failed (no grit)"
)
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._grid.distance(state.pos, target.pos) * _SQUARE_FT
if dist_ft <= _UP_CLOSE_RANGE_FT:
# 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)"
)
else:
self._log(
f"round {self._current_round} {state.combatant.id}: deed Up Close "
f"and Deadly - target out of range"
)
elif deed_name == "quick_clear":
# Quick Clear: swift action, clears misfire
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
center = state.pos
affected: list[CombatantState] = []
for state_other in self._states:
if not state_other.active:
continue
if state_other.side == state.side:
dist = self._grid.distance(center, state_other.pos)
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._grid.distance(caster.pos, target.pos) * _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(self._grid, caster.pos, target.pos):
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) 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"
)
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,
)
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,
) -> str:
raw = effect.formula.roll(self._rng)
if maximize:
raw = effect.formula.max_value()
dmg = max(0, raw // 2) if saved and effect.half_on_save else raw
if empower:
dmg = int(dmg * 1.5)
target.hp -= dmg
caster_live.damage_dealt += dmg
target_live.damage_taken += 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 = self._grid.distance(center, state.pos)
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)
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 (0 <= pos[0] < self._grid.height and
0 <= pos[1] < self._grid.width and
not any(s.pos == pos for s in self._states if s.active)):
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 ""
bonus_str = (
f"{result.base_bonus}{result.penalty:+d}{flank_str}"
if result.penalty
else f"{result.base_bonus}{flank_str}"
)
line = (
f"round {self._current_round} {attacker.combatant.id}: {label} "
f"vs {target.combatant.id} "
f"d20={result.roll}+{bonus_str}={result.total} "
f"AC {result.ac} -> {outcome}"
)
if result.hit:
line += f" {result.damage} damage ({hp_before}->{target.hp})"
self._log(line)
if target.dead:
self._log(
f"round {self._current_round} {attacker.combatant.id}: "
f"{target.combatant.id} dead"
)
return True
if not target.active:
self._log(
f"round {self._current_round} {attacker.combatant.id}: "
f"{target.combatant.id} down"
)
return True
return False
def _attack(
self, attacker: CombatantState, target: CombatantState, weapon: AttackSpec
) -> None:
for swing in range(1, weapon.count + 1):
if not attacker.active:
return
if self._check_provoked_aoo(attacker, weapon):
return
label = weapon.name if weapon.count == 1 else f"{weapon.name} #{swing}"
if self._resolve_swing(attacker, target, weapon, label=label):
return
def _single_attack(
self, attacker: CombatantState, target: CombatantState, weapon: AttackSpec
) -> None:
if not attacker.active:
return
if self._check_provoked_aoo(attacker, weapon):
return
self._resolve_swing(attacker, target, weapon, label=weapon.name)
def _full_attack(
self, attacker: CombatantState, target: CombatantState, weapon: AttackSpec
) -> None:
if weapon.count > 1:
self._attack(attacker, target, weapon)
return
n = min(
_MAX_ITERATIVES,
1 + max(0, (attacker.combatant.bab - 1) // _ITERATIVE_PENALTY),
)
for i in range(n):
if not attacker.active:
return
if self._check_provoked_aoo(attacker, weapon):
return
label = weapon.name if n == 1 else f"{weapon.name} #{i + 1}"
bonus = weapon.attack_bonus - _ITERATIVE_PENALTY * i
if self._resolve_swing(
attacker, target, weapon, label=label, bonus_override=bonus
):
return
def _step5(self, state: CombatantState, target: CombatantState) -> None:
if state.moved_this_turn:
return
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:
for enemy in self._enemies_threatening(state, state.pos):
if enemy.combatant.id in self._aoo_used:
continue
self._resolve_aoo(enemy, state)
if not state.active:
break
if not state.active:
break
state.pos = step
actual_path.append(step)
if actual_path:
state.moved_this_turn = True
coords = "->".join(f"({p[0]},{p[1]})" for p in (start, *actual_path))
self._log(f"round {self._current_round} {state.combatant.id}: move {coords}")
def _move_path(self, state: CombatantState, target: CombatantState) -> list[Pos]:
"""Full movement path toward the target, up to the combatant's speed."""
speed_mult = self._get_speed_mult(state)
speed_cells = int(state.combatant.speed_land_ft // _SQUARE_FT * speed_mult)
if speed_cells <= 0:
return []
blocked = frozenset(s.pos for s in self._states if s is not state and s.active)
to_target = self._grid.reachable(target.pos, None, blocked)
if state.pos not in to_target:
return []
path: list[Pos] = []
current = state.pos
budget = speed_cells
while budget > 0:
current_cost = to_target[current]
best: tuple[int, int, Pos] | None = None
row, col = current
for d_row, d_col in _STEP_DELTAS:
nxt = (row + d_row, col + d_col)
if nxt in blocked:
continue
nxt_cost = to_target.get(nxt)
if nxt_cost is None or nxt_cost >= current_cost:
continue
step = self._grid.step_cost(current, nxt, 0)
if step > budget:
continue
score = step + nxt_cost
if best is None or score < best[0]:
best = (score, step, nxt)
if best is None:
break
current = best[2]
path.append(current)
budget -= best[1]
return path
def _flee_path(self, state: CombatantState, threat: CombatantState) -> list[Pos]:
"""Movement path away from ``threat``, up to double speed.
Uses the Dijkstra cost field from the threat and greedily picks the
step that maximizes distance (cost) from the threat each cell.
"""
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 = frozenset(s.pos for s in self._states if s is not state and s.active)
from_threat = self._grid.reachable(threat.pos, None, blocked)
if state.pos not in from_threat:
return []
path: list[Pos] = []
current = state.pos
budget = max_cells
while budget > 0:
best = self._best_flee_step(current, from_threat, blocked, budget)
if best is None:
break
_, step_cost, nxt = best
current = nxt
path.append(current)
budget -= step_cost
return path
def _best_flee_step(
self,
current: Pos,
from_threat: dict[Pos, int],
blocked: frozenset[Pos],
budget: int,
) -> tuple[int, int, Pos] | None:
"""Pick the adjacent square that maximizes distance from the threat."""
current_cost = from_threat[current]
best: tuple[int, int, Pos] | None = None
row, col = current
for d_row, d_col in _STEP_DELTAS:
nxt = (row + d_row, col + d_col)
if nxt in blocked or not self._grid.passable(nxt):
continue
if not self._grid.diagonal_allowed(current, nxt):
continue
nxt_cost = from_threat.get(nxt)
if nxt_cost is None or nxt_cost <= current_cost:
continue
step = self._grid.step_cost(current, nxt, 0)
if step > budget:
continue
score = -(nxt_cost * 100) + step
if best is None or score < best[0]:
best = (score, step, nxt)
return best
def _withdraw(self, state: CombatantState, threat: CombatantState | None) -> None:
"""Full-round action: move up to 2x speed away from the nearest enemy.
The starting square is not threatened — no AoO when leaving it.
Subsequent squares provoke AoOs normally (step-by-step, as for move).
"""
if threat is None:
return
path = self._flee_path(state, threat)
if not path:
return
start = state.pos
actual_path: list[Pos] = []
for i, step in enumerate(path):
if i > 0:
for enemy in self._enemies_threatening(state, state.pos):
if enemy.combatant.id in self._aoo_used:
continue
self._resolve_aoo(enemy, state)
if not state.active:
break
if not state.active:
break
state.pos = step
actual_path.append(step)
if actual_path:
state.moved_this_turn = True
coords = "->".join(f"({p[0]},{p[1]})" for p in (start, *actual_path))
self._log(
f"round {self._current_round} {state.combatant.id}: withdraw {coords}"
)
def _log(self, line: str) -> None:
self._transcript.append(line)
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)
# Check if destination is valid (in bounds, not blocked)
if not (0 <= new_pos[0] < self._grid.height and 0 <= new_pos[1] < self._grid.width):
return False
# Check if occupied
if any(s.pos == new_pos for s in self._states if s.active and s is not target):
return False
target.pos = new_pos
return True
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.grid.distance(caster.pos, target.pos) * _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(engine.grid, caster.pos, target.pos):
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
Policy = Callable[[CombatEngine, CombatantState], tuple[Action, ...]]
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. Heal spell if an ally is below 50% HP and a heal spell is available.
2. Offensive spell if an enemy is in range and spell slots available.
3. Buff spell if no immediate threat and spell slots available.
4. Full-attack if a weapon is usable against the nearest enemy this turn (no move needed).
5. Charge if a straight-line charge path exists (2x speed, +2 attack, -2 AC).
5. 5-foot step then full-attack if a single step brings the target into weapon range.
6. Move toward the target then single attack (standard + move economy).
7. Wait if nothing is possible.
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),)
# Try spellcasting if caster has spells
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)
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),
)