2287 lines
90 KiB
Python
2287 lines
90 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()
|
|
|
|
@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."""
|
|
order = self._roll_initiative()
|
|
round_no = 1
|
|
while round_no <= self._round_cap:
|
|
self._current_round = round_no
|
|
self._aoo_used.clear()
|
|
for state in order:
|
|
if not state.active:
|
|
continue
|
|
if self._take_turn(state):
|
|
break
|
|
if self._winner_side() is not None:
|
|
break
|
|
round_no += 1
|
|
winner = self._winner_side()
|
|
if winner is not None:
|
|
self._log(f"battle over: {winner} win in {round_no} rounds")
|
|
else:
|
|
self._log(f"battle over: draw after {self._round_cap} rounds")
|
|
stats = {cid: CombatantStats(**asdict(live)) for cid, live in self._stats.items()}
|
|
return CombatResult(
|
|
winner=winner,
|
|
rounds=round_no if winner is not None else self._round_cap,
|
|
transcript=tuple(self._transcript),
|
|
stats=stats,
|
|
)
|
|
|
|
def nearest_enemy(self, state: CombatantState) -> CombatantState | None:
|
|
"""Closest active enemy by grid distance; ties keep list order."""
|
|
enemies = [s for s in self._states if s.side != state.side and s.active]
|
|
if not enemies:
|
|
return None
|
|
return min(
|
|
enemies,
|
|
key=lambda s: (self._grid.distance(state.pos, s.pos), self._states.index(s)),
|
|
)
|
|
|
|
def weapon_for(self, attacker: CombatantState, target: CombatantState) -> AttackSpec | None:
|
|
"""First weapon usable against the target at this range and with clear LoE."""
|
|
if not has_line_of_effect(self._grid, attacker.pos, target.pos):
|
|
return None
|
|
dist_ft = self._grid.distance(attacker.pos, target.pos) * _SQUARE_FT
|
|
for weapon in attacker.combatant.attacks:
|
|
if weapon.kind in ("melee", "touch") and dist_ft <= weapon.reach_ft:
|
|
return weapon
|
|
if (
|
|
weapon.kind == "ranged"
|
|
and weapon.range_increment_ft is not None
|
|
and dist_ft <= weapon.range_increment_ft * _MAX_RANGE_INCREMENTS
|
|
):
|
|
return weapon
|
|
return None
|
|
|
|
def range_penalty(self, weapon: AttackSpec, dist_ft: int) -> int:
|
|
"""PF1e: -2 per full range increment beyond the first, zero within it."""
|
|
if weapon.kind != "ranged" or weapon.range_increment_ft is None:
|
|
return 0
|
|
increments = dist_ft // weapon.range_increment_ft
|
|
if dist_ft % weapon.range_increment_ft != 0:
|
|
increments += 1
|
|
return -_RANGE_INCREMENT_PENALTY * max(0, increments - 1)
|
|
|
|
def _flanking_bonus(self, attacker: CombatantState, target: CombatantState) -> int:
|
|
"""+2 if an active ally threatens the target from the opposite side (CRB flanking)."""
|
|
ar, ac = attacker.pos
|
|
tr, tc = target.pos
|
|
opposite = (2 * tr - ar, 2 * tc - ac)
|
|
for ally in self._states:
|
|
if ally is attacker or ally is target:
|
|
continue
|
|
if not ally.active or ally.side != attacker.side:
|
|
continue
|
|
if ally.pos != opposite:
|
|
continue
|
|
if any(w.kind == "melee" for w in ally.combatant.attacks):
|
|
return _FLANK_BONUS
|
|
return 0
|
|
|
|
def _stat_modifiers(
|
|
self, state: CombatantState, target: str, *, weapon_name: str | None = None
|
|
) -> list[StatModifier]:
|
|
"""Collect all StatModifiers for ``target`` from 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)
|
|
order = sorted(
|
|
self._states,
|
|
key=lambda s: (
|
|
rolls[s.combatant.id][1],
|
|
s.combatant.initiative_mod,
|
|
self._states.index(s),
|
|
),
|
|
reverse=True,
|
|
)
|
|
for state in order:
|
|
roll, total = rolls[state.combatant.id]
|
|
self._log(
|
|
f"initiative: {state.combatant.id} "
|
|
f"d20={roll}+{state.combatant.initiative_mod}={total}"
|
|
)
|
|
return order
|
|
|
|
def _winner_side(self) -> str | None:
|
|
active_sides = {s.side for s in self._states if s.active}
|
|
if len(active_sides) == 1:
|
|
return active_sides.pop()
|
|
return None
|
|
|
|
def _threatened_squares(self, state: CombatantState) -> frozenset[Pos]:
|
|
"""Squares this combatant threatens with a melee weapon (8 adjacent for 5 ft reach)."""
|
|
if not state.active:
|
|
return frozenset()
|
|
if not any(w.kind == "melee" for w in state.combatant.attacks):
|
|
return frozenset()
|
|
r, c = state.pos
|
|
return frozenset((r + dr, c + dc) for dr, dc in _STEP_DELTAS)
|
|
|
|
def _enemies_threatening(self, state: CombatantState, pos: Pos) -> list[CombatantState]:
|
|
"""Active enemies whose threatened squares include pos (in self._states order)."""
|
|
return [
|
|
s
|
|
for s in self._states
|
|
if s is not state
|
|
and s.active
|
|
and s.side != state.side
|
|
and pos in self._threatened_squares(s)
|
|
]
|
|
|
|
def _resolve_aoo(self, attacker: CombatantState, target: CombatantState) -> bool:
|
|
"""Resolve one attack of opportunity (single melee attack at normal bonus).
|
|
|
|
AoOs are always taken (PF1e allows declining, but the simulator always strikes).
|
|
Returns True if the target dropped.
|
|
"""
|
|
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._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),
|
|
)
|