3780 lines
151 KiB
Python
3780 lines
151 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 modeled via an opt-in ``reaction_policy``
|
|
hook: each resolved attack, movement step, spell cast, and damage application
|
|
emits a ``ReactionEvent``; the first combatant whose policy returns an
|
|
``Action(kind="immediate")`` executes it immediately, and its next turn's
|
|
swift action is consumed. Flanking, full attack, charge, withdraw, and
|
|
5-foot step are modeled; attacks of opportunity are resolved inline (see
|
|
below).
|
|
- A weapon with `count` > 1 ("2x Talons") resolves `count` independent attacks
|
|
in one attack action; remaining swings are lost once the target drops
|
|
(down or dead) mid-routine. BAB iterative attacks (full-round action
|
|
``full_attack``): at BAB +6/+11/+16, additional attacks at -5/-10/-15 from
|
|
the weapon's attack bonus; iteratives are only computed for weapons with
|
|
``count == 1`` (natural multi-attacks do not gain iteratives). Multiple
|
|
natural weapons in a single full attack are not modeled.
|
|
- Natural 1 always misses; natural 20 always hits and threatens a crit.
|
|
- A confirmed crit multiplies the total damage (dice + flat bonus) by crit_mult.
|
|
- DR applies once, after crit multiplication; any bypassing type defeats DR.
|
|
- Death when hp < min(-10, -CON); hp <= 0 cannot act.
|
|
- Initiative ties: higher initiative_mod first, then list order (no re-roll).
|
|
- Movement: a move action travels up to the combatant's speed in cells along
|
|
the true shortest path (Dijkstra cost field from the target, 5-10-5
|
|
diagonals), stopping adjacent to the nearest enemy; ties keep delta order.
|
|
- Line of effect gates all attacks: a target fully behind blocking terrain
|
|
cannot be attacked, and the policy moves to gain sight instead.
|
|
- Cover (corner rule) grants +4 AC on hit and crit-confirm rolls; melee and
|
|
ranged reuse the same corner rule. Soft cover: active creatures between
|
|
attacker and target also grant +4 AC on ranged attacks (CRB soft cover);
|
|
melee attacks ignore creatures.
|
|
- Flanking: +2 on melee attack rolls when an active ally with a melee weapon
|
|
threatens the target from the opposite border or corner. Ranged attacks do
|
|
not benefit from flanking; allies without melee weapons do not threaten.
|
|
- Attacks of opportunity: a combatant threatens the 8 adjacent squares if
|
|
active and wielding a melee weapon. Two triggers are modeled: (1) moving
|
|
out of a threatened square — movement is resolved step-by-step, and each
|
|
enemy that threatens the current square gets one AoO before the mover
|
|
leaves it; (2) making a ranged attack while in a threatened square — each
|
|
threatening enemy gets one AoO before each ranged swing. One AoO per
|
|
combatant per round by default (tracked in ``_aoo_used``, cleared at round
|
|
start); the Combat Reflexes feat raises the allowance to ``max(1, 1 + DEX
|
|
mod)`` per round. AoOs always strike (PF1e allows declining, the simulator
|
|
does not). A 5-foot step never provokes AoOs (it is not a move action); the
|
|
withdraw action protects only the starting square (see below). A 5-foot
|
|
step and any other movement (move, charge, withdraw) are mutually exclusive
|
|
within the same turn (``moved_this_turn`` flag, cleared in ``_take_turn``).
|
|
- Charge: a full-round action that moves in a straight line (Bresenham) up to
|
|
2x speed toward the closest square from which the charger can melee the
|
|
target, then makes a single melee attack at +2. The charger takes -2 AC
|
|
until the start of their next turn (tracked as a StatModifier in
|
|
``effects``, cleared in ``_take_turn``). The straight line must be clear of difficult terrain,
|
|
obstacles, and creatures; minimum 2 cells (10 ft). ``default_policy``
|
|
chooses a charge when the enemy is out of reach but a valid charge path
|
|
exists.
|
|
- Withdraw: a full-round action that moves up to 2x speed away from the
|
|
nearest enemy. The starting square is not threatened — no AoO when leaving
|
|
it. Subsequent squares provoke AoOs normally (resolved step-by-step, as for
|
|
move). The path is a greedy ascent of the Dijkstra cost field from the
|
|
threat. ``default_policy`` does not choose withdraw (it is only available
|
|
via a custom policy).
|
|
- 5-foot step: a free action that moves one square toward the nearest enemy
|
|
without provoking AoOs. Mutually exclusive with any other movement (move,
|
|
charge, withdraw) in the same turn — tracked via ``moved_this_turn``,
|
|
cleared at the start of each turn in ``_take_turn``. ``default_policy``
|
|
uses ``(5foot_step, full_attack)`` when a single step brings the target
|
|
into weapon range.
|
|
- Ranged: cumulative -2 per full range increment beyond the first, up to 10
|
|
range increments; the penalty applies to attack and crit-confirm rolls.
|
|
Thrown weapons (5 increments max) are not distinguished.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from collections.abc import Callable, Iterable
|
|
from dataclasses import asdict, dataclass, field
|
|
from pathlib import Path
|
|
from typing import TYPE_CHECKING
|
|
|
|
from pf1e_simulator.conditions import CONDITIONS_BY_NAME, Condition
|
|
from pf1e_simulator.dice import dice_to_str
|
|
from pf1e_simulator.effects import ActiveBuff, ActiveCondition, StatModifier, resolve_modifiers
|
|
from pf1e_simulator.loaders.combatant import CombatantLoadError, load_combatant
|
|
from pf1e_simulator.models import PREDEFINED_SKILLS
|
|
from pf1e_simulator.spells import (
|
|
AreaEffect,
|
|
BuffEffect,
|
|
ConditionEffect,
|
|
DamageEffect,
|
|
HealEffect,
|
|
SpellEffect,
|
|
SummonEffect,
|
|
save_descriptors_for_effects,
|
|
spell_range_ft,
|
|
spell_save_descriptors,
|
|
)
|
|
|
|
if TYPE_CHECKING:
|
|
from typing import Literal
|
|
|
|
from pf1e_simulator.grid import Grid
|
|
from pf1e_simulator.map import Pos, TerrainType
|
|
from pf1e_simulator.models import AttackSpec, Combatant, DamageReduction
|
|
from pf1e_simulator.rng import Rng
|
|
from pf1e_simulator.spells import SpellSpec
|
|
|
|
TrickCondition = Literal["dazzled", "blinded", "deafened", "entangled", "nauseated"]
|
|
|
|
from pf1e_simulator.los import (
|
|
has_cover_cells,
|
|
has_line_of_effect_cells,
|
|
)
|
|
|
|
_SQUARE_FT = 5 # Phase 0 maps use 5-ft squares
|
|
_DEATH_FLOOR = -10 # PF1e: dead when hp < -10 or -CON, whichever is lower
|
|
_NATURAL_ONE = 1 # PF1e: natural 1 always misses
|
|
_NATURAL_TWENTY = 20 # PF1e: natural 20 always hits and threatens
|
|
_COVER_AC_BONUS = 4 # PF1e: partial cover grants +4 AC
|
|
_RANGE_INCREMENT_PENALTY = 2 # PF1e: -2 per full range increment beyond the first
|
|
_MAX_RANGE_INCREMENTS = 10 # PF1e: projectile weapons shoot up to 10 increments
|
|
_MAX_TOUCH_RANGE_INCREMENTS = 5 # PF1e: thrown/firearm touch weapons shoot up to 5 increments
|
|
_FLANK_BONUS = 2 # PF1e: +2 melee attack when ally threatens opposite side
|
|
_HIGH_GROUND_BONUS = 1 # PF1e: +1 melee attack when attacker stands higher than target
|
|
_ITERATIVE_PENALTY = 5 # PF1e: each BAB iterative is at -5 from the previous
|
|
_MAX_ITERATIVES = 4 # PF1e: BAB +16 gives 4 attacks (at +16, +11, +6, +1)
|
|
_WEAPON_DURABILITY = 10 # Accumulated sunder damage that destroys a weapon.
|
|
_CHARGE_ATTACK_BONUS = 2 # PF1e: +2 attack roll on a charge
|
|
_CHARGE_AC_PENALTY = 2 # PF1e: -2 AC until start of next turn after charging
|
|
_CHARGE_MIN_CELLS = 2 # PF1e: charge must move at least 10 ft (2 squares)
|
|
_UP_CLOSE_RANGE_FT = 30 # PF1e: Up Close and Deadly works within 30 ft
|
|
# PF1e: Point-Blank Shot grants +1/+1 within 30 ft with ranged weapons
|
|
_POINT_BLANK_RANGE_FT = 30
|
|
_SAVE_DESCRIPTOR_CONDITIONS = frozenset({
|
|
"fear", "cold", "electricity", "fey", "divination", "poison", "disease",
|
|
"mind_affecting",
|
|
})
|
|
|
|
|
|
_SIZE_MODIFIERS: dict[str, int] = {
|
|
"fine": -8, "diminutive": -4, "tiny": -2, "small": -1,
|
|
"medium": 0, "large": 1, "huge": 2, "gargantuan": 4, "colossal": 8,
|
|
}
|
|
|
|
# PF1e natural reach for tall creatures (simplification: no long/quadruped
|
|
# distinction). Unknown sizes fall back to 5 ft (the historical behavior).
|
|
_SIZE_NATURAL_REACH_FT: dict[str, int] = {
|
|
"Small": 5,
|
|
"Medium": 5,
|
|
"Large": 10,
|
|
"Huge": 15,
|
|
"Gargantuan": 20,
|
|
"Colossal": 30,
|
|
}
|
|
|
|
# Number of grid cells per side of a creature's footprint (square footprint).
|
|
# Unknown sizes fall back to 1 (the historical single-cell behavior).
|
|
SIZE_CELLS: dict[str, int] = {
|
|
"Small": 1,
|
|
"Medium": 1,
|
|
"Large": 2,
|
|
"Huge": 3,
|
|
"Gargantuan": 4,
|
|
"Colossal": 6,
|
|
}
|
|
|
|
# Row-major scan order of the 8 adjacent cells, used by reposition.
|
|
_NEIGHBOR_OFFSETS: tuple[tuple[int, int], ...] = (
|
|
(-1, -1), (-1, 0), (-1, 1), (0, -1), (0, 1), (1, -1), (1, 0), (1, 1),
|
|
)
|
|
|
|
|
|
def _save_label(result: SaveResult | None) -> str:
|
|
if result is None:
|
|
return "NO SAVE"
|
|
return "SAVED" if result.success else "FAILED"
|
|
|
|
|
|
def _spell_effect_summary(effect: SpellEffect) -> str:
|
|
"""Short serializable summary of one spell effect (never the description)."""
|
|
if isinstance(effect, DamageEffect):
|
|
parts = [dice_to_str(effect.formula), *effect.types]
|
|
if effect.half_on_save:
|
|
parts.append("half on save")
|
|
return " ".join(parts)
|
|
if isinstance(effect, ConditionEffect):
|
|
return f"{effect.condition_name} {effect.duration_rounds}r"
|
|
if isinstance(effect, HealEffect):
|
|
return f"heal {dice_to_str(effect.formula)}"
|
|
if isinstance(effect, BuffEffect):
|
|
return f"buff {effect.duration_rounds}r"
|
|
if isinstance(effect, AreaEffect):
|
|
return f"area {effect.radius_ft}ft"
|
|
return f"summon {effect.creature}"
|
|
|
|
|
|
def _spell_summary(spell: SpellSpec) -> str:
|
|
"""Short serializable summary of a spell's effects, joined with ', '."""
|
|
return ", ".join(_spell_effect_summary(e) for e in spell.effects)
|
|
|
|
|
|
_STEP_DELTAS: tuple[Pos, ...] = (
|
|
(-1, -1),
|
|
(-1, 0),
|
|
(-1, 1),
|
|
(0, -1),
|
|
(0, 1),
|
|
(1, -1),
|
|
(1, 0),
|
|
(1, 1),
|
|
)
|
|
|
|
|
|
def _effective_reach_ft(state: CombatantState, weapon: AttackSpec) -> int:
|
|
"""Effective melee reach in feet: max of natural size reach and weapon reach."""
|
|
return max(_SIZE_NATURAL_REACH_FT.get(state.combatant.size, 5), weapon.reach_ft)
|
|
|
|
|
|
def _bresenham_line(start: Pos, end: Pos) -> list[Pos]:
|
|
"""Integer Bresenham line from start to end (exclusive of start, inclusive of end)."""
|
|
r0, c0 = start
|
|
r1, c1 = end
|
|
dr = abs(r1 - r0)
|
|
dc = abs(c1 - c0)
|
|
sr = 1 if r1 > r0 else -1
|
|
sc = 1 if c1 > c0 else -1
|
|
err = dr - dc
|
|
path: list[Pos] = []
|
|
r, c = r0, c0
|
|
while (r, c) != (r1, c1):
|
|
e2 = 2 * err
|
|
if e2 > -dc:
|
|
err -= dc
|
|
r += sr
|
|
if e2 < dr:
|
|
err += dr
|
|
c += sc
|
|
path.append((r, c))
|
|
return path
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class CombatantStats:
|
|
"""Aggregated outcomes for one combatant over a battle."""
|
|
|
|
hits: int = 0
|
|
crits: int = 0
|
|
damage_dealt: int = 0
|
|
damage_taken: int = 0
|
|
|
|
|
|
@dataclass
|
|
class CombatantState:
|
|
"""Mutable runtime state of one combatant on the grid."""
|
|
|
|
combatant: Combatant
|
|
side: str
|
|
pos: Pos
|
|
hp: int
|
|
# Old fields for backwards compatibility with tests
|
|
effects: list[StatModifier] = field(default_factory=list)
|
|
conditions: list[Condition] = field(default_factory=list)
|
|
# New duration-tracked fields
|
|
active_buffs: list[ActiveBuff] = field(default_factory=list)
|
|
active_conditions: list[ActiveCondition] = field(default_factory=list)
|
|
resources_left: dict[str, int] = field(default_factory=dict)
|
|
spell_slots_left: dict[int, int] = field(default_factory=dict)
|
|
moved_this_turn: bool = False
|
|
power_attack: bool = False
|
|
grappled_with: str | None = None
|
|
deed_up_close_and_deadly: bool = False
|
|
immediate_used: bool = False
|
|
summon_duration: int = 0
|
|
summon_caster_id: str | None = None
|
|
# Runtime weapon durability (not in the frozen data model): accumulated
|
|
# sunder damage per weapon id, and the set of weapon ids rendered unusable.
|
|
weapon_damage: dict[str, int] = field(default_factory=dict)
|
|
weapon_broken: set[str] = field(default_factory=set)
|
|
# Runtime weapon theft (not in the frozen data model): weapon ids taken by
|
|
# steal maneuvers.
|
|
weapon_stolen: set[str] = field(default_factory=set)
|
|
|
|
def usable_attacks(self) -> list[AttackSpec]:
|
|
return [
|
|
w for w in self.combatant.attacks
|
|
if w.id not in self.weapon_broken and w.id not in self.weapon_stolen
|
|
]
|
|
|
|
def occupied_cells(self) -> frozenset[Pos]:
|
|
"""The set of grid cells this creature occupies (its footprint).
|
|
|
|
``pos`` is the anchor (top-left corner); the footprint is an n x n
|
|
square where n = SIZE_CELLS[size]. A 1x1 creature occupies {pos}.
|
|
"""
|
|
n = SIZE_CELLS.get(self.combatant.size, 1)
|
|
r, c = self.pos
|
|
return frozenset(
|
|
(r + dr, c + dc) for dr in range(n) for dc in range(n)
|
|
)
|
|
|
|
def __post_init__(self) -> None:
|
|
if not self.spell_slots_left:
|
|
self.spell_slots_left = dict(self.combatant.spell_slots)
|
|
|
|
def spend_resource(self, name: str, n: int = 1) -> bool:
|
|
"""Spend n points of a resource. Returns True if successful, False if insufficient."""
|
|
current = self.resources_left.get(name, 0)
|
|
if current < n:
|
|
return False
|
|
self.resources_left[name] = current - n
|
|
return True
|
|
|
|
def spend_spell_slot(self, level: int) -> bool:
|
|
"""Spend one spell slot of ``level``. Returns True if a slot was spent."""
|
|
current = self.spell_slots_left.get(level, 0)
|
|
if current <= 0:
|
|
return False
|
|
self.spell_slots_left[level] = current - 1
|
|
return True
|
|
|
|
@property
|
|
def active(self) -> bool:
|
|
return self.hp > 0
|
|
|
|
@property
|
|
def dead(self) -> bool:
|
|
return self.hp < self.death_threshold
|
|
|
|
@property
|
|
def death_threshold(self) -> int:
|
|
return min(_DEATH_FLOOR, -self.combatant.abilities.con_score)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class AttackResult:
|
|
"""Outcome of one attack roll, including the raw rolls for the log."""
|
|
|
|
hit: bool
|
|
crit: bool
|
|
damage: int
|
|
roll: int
|
|
total: int
|
|
ac: int
|
|
penalty: int = 0
|
|
flank_bonus: int = 0
|
|
high_ground_bonus: int = 0
|
|
base_bonus: int = 0
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ChargeResult:
|
|
"""Outcome of a charge feasibility check (public, for policy/LLM reading).
|
|
|
|
weapon is the melee weapon that would be used when the charge is possible
|
|
(reason None); otherwise weapon is None and reason explains the failure.
|
|
distance_ft is 0 on failure.
|
|
"""
|
|
|
|
weapon: AttackSpec | None
|
|
distance_ft: int
|
|
reason: str | None
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Observation:
|
|
"""Serializable snapshot of combat state for a policy/LLM.
|
|
|
|
Built by the engine at the point of policy invocation; the snapshot is the
|
|
ONLY source of information for an ``LLMPolicy`` — it never exposes
|
|
``engine.*`` callables nor mutable references to live state. All collections
|
|
are copied (tuples/dicts) so the snapshot is frozen and JSON-serializable.
|
|
|
|
``combatants`` holds one dict per combatant in ``self._states`` order;
|
|
``pairs`` holds one dict per (acting combatant x each other combatant) with
|
|
``target_index`` disambiguating the target. ``terrain`` and ``elevation``
|
|
are height x width layers aligned on the same dimensions (``elevation`` is
|
|
``()`` when the grid has no elevation layer). ``threatened`` is a height x
|
|
width boolean grid whose cells are True when threatened by at least one
|
|
ENEMY of the acting combatant (active, opposite side, with a melee
|
|
weapon); the union is bounded to the grid — out-of-grid threatened cells
|
|
are dropped. ``terrain_semantics`` is one dict per distinct terrain type
|
|
on the map (sorted by type for determinism), carrying
|
|
``{type, move_cost, blocks_los, cover}`` — derived from
|
|
``grid.terrain_at((r, c))`` over the entire map (the Grid does not expose
|
|
``MapSpec.legend``).
|
|
"""
|
|
|
|
round: int
|
|
acting_combatant: int
|
|
combatants: tuple[dict[str, object], ...]
|
|
pairs: tuple[dict[str, object], ...]
|
|
terrain: tuple[tuple[str | None, ...], ...]
|
|
elevation: tuple[tuple[int, ...], ...]
|
|
threatened: tuple[tuple[bool, ...], ...] = ()
|
|
terrain_semantics: tuple[dict[str, object], ...] = ()
|
|
|
|
def to_dict(self) -> dict[str, object]:
|
|
"""Return a JSON-serializable dict with the pinned top-level keys."""
|
|
return {
|
|
"round": self.round,
|
|
"acting_combatant": self.acting_combatant,
|
|
"combatants": self.combatants,
|
|
"pairs": self.pairs,
|
|
"terrain": self.terrain,
|
|
"elevation": self.elevation,
|
|
"threatened": self.threatened,
|
|
"terrain_semantics": self.terrain_semantics,
|
|
}
|
|
|
|
|
|
@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", "retrieve_weapon", "repair_weapon",
|
|
]
|
|
target_id: str | None = None
|
|
spell_name: str | None = None
|
|
maneuver: str | None = None # "trip", "disarm", "bull_rush", "sunder",
|
|
# "grapple", "escape", "overrun", "dirty_trick", "steal", "reposition", "drag"
|
|
hex_name: str | None = None # "evil_eye", "slumber", "cackle"
|
|
deed_name: str | None = None # "up_close_and_deadly", "quick_clear"
|
|
class_ability: str | None = None # "deed"|"spell_combat"|"inspire_courage"|"hex"|"channel"
|
|
channel_mode: Literal["heal", "harm"] | None = None # channel energy polarity for this use
|
|
direction: tuple[int, int] | None = None # steering for directional maneuvers
|
|
trick_condition: TrickCondition | None = None # dirty trick condition choice
|
|
weapon_id: str | None = None # weapon id for retrieve_weapon / repair_weapon
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ReactionEvent:
|
|
"""An off-turn event a ``reaction_policy`` may react to.
|
|
|
|
``kind`` describes what just happened: ``attack_resolved`` (a swing was
|
|
resolved, ``actor`` is the attacker, ``target`` the defender, ``damage``
|
|
the damage dealt), ``movement_step`` (``actor`` moved one cell),
|
|
``spell_cast`` (``actor`` finished casting at ``target``), or
|
|
``damage_taken`` (``target`` lost ``damage`` hp from a non-attack source
|
|
such as a spell or channel energy). ``action`` is reserved for future
|
|
use (the triggering action is not threaded through the engine today).
|
|
"""
|
|
|
|
kind: Literal["attack_resolved", "movement_step", "spell_cast", "damage_taken"]
|
|
actor: CombatantState
|
|
target: CombatantState | None = None
|
|
damage: int = 0
|
|
action: Action | None = None
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class CombatResult:
|
|
"""Outcome of a full battle: winner, rounds, log, and per-combatant stats."""
|
|
|
|
winner: str | None
|
|
rounds: int
|
|
transcript: tuple[str, ...]
|
|
stats: dict[str, CombatantStats]
|
|
|
|
|
|
@dataclass
|
|
class _LiveStats:
|
|
hits: int = 0
|
|
crits: int = 0
|
|
damage_dealt: int = 0
|
|
damage_taken: int = 0
|
|
|
|
|
|
@dataclass
|
|
class _ManeuverRoll:
|
|
"""Roll context shared by all combat-maneuver handlers."""
|
|
roll: int
|
|
attacker_cmb: int
|
|
total: int
|
|
target_cmd: int
|
|
success: bool
|
|
|
|
|
|
class CombatEngine:
|
|
"""Runs one battle to completion on a grid with a single RNG stream."""
|
|
|
|
def __init__(
|
|
self,
|
|
rng: Rng,
|
|
grid: Grid,
|
|
states: list[CombatantState],
|
|
*,
|
|
round_cap: int = 100,
|
|
policy: Policy | None = None,
|
|
llm_policy: LLMPolicy | None = None,
|
|
spell_registry: dict[str, SpellSpec] | None = None,
|
|
reaction_policy: ReactionPolicy | None = None,
|
|
) -> None:
|
|
if policy is not None and llm_policy is not None:
|
|
msg = "policy and llm_policy are mutually exclusive"
|
|
raise ValueError(msg)
|
|
self._rng = rng
|
|
self._grid = grid
|
|
self._states = states
|
|
self._round_cap = round_cap
|
|
if llm_policy is not None:
|
|
self._policy = self._llm_adapter(llm_policy)
|
|
else:
|
|
self._policy = policy if policy is not None else default_policy
|
|
self._spell_registry = spell_registry or {}
|
|
self._reaction_policy = reaction_policy
|
|
self._in_reaction = False
|
|
self._transcript: list[str] = []
|
|
self._stats: dict[str, _LiveStats] = {s.combatant.id: _LiveStats() for s in states}
|
|
self._current_round = 0
|
|
self._aoo_used: dict[str, int] = {}
|
|
self._initiative_keys: dict[str, tuple[int, int, int]] = {}
|
|
self._pending_summons: list[CombatantState] = []
|
|
|
|
@property
|
|
def states(self) -> list[CombatantState]:
|
|
"""The live list of combatant states in initiative order."""
|
|
return self._states
|
|
|
|
@property
|
|
def grid(self) -> Grid:
|
|
"""The combat grid."""
|
|
return self._grid
|
|
|
|
@property
|
|
def spell_registry(self) -> dict[str, SpellSpec]:
|
|
"""The spell registry used for spell lookups."""
|
|
return self._spell_registry
|
|
|
|
def _llm_adapter(self, llm_policy: LLMPolicy) -> Policy:
|
|
"""Wrap an ``LLMPolicy`` into a ``Policy``.
|
|
|
|
At the point of invocation (``actions = self._policy(self, state)`` in
|
|
``_take_turn``), the Observation is built for the current state and
|
|
delegated to ``llm_policy(observation, state)``. The adapter consumes
|
|
no RNG and mutates no state.
|
|
"""
|
|
|
|
def adapter(_engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
|
|
observation = self._build_observation(state)
|
|
return llm_policy(observation, state)
|
|
|
|
return adapter
|
|
|
|
def _build_observation(self, state: CombatantState) -> Observation:
|
|
"""Build a frozen, serializable snapshot of the current combat state.
|
|
|
|
Pure read: never mutates state nor consumes RNG. ``state`` is the
|
|
acting combatant (the one whose turn it is).
|
|
"""
|
|
acting_index = self._states.index(state)
|
|
combatants = tuple(self._combatant_snapshot(s) for s in self._states)
|
|
pairs = tuple(
|
|
self._pair_snapshot(state, target)
|
|
for target in self._states
|
|
if target is not state
|
|
)
|
|
terrain = tuple(
|
|
tuple(self._grid.terrain_at((r, c)).type for c in range(self._grid.width))
|
|
for r in range(self._grid.height)
|
|
)
|
|
has_elevation = getattr(self._grid, "_elevation", None) is not None
|
|
if has_elevation:
|
|
elevation = tuple(
|
|
tuple(
|
|
self._grid.elevation_at((r, c)) for c in range(self._grid.width)
|
|
)
|
|
for r in range(self._grid.height)
|
|
)
|
|
else:
|
|
elevation = ()
|
|
threatened = self._threat_grid(state)
|
|
terrain_semantics = self._terrain_semantics()
|
|
return Observation(
|
|
round=self._current_round,
|
|
acting_combatant=acting_index,
|
|
combatants=combatants,
|
|
pairs=pairs,
|
|
terrain=terrain,
|
|
elevation=elevation,
|
|
threatened=threatened,
|
|
terrain_semantics=terrain_semantics,
|
|
)
|
|
|
|
def _threat_grid(self, state: CombatantState) -> tuple[tuple[bool, ...], ...]:
|
|
"""Build the height x width bool grid of cells threatened by enemies.
|
|
|
|
Union of ``threatened_squares(e)`` for every enemy ``e`` of ``state``
|
|
(active, opposite side, with a usable melee weapon). The union is
|
|
bounded to the grid: cells produced by the public helper that lie
|
|
outside the map are dropped (helper output is in 5-ft-square coords
|
|
and may overflow on small maps).
|
|
"""
|
|
enemies = (
|
|
s for s in self._states
|
|
if s is not state
|
|
and s.active
|
|
and s.side != state.side
|
|
and any(w.kind == "melee" for w in s.usable_attacks())
|
|
)
|
|
threatened: set[Pos] = set()
|
|
for enemy in enemies:
|
|
threatened |= self.threatened_squares(enemy)
|
|
return tuple(
|
|
tuple((r, c) in threatened for c in range(self._grid.width))
|
|
for r in range(self._grid.height)
|
|
)
|
|
|
|
def _terrain_semantics(self) -> tuple[dict[str, object], ...]:
|
|
"""One dict per distinct ``TerrainType`` on the map (sorted by type).
|
|
|
|
Derived by deduplicating ``grid.terrain_at`` over the entire map: the
|
|
Grid stores resolved ``TerrainType`` objects in ``_cells`` but does
|
|
not expose ``MapSpec.legend`` directly, so iterating the cells is
|
|
the safest source. The Grid is guaranteed to hold at least one
|
|
cell (constructor enforces non-empty ``cells``).
|
|
"""
|
|
seen: dict[str, TerrainType] = {}
|
|
for r in range(self._grid.height):
|
|
for c in range(self._grid.width):
|
|
cell = self._grid.terrain_at((r, c))
|
|
seen.setdefault(cell.type, cell)
|
|
return tuple(
|
|
{
|
|
"type": cell.type,
|
|
"move_cost": cell.move_cost,
|
|
"blocks_los": cell.blocks_los,
|
|
"cover": cell.cover,
|
|
}
|
|
for cell in (seen[name] for name in sorted(seen))
|
|
)
|
|
|
|
def _combatant_snapshot(self, state: CombatantState) -> dict[str, object]:
|
|
"""One per-combatant dict for the Observation (frozen copies)."""
|
|
conditions = tuple(
|
|
(c.condition.name, c.rounds_left) for c in state.active_conditions
|
|
) + tuple((c.name, 0) for c in state.conditions)
|
|
resources = dict(state.resources_left)
|
|
for level, count in state.spell_slots_left.items():
|
|
resources[f"spell_slot_{level}"] = count
|
|
return {
|
|
"id": state.combatant.id,
|
|
"side": state.side,
|
|
"pos": state.pos,
|
|
"occupied_cells": tuple(sorted(state.occupied_cells())),
|
|
"hp": state.hp,
|
|
"hp_max": state.combatant.hp_max,
|
|
"active": state.active,
|
|
"dead": state.dead,
|
|
"conditions": conditions,
|
|
"resources_left": resources,
|
|
"weapon_broken": tuple(sorted(state.weapon_broken)),
|
|
"weapon_stolen": tuple(sorted(state.weapon_stolen)),
|
|
"grappled_with": state.grappled_with,
|
|
"moved_this_turn": state.moved_this_turn,
|
|
"power_attack": state.power_attack,
|
|
"name": state.combatant.name,
|
|
"level": state.combatant.level,
|
|
"size": state.combatant.size,
|
|
"speed_land_ft": state.combatant.speed_land_ft,
|
|
"speed_fly_ft": state.combatant.speed_fly_ft,
|
|
"ac": {
|
|
"total": state.combatant.ac.total,
|
|
"touch": state.combatant.ac.touch,
|
|
"flat_footed": state.combatant.ac.flat_footed,
|
|
},
|
|
"saves": {
|
|
"fort": state.combatant.saves.fort,
|
|
"ref": state.combatant.saves.ref,
|
|
"will": state.combatant.saves.will,
|
|
},
|
|
"cmb": self._maneuver_cmb(state),
|
|
"cmd": self._maneuver_cmd(state),
|
|
"attacks": tuple(
|
|
{
|
|
"id": attack.id,
|
|
"name": attack.name,
|
|
"kind": attack.kind,
|
|
"attack_bonus": attack.attack_bonus,
|
|
"damage": tuple(
|
|
{
|
|
"formula": dice_to_str(component.formula),
|
|
"types": tuple(component.types),
|
|
}
|
|
for component in attack.damage
|
|
),
|
|
"damage_bonus": attack.damage_bonus,
|
|
"crit_range": attack.crit_range,
|
|
"crit_mult": attack.crit_mult,
|
|
"reach_ft": attack.reach_ft,
|
|
"range_increment_ft": attack.range_increment_ft,
|
|
"count": attack.count,
|
|
"firearm": attack.firearm,
|
|
}
|
|
for attack in state.combatant.attacks
|
|
),
|
|
"features": tuple(sorted(f.name for f in state.combatant.features)),
|
|
"spells": tuple(
|
|
self._spell_snapshot(state, name) for name in state.combatant.spells
|
|
),
|
|
}
|
|
|
|
def _pair_snapshot(
|
|
self, state: CombatantState, target: CombatantState
|
|
) -> dict[str, object]:
|
|
"""One per-pair dict (acting combatant x target) for the Observation."""
|
|
distance_ft = self.creature_distance(state, target) * _SQUARE_FT
|
|
weapon = self.weapon_for(state, target)
|
|
weapon_name = weapon.name if weapon is not None else None
|
|
range_penalty = (
|
|
self.range_penalty(weapon, distance_ft) if weapon is not None else 0
|
|
)
|
|
charge = self.can_charge(state, target)
|
|
can_charge = charge is not None and charge.weapon is not None
|
|
occupied = state.occupied_cells()
|
|
target_threatens_me = bool(
|
|
target.active
|
|
and any(w.kind == "melee" for w in target.usable_attacks())
|
|
and self.threatened_squares(target) & occupied
|
|
)
|
|
threatening_enemies: set[str] = set()
|
|
for cell in occupied:
|
|
for enemy in self.enemies_threatening(state, cell):
|
|
threatening_enemies.add(enemy.combatant.id)
|
|
return {
|
|
"target_index": self._states.index(target),
|
|
"distance_ft": distance_ft,
|
|
"weapon_for": weapon_name,
|
|
"range_penalty": range_penalty,
|
|
"can_charge": can_charge,
|
|
"can_step5_to_attack": self.can_step5_to_attack(state, target),
|
|
"has_line_of_effect": has_line_of_effect_cells(
|
|
self._grid, state.occupied_cells(), target.occupied_cells()
|
|
),
|
|
"flanking": self._flanking_bonus(state, target) > 0,
|
|
"movement_cost_ft": self.movement_cost_ft(state, target),
|
|
"target_threatens_me": target_threatens_me,
|
|
"enemies_threatening_me": len(threatening_enemies),
|
|
}
|
|
|
|
def _take_turn(self, state: CombatantState) -> bool:
|
|
"""Execute one combatant's full turn; return True if the battle is over."""
|
|
self._decrement_durations(state)
|
|
# Check cant_act flag from conditions
|
|
if self.has_condition_flag(state, "cant_act"):
|
|
self._log(f"round {self._current_round} {state.combatant.id}: wait (cannot act)")
|
|
return self._winner_side() is not None
|
|
self._apply_fear_aura(state)
|
|
state.moved_this_turn = False
|
|
state.power_attack = False
|
|
actions = self._policy(self, state)
|
|
if state.immediate_used:
|
|
actions = _without_swift_bonus(actions)
|
|
state.immediate_used = False
|
|
if self._has_condition_named(state, "staggered"):
|
|
actions = _staggered_filter(actions)
|
|
elif self.has_condition_flag(state, "cant_attack"):
|
|
actions = _nauseated_filter(actions)
|
|
before = len(self._transcript)
|
|
for action in actions:
|
|
if not state.active:
|
|
break
|
|
self._execute(state, action)
|
|
if self._winner_side() is not None:
|
|
break
|
|
if state.active and len(self._transcript) == before:
|
|
self._log(f"round {self._current_round} {state.combatant.id}: wait")
|
|
return self._winner_side() is not None
|
|
|
|
def has_condition_flag(self, state: CombatantState, flag: str) -> bool:
|
|
"""Check if any active condition has the given behavioral flag set to True."""
|
|
for ac in state.active_conditions:
|
|
if getattr(ac.condition, flag, False):
|
|
return True
|
|
# Also check old conditions field for backwards compatibility
|
|
return any(getattr(cond, flag, False) for cond in state.conditions)
|
|
|
|
def _has_condition_named(self, state: CombatantState, name: str) -> bool:
|
|
"""Check if the state has an active or old condition with the given name."""
|
|
return (
|
|
any(ac.condition.name == name for ac in state.active_conditions)
|
|
or any(cond.name == name for cond in state.conditions)
|
|
)
|
|
|
|
def _modifier_condition_holds(
|
|
self,
|
|
_state: CombatantState,
|
|
condition_name: str,
|
|
*,
|
|
weapon: AttackSpec | None = None,
|
|
dist_ft: int | None = None,
|
|
save_descriptors: frozenset[str] = frozenset(),
|
|
) -> bool:
|
|
"""Evaluate whether a StatModifier's runtime condition holds.
|
|
|
|
Unknown/unresolvable conditions return False (modifier excluded).
|
|
Supported: "target_within_30ft_ranged" (ranged-style weapon, <= 30 ft),
|
|
"axe" (weapon name contains "axe"), and save-descriptor names (fear,
|
|
cold, ...) that are present in the save's descriptor set.
|
|
"""
|
|
if condition_name == "target_within_30ft_ranged":
|
|
return (
|
|
weapon is not None
|
|
and dist_ft is not None
|
|
and self._is_ranged_attack(weapon)
|
|
and dist_ft <= _POINT_BLANK_RANGE_FT
|
|
)
|
|
if condition_name == "axe":
|
|
return weapon is not None and "axe" in weapon.name.lower()
|
|
if condition_name in _SAVE_DESCRIPTOR_CONDITIONS:
|
|
return condition_name in save_descriptors
|
|
return False
|
|
|
|
def _get_speed_mult(self, state: CombatantState) -> float:
|
|
"""Get the minimum speed multiplier from all active conditions."""
|
|
speed_mult = 1.0
|
|
for ac in state.active_conditions:
|
|
if ac.condition.speed_mult != 1.0:
|
|
speed_mult = min(speed_mult, ac.condition.speed_mult)
|
|
for cond in state.conditions:
|
|
if cond.speed_mult != 1.0:
|
|
speed_mult = min(speed_mult, cond.speed_mult)
|
|
return speed_mult
|
|
|
|
def _decrement_durations(self, state: CombatantState) -> None:
|
|
"""Decrement duration rounds for buffs and conditions; remove expired ones."""
|
|
state.active_buffs[:] = [
|
|
ActiveBuff(modifiers=b.modifiers, rounds_left=b.rounds_left - 1, source=b.source)
|
|
for b in state.active_buffs
|
|
if b.rounds_left > 1
|
|
]
|
|
state.active_conditions[:] = [
|
|
ActiveCondition(condition=c.condition, rounds_left=c.rounds_left - 1, source=c.source)
|
|
for c in state.active_conditions
|
|
if c.rounds_left > 1
|
|
]
|
|
|
|
def run(self) -> CombatResult:
|
|
"""Run the battle and return the final result and transcript."""
|
|
self._roll_initiative()
|
|
round_no = 1
|
|
while round_no <= self._round_cap:
|
|
self._current_round = round_no
|
|
self._aoo_used.clear()
|
|
self._expire_summons()
|
|
if self._run_round():
|
|
break
|
|
round_no += 1
|
|
winner = self._winner_side()
|
|
if winner is not None:
|
|
self._log(f"battle over: {winner} win in {round_no} rounds")
|
|
else:
|
|
self._log(f"battle over: draw after {self._round_cap} rounds")
|
|
stats = {cid: CombatantStats(**asdict(live)) for cid, live in self._stats.items()}
|
|
return CombatResult(
|
|
winner=winner,
|
|
rounds=round_no if winner is not None else self._round_cap,
|
|
transcript=tuple(self._transcript),
|
|
stats=stats,
|
|
)
|
|
|
|
def _run_round(self) -> bool:
|
|
"""Execute one round; return True if the battle ended."""
|
|
order = self._build_round_order()
|
|
for state in order:
|
|
if not state.active:
|
|
continue
|
|
if self._take_turn(state):
|
|
return True
|
|
if self._drive_pending_summons():
|
|
return True
|
|
self._pending_summons.clear()
|
|
return self._winner_side() is not None
|
|
|
|
def _drive_pending_summons(self) -> bool:
|
|
"""Give summoned creatures their turn immediately; return True if battle ended."""
|
|
while self._pending_summons:
|
|
summoned = self._pending_summons.pop(0)
|
|
if (
|
|
summoned in self._states
|
|
and summoned.active
|
|
and self._take_turn(summoned)
|
|
):
|
|
return True
|
|
return False
|
|
|
|
def _blocked(self, *exclude: CombatantState) -> frozenset[Pos]:
|
|
"""Union of occupied cells of all active states except those in ``exclude``."""
|
|
return frozenset(
|
|
cell
|
|
for s in self._states
|
|
if s.active and all(s is not e for e in exclude)
|
|
for cell in s.occupied_cells()
|
|
)
|
|
|
|
def creature_distance(self, a: CombatantState, b: CombatantState) -> int:
|
|
"""Minimum grid distance between any occupied cell of ``a`` and ``b``."""
|
|
cells_b = b.occupied_cells()
|
|
return min(
|
|
self._grid.distance(cell_a, cell_b)
|
|
for cell_a in a.occupied_cells()
|
|
for cell_b in cells_b
|
|
)
|
|
|
|
def _elevation(self, state: CombatantState) -> int:
|
|
"""Height of the creature's anchor cell (0 when no elevation layer)."""
|
|
return self._grid.elevation_at(state.pos)
|
|
|
|
def _cells_free(self, cells: Iterable[Pos], mover: CombatantState) -> bool:
|
|
"""True if every cell is in bounds, passable, and unoccupied by others."""
|
|
blocked = self._blocked(mover)
|
|
return all(
|
|
self._grid.passable(cell) and cell not in blocked for cell in cells
|
|
)
|
|
|
|
def _footprint_at(self, anchor: Pos, state: CombatantState) -> frozenset[Pos]:
|
|
"""The cells ``state`` would occupy if its anchor were at ``anchor``."""
|
|
n = SIZE_CELLS.get(state.combatant.size, 1)
|
|
r, c = anchor
|
|
return frozenset(
|
|
(r + dr, c + dc) for dr in range(n) for dc in range(n)
|
|
)
|
|
|
|
def _footprint_cost(
|
|
self, footprint: frozenset[Pos], cost_field: dict[Pos, int]
|
|
) -> int | None:
|
|
"""Minimum cost over footprint cells present in the cost field."""
|
|
costs = [cost_field[cell] for cell in footprint if cell in cost_field]
|
|
return min(costs) if costs else None
|
|
|
|
def nearest_enemy(self, state: CombatantState) -> CombatantState | None:
|
|
"""Closest active enemy by grid distance; ties keep list order."""
|
|
enemies = [s for s in self._states if s.side != state.side and s.active]
|
|
if not enemies:
|
|
return None
|
|
return min(
|
|
enemies,
|
|
key=lambda s: (self.creature_distance(state, s), self._states.index(s)),
|
|
)
|
|
|
|
def weapon_for(self, attacker: CombatantState, target: CombatantState) -> AttackSpec | None:
|
|
"""First weapon usable against the target at this range and with clear LoE."""
|
|
if not has_line_of_effect_cells(
|
|
self._grid, attacker.occupied_cells(), target.occupied_cells()
|
|
):
|
|
return None
|
|
dist_ft = self.creature_distance(attacker, target) * _SQUARE_FT
|
|
for weapon in attacker.usable_attacks():
|
|
if weapon.kind in ("melee", "touch"):
|
|
effective_reach_ft = _effective_reach_ft(attacker, weapon)
|
|
if weapon.reach_ft > _SQUARE_FT:
|
|
if _SQUARE_FT < dist_ft <= effective_reach_ft:
|
|
return weapon
|
|
elif dist_ft <= effective_reach_ft:
|
|
return weapon
|
|
max_increments = (
|
|
_MAX_TOUCH_RANGE_INCREMENTS
|
|
if weapon.kind == "touch"
|
|
else _MAX_RANGE_INCREMENTS
|
|
)
|
|
if (
|
|
weapon.kind in ("ranged", "touch")
|
|
and weapon.range_increment_ft is not None
|
|
and dist_ft <= weapon.range_increment_ft * max_increments
|
|
):
|
|
return weapon
|
|
return None
|
|
|
|
def _is_ranged_attack(self, weapon: AttackSpec) -> bool:
|
|
"""Ranged-style attack: projectile, or touch thrown/fired from a distance.
|
|
|
|
Melee touch attacks (no range increment) are not ranged-style.
|
|
"""
|
|
return weapon.kind == "ranged" or (
|
|
weapon.kind == "touch" and weapon.range_increment_ft is not None
|
|
)
|
|
|
|
def range_penalty(self, weapon: AttackSpec, dist_ft: int) -> int:
|
|
"""PF1e: -2 per full range increment beyond the first, zero within it."""
|
|
if weapon.kind not in ("ranged", "touch") or weapon.range_increment_ft is None:
|
|
return 0
|
|
increments = dist_ft // weapon.range_increment_ft
|
|
if dist_ft % weapon.range_increment_ft != 0:
|
|
increments += 1
|
|
return -_RANGE_INCREMENT_PENALTY * max(0, increments - 1)
|
|
|
|
def _flanking_bonus(self, attacker: CombatantState, target: CombatantState) -> int:
|
|
"""+2 if an active ally threatens the target from the opposite side (CRB flanking).
|
|
|
|
The opposite side is the mirror of every attacker cell across every
|
|
target cell; an ally occupying any mirrored cell flanks.
|
|
"""
|
|
opposite = {
|
|
(2 * tr - ar, 2 * tc - ac)
|
|
for ar, ac in attacker.occupied_cells()
|
|
for tr, tc in target.occupied_cells()
|
|
}
|
|
for ally in self._states:
|
|
if ally is attacker or ally is target:
|
|
continue
|
|
if not ally.active or ally.side != attacker.side:
|
|
continue
|
|
if not (ally.occupied_cells() & opposite):
|
|
continue
|
|
if any(w.kind == "melee" for w in ally.combatant.attacks):
|
|
return _FLANK_BONUS
|
|
return 0
|
|
|
|
def _stat_modifiers(
|
|
self,
|
|
state: CombatantState,
|
|
target: str,
|
|
*,
|
|
weapon_name: str | None = None,
|
|
weapon: AttackSpec | None = None,
|
|
dist_ft: int | None = None,
|
|
save_descriptors: frozenset[str] = frozenset(),
|
|
) -> list[StatModifier]:
|
|
"""Collect all StatModifiers for ``target`` from all sources.
|
|
|
|
``weapon_name`` filters out modifiers with a ``weapon_filter`` that
|
|
doesn't match (e.g. Weapon Focus (Pistol) only applies to "Pistol").
|
|
Modifiers without a ``weapon_filter`` are always included.
|
|
|
|
Modifiers whose ``condition`` is set are only included when the named
|
|
runtime condition holds for ``state`` (evaluated via
|
|
``_modifier_condition_holds``, which consumes the weapon/range/save
|
|
context threaded from the action being resolved). Unsupported
|
|
condition names are treated as NOT holding (modifier excluded).
|
|
"""
|
|
mods: list[StatModifier] = []
|
|
# Old effects field (backwards compatibility)
|
|
mods.extend(m for m in state.effects if m.target == target)
|
|
# Old conditions field (backwards compatibility)
|
|
for cond in state.conditions:
|
|
mods.extend(m for m in cond.modifiers if m.target == target)
|
|
# New active buffs
|
|
for buff in state.active_buffs:
|
|
mods.extend(m for m in buff.modifiers if m.target == target)
|
|
# New active conditions
|
|
for ac in state.active_conditions:
|
|
mods.extend(m for m in ac.condition.modifiers if m.target == target)
|
|
# Features
|
|
for feat in state.combatant.features:
|
|
mods.extend(
|
|
m
|
|
for m in feat.effects
|
|
if m.target == target
|
|
and (m.weapon_filter is None or m.weapon_filter == weapon_name)
|
|
)
|
|
if target == "ac":
|
|
if self._has_condition_named(state, "prone"):
|
|
mods.append(StatModifier(target="ac", value=-4))
|
|
if self._has_condition_named(state, "paralyzed"):
|
|
mods.append(StatModifier(target="ac", value=-4))
|
|
return [
|
|
m
|
|
for m in mods
|
|
if m.condition is None
|
|
or self._modifier_condition_holds(
|
|
state,
|
|
m.condition,
|
|
weapon=weapon,
|
|
dist_ft=dist_ft,
|
|
save_descriptors=save_descriptors,
|
|
)
|
|
]
|
|
|
|
def _has_feat(self, state: CombatantState, feat_name: str) -> bool:
|
|
"""True if the combatant has a feature with the given name."""
|
|
return any(f.name == feat_name for f in state.combatant.features)
|
|
|
|
def _is_paralyzing_touch(self, attacker: CombatantState, weapon: AttackSpec) -> bool:
|
|
"""True if a hit with this weapon should apply the Lich's paralyzing touch.
|
|
|
|
Robust check: the attacker has the "Paralyzing Touch" feature and the
|
|
weapon is a touch attack (or is literally named "Paralyzing Touch").
|
|
"""
|
|
if not self._has_feat(attacker, "Paralyzing Touch"):
|
|
return False
|
|
return weapon.kind == "touch" or weapon.name == "Paralyzing Touch"
|
|
|
|
_FEAR_AURA_DC = 22
|
|
_FEAR_AURA_RANGE_FT = 30
|
|
_FEAR_AURA_ROUNDS = 1
|
|
|
|
def _apply_fear_aura(self, state: CombatantState) -> None:
|
|
"""Apply the Lich's Fear Aura: enemies within 30 ft save Will (DC 22) or become shaken.
|
|
|
|
Triggered at the start of the aura-bearer's turn (simplification of the
|
|
PF1e "on range entry" trigger — the lich acts first each round, so this
|
|
covers entry into range). Only enemies within range who are not already
|
|
shaken are tested, so the effect does not re-roll on the same enemy each
|
|
turn. Shaken lasts a fixed 1 round.
|
|
"""
|
|
if not self._has_feat(state, "Fear Aura"):
|
|
return
|
|
for enemy in self._states:
|
|
if (
|
|
not enemy.active
|
|
or enemy.side == state.side
|
|
or self._has_condition_named(enemy, "shaken")
|
|
):
|
|
continue
|
|
dist_ft = self.creature_distance(state, enemy) * _SQUARE_FT
|
|
if dist_ft > self._FEAR_AURA_RANGE_FT:
|
|
continue
|
|
save = self.resolve_save(
|
|
enemy, "will", self._FEAR_AURA_DC, save_descriptors=frozenset({"fear"})
|
|
)
|
|
if save.success:
|
|
self._log(
|
|
f"round {self._current_round} {state.combatant.id}: fear aura on "
|
|
f"{enemy.combatant.id} -> SAVED"
|
|
)
|
|
continue
|
|
enemy.active_conditions.append(
|
|
ActiveCondition(
|
|
condition=CONDITIONS_BY_NAME["shaken"],
|
|
rounds_left=self._FEAR_AURA_ROUNDS,
|
|
source=f"monster ability: fear aura by {state.combatant.id}",
|
|
)
|
|
)
|
|
self._log(
|
|
f"round {self._current_round} {state.combatant.id}: fear aura on "
|
|
f"{enemy.combatant.id} -> shaken ({self._FEAR_AURA_ROUNDS}r)"
|
|
)
|
|
|
|
|
|
|
|
def _aoo_allowance(self, state: CombatantState) -> int:
|
|
"""Number of AoOs allowed per round for a combatant.
|
|
|
|
Base is 1. Combat Reflexes adds the DEX modifier on top (clamped to >= 1
|
|
so a non-positive DEX mod never reduces the normal single AoO).
|
|
"""
|
|
if not self._has_feat(state, "Combat Reflexes"):
|
|
return 1
|
|
return max(1, 1 + ((state.combatant.abilities.dex_score - 10) // 2))
|
|
|
|
def _power_attack_amt(self, attacker: CombatantState) -> int:
|
|
"""Power Attack exchange amount: X = BAB//4 + 1 (min 1). Returns 0 if inactive."""
|
|
if not attacker.power_attack or not self._has_feat(attacker, "Power Attack"):
|
|
return 0
|
|
return max(1, attacker.combatant.bab // 4 + 1)
|
|
|
|
def resolve_attack(
|
|
self,
|
|
attacker: CombatantState,
|
|
target: CombatantState,
|
|
weapon: AttackSpec,
|
|
*,
|
|
bonus_override: int | None = None,
|
|
) -> AttackResult:
|
|
"""Roll one attack (with crit confirm and damage) and apply it."""
|
|
roll = self._rng.d20()
|
|
dist_ft = self.creature_distance(attacker, target) * _SQUARE_FT
|
|
penalty = self.range_penalty(weapon, dist_ft)
|
|
flank = self._flanking_bonus(attacker, target) if weapon.kind == "melee" else 0
|
|
high_ground = (
|
|
_HIGH_GROUND_BONUS
|
|
if weapon.kind == "melee"
|
|
and self._elevation(attacker) > self._elevation(target)
|
|
else 0
|
|
)
|
|
base_bonus = bonus_override if bonus_override is not None else weapon.attack_bonus
|
|
atk_mods = self._stat_modifiers(
|
|
attacker, "attack", weapon_name=weapon.name, weapon=weapon, dist_ft=dist_ft
|
|
)
|
|
if self._is_ranged_attack(weapon) and self._has_condition_named(attacker, "prone"):
|
|
atk_mods.append(StatModifier(target="attack", value=-4))
|
|
attack_mod = resolve_modifiers(atk_mods)
|
|
pa_amt = self._power_attack_amt(attacker) if weapon.kind == "melee" else 0
|
|
total = roll + base_bonus + penalty + flank + high_ground + attack_mod - pa_amt
|
|
# Check if target denies DEX to AC (flat-footed)
|
|
denies_dex = self.has_condition_flag(target, "denies_dex_to_ac")
|
|
full_ac_base = (
|
|
target.combatant.ac.flat_footed
|
|
if denies_dex
|
|
else target.combatant.ac.total
|
|
)
|
|
# Touch resolves vs touch AC; firearms beyond their first increment use full AC.
|
|
if weapon.kind == "touch":
|
|
if (
|
|
weapon.firearm
|
|
and weapon.range_increment_ft is not None
|
|
and dist_ft > weapon.range_increment_ft
|
|
):
|
|
base_ac = full_ac_base
|
|
elif denies_dex:
|
|
base_ac = (
|
|
target.combatant.ac.touch - max(0, target.combatant.abilities.dex_mod)
|
|
)
|
|
else:
|
|
base_ac = target.combatant.ac.touch
|
|
else:
|
|
base_ac = full_ac_base
|
|
ac = base_ac + resolve_modifiers(self._stat_modifiers(target, "ac"))
|
|
occupied = frozenset(
|
|
cell
|
|
for s in self._states
|
|
if s.active and s is not attacker and s is not target
|
|
for cell in s.occupied_cells()
|
|
)
|
|
if has_cover_cells(
|
|
self._grid,
|
|
attacker.occupied_cells(),
|
|
target.occupied_cells(),
|
|
ranged=self._is_ranged_attack(weapon),
|
|
occupied=occupied,
|
|
):
|
|
ac += _COVER_AC_BONUS
|
|
hit = roll == _NATURAL_TWENTY or (roll != _NATURAL_ONE and total >= ac)
|
|
crit = False
|
|
damage = 0
|
|
if hit:
|
|
if roll != _NATURAL_ONE and roll >= weapon.crit_range:
|
|
confirm = self._rng.d20()
|
|
confirm_total = (
|
|
confirm + base_bonus + penalty + flank + high_ground + attack_mod - pa_amt
|
|
)
|
|
crit = confirm != _NATURAL_ONE and confirm_total >= ac
|
|
dmg_mods = self._stat_modifiers(
|
|
attacker, "damage", weapon_name=weapon.name, weapon=weapon, dist_ft=dist_ft
|
|
)
|
|
damage = sum(c.formula.roll(self._rng) for c in weapon.damage) + weapon.damage_bonus
|
|
damage += resolve_modifiers(dmg_mods)
|
|
damage += pa_amt * 2 if weapon.kind == "melee" else 0
|
|
if crit:
|
|
damage *= weapon.crit_mult
|
|
damage = self._apply_dr(damage, weapon, target)
|
|
target.hp -= damage
|
|
return AttackResult(
|
|
hit=hit, crit=crit, damage=damage, roll=roll, total=total, ac=ac,
|
|
penalty=penalty, flank_bonus=flank, high_ground_bonus=high_ground,
|
|
base_bonus=base_bonus,
|
|
)
|
|
|
|
def _apply_dr(self, damage: int, weapon: AttackSpec, target: CombatantState) -> int:
|
|
dr: DamageReduction | None = target.combatant.dr
|
|
if dr is None:
|
|
return damage
|
|
types = {t for component in weapon.damage for t in component.types}
|
|
if types & dr.bypass:
|
|
return damage
|
|
return max(0, damage - dr.amount)
|
|
|
|
def _apply_dr_for_types(
|
|
self, damage: int, damage_types: frozenset[str], target: CombatantState
|
|
) -> int:
|
|
dr: DamageReduction | None = target.combatant.dr
|
|
if dr is None:
|
|
return damage
|
|
if damage_types & dr.bypass:
|
|
return damage
|
|
return max(0, damage - dr.amount)
|
|
|
|
def resolve_save(
|
|
self,
|
|
state: CombatantState,
|
|
save_type: Literal["fort", "ref", "will"],
|
|
dc: int,
|
|
*,
|
|
save_descriptors: frozenset[str] = frozenset(),
|
|
) -> SaveResult:
|
|
"""Roll a saving throw (fort/ref/will) against ``dc``.
|
|
|
|
PF1e: natural 1 = automatic failure, natural 20 = automatic success.
|
|
Effect modifiers and condition modifiers (StatModifier with matching
|
|
target) are applied via ``resolve_modifiers`` — same bonus-type
|
|
stacking rules as attacks. ``save_descriptors`` (e.g. {"fear"},
|
|
{"poison"}) gates conditional modifiers like Bitter or Born Damned.
|
|
"""
|
|
roll = self._rng.d20()
|
|
base = getattr(state.combatant.saves, save_type)
|
|
bonus = resolve_modifiers(
|
|
self._stat_modifiers(state, save_type, save_descriptors=save_descriptors)
|
|
)
|
|
total = roll + base + bonus
|
|
success = roll != _NATURAL_ONE and (roll == _NATURAL_TWENTY or total >= dc)
|
|
return SaveResult(success=success, roll=roll, total=total, dc=dc)
|
|
|
|
def resolve_skill(
|
|
self, state: CombatantState, skill: str, dc: int
|
|
) -> SaveResult:
|
|
"""Roll a skill check against ``dc``."""
|
|
|
|
roll = self._rng.d20()
|
|
bonus = self._skill_static_bonus(state, skill)
|
|
total = roll + bonus
|
|
# Natural 1/20 are not auto-fail/success for skills
|
|
success = total >= dc
|
|
return SaveResult(success=success, roll=roll, total=total, dc=dc)
|
|
|
|
def _skill_static_bonus(self, state: CombatantState, skill: str) -> int:
|
|
"""Return a skill's static bonus (no d20). Shared by resolve_skill."""
|
|
skill_spec = PREDEFINED_SKILLS.get(skill)
|
|
if skill_spec is None:
|
|
msg = f"Unknown skill: {skill}"
|
|
raise ValueError(msg)
|
|
|
|
skill_rank = state.combatant.skills.get(skill, None)
|
|
rank = skill_rank.rank if skill_rank else 0
|
|
# Class skill bonus
|
|
class_bonus = 3 if skill_rank and skill_rank.class_skill else 0
|
|
# Armor check penalty
|
|
armor_penalty = skill_rank.armor_penalty if skill_rank else 0
|
|
if armor_penalty and skill_spec.armor_check_penalty:
|
|
armor_penalty = -abs(armor_penalty)
|
|
else:
|
|
armor_penalty = 0
|
|
# Ability modifier
|
|
ability_name = skill_spec.ability
|
|
ability_score = getattr(state.combatant.abilities, f"{ability_name}_score")
|
|
ability_mod = (ability_score - 10) // 2
|
|
# Effect/condition modifiers
|
|
mod_bonus = resolve_modifiers(self._stat_modifiers(state, skill))
|
|
return rank + class_bonus + ability_mod + armor_penalty + mod_bonus
|
|
|
|
def _roll_initiative(self) -> list[CombatantState]:
|
|
rolls: dict[str, tuple[int, int]] = {}
|
|
for state in self._states:
|
|
roll = self._rng.d20()
|
|
rolls[state.combatant.id] = (roll, roll + state.combatant.initiative_mod)
|
|
self._initiative_keys = {
|
|
s.combatant.id: (
|
|
rolls[s.combatant.id][1],
|
|
s.combatant.initiative_mod,
|
|
self._states.index(s),
|
|
)
|
|
for s in self._states
|
|
}
|
|
order = sorted(
|
|
self._states,
|
|
key=lambda s: self._initiative_keys[s.combatant.id],
|
|
reverse=True,
|
|
)
|
|
for state in order:
|
|
roll, total = rolls[state.combatant.id]
|
|
self._log(
|
|
f"initiative: {state.combatant.id} "
|
|
f"d20={roll}+{state.combatant.initiative_mod}={total}"
|
|
)
|
|
return order
|
|
|
|
def _build_round_order(self) -> list[CombatantState]:
|
|
"""Build initiative order for this round from current ``_states``.
|
|
|
|
Summoned creatures (not present at the start of combat) inherit their
|
|
caster's initiative key so they act immediately after the caster via
|
|
stable sort.
|
|
"""
|
|
|
|
def sort_key(s: CombatantState) -> tuple[int, int, int]:
|
|
key = self._initiative_keys.get(s.combatant.id)
|
|
if key is not None:
|
|
return key
|
|
caster_id = s.summon_caster_id
|
|
if caster_id and caster_id in self._initiative_keys:
|
|
ck = self._initiative_keys[caster_id]
|
|
return (ck[0], ck[1], ck[2])
|
|
return (0, 0, -999)
|
|
|
|
return sorted(self._states, key=sort_key, reverse=True)
|
|
|
|
def _expire_summons(self) -> None:
|
|
"""Decrement summon durations and remove expired summoned creatures."""
|
|
expired: list[CombatantState] = []
|
|
for state in list(self._states):
|
|
if state.summon_duration > 0:
|
|
state.summon_duration -= 1
|
|
if state.summon_duration <= 0:
|
|
expired.append(state)
|
|
for state in expired:
|
|
self._log(
|
|
f"round {self._current_round} {state.combatant.id}: summon expired"
|
|
)
|
|
self._states.remove(state)
|
|
if state in self._pending_summons:
|
|
self._pending_summons.remove(state)
|
|
|
|
def _winner_side(self) -> str | None:
|
|
active_sides = {s.side for s in self._states if s.active}
|
|
if len(active_sides) == 1:
|
|
return active_sides.pop()
|
|
return None
|
|
|
|
def threatened_squares(self, state: CombatantState) -> frozenset[Pos]:
|
|
"""Squares this combatant threatens with a melee weapon.
|
|
|
|
(API publique, lecture pour politique LLM.) Non-reach weapons
|
|
(reach_ft == 5) threaten the disk of effective reach; reach weapons
|
|
(reach_ft > 5) threaten only the ring at exactly their reach (PF1e
|
|
reach dead zone). The union over usable melee weapons is returned.
|
|
"""
|
|
if not state.active:
|
|
return frozenset()
|
|
melee = [w for w in state.usable_attacks() if w.kind == "melee"]
|
|
if not melee:
|
|
return frozenset()
|
|
threatened: set[Pos] = set()
|
|
for weapon in melee:
|
|
threatened |= self._weapon_threatened_squares(state, weapon)
|
|
return frozenset(threatened)
|
|
|
|
def _weapon_threatened_squares(self, state: CombatantState, weapon: AttackSpec) -> set[Pos]:
|
|
"""Squares one melee weapon threatens: disk for non-reach, ring for reach.
|
|
|
|
The union over every occupied cell of the creature's footprint; the
|
|
creature's own cells are never threatened.
|
|
"""
|
|
threatened: set[Pos] = set()
|
|
if weapon.reach_ft > _SQUARE_FT:
|
|
ring = weapon.reach_ft // _SQUARE_FT
|
|
for r, c in state.occupied_cells():
|
|
threatened |= self._ring_cells((r, c), ring)
|
|
else:
|
|
reach_cells = _effective_reach_ft(state, weapon) // _SQUARE_FT
|
|
for r, c in state.occupied_cells():
|
|
threatened |= self._disk_cells((r, c), reach_cells)
|
|
threatened -= state.occupied_cells()
|
|
return threatened
|
|
|
|
def _ring_cells(self, center: Pos, ring: int) -> set[Pos]:
|
|
"""Cells at exactly ``ring`` squares from ``center`` (5-10-5 distance)."""
|
|
r, c = center
|
|
return {
|
|
(r + dr, c + dc)
|
|
for dr in range(-ring, ring + 1)
|
|
for dc in range(-ring, ring + 1)
|
|
if self._grid.distance(center, (r + dr, c + dc)) == ring
|
|
}
|
|
|
|
def _disk_cells(self, center: Pos, radius: int) -> set[Pos]:
|
|
"""Cells within ``radius`` squares of ``center`` (5-10-5 distance)."""
|
|
r, c = center
|
|
return {
|
|
(r + dr, c + dc)
|
|
for dr in range(-radius, radius + 1)
|
|
for dc in range(-radius, radius + 1)
|
|
if (dr, dc) != (0, 0)
|
|
and self._grid.distance(center, (r + dr, c + dc)) <= radius
|
|
}
|
|
|
|
def enemies_threatening(self, state: CombatantState, pos: Pos) -> list[CombatantState]:
|
|
"""Active enemies whose threatened squares include pos (in self._states order).
|
|
|
|
(API publique, lecture pour politique LLM.)
|
|
"""
|
|
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 reachable_ft(self, state: CombatantState, budget: int | None = None) -> dict[Pos, int]:
|
|
"""Movement cost in feet from state's anchor to each reachable cell.
|
|
|
|
(API publique, lecture pour politique LLM.) Same cost model as
|
|
``Grid.reachable``: 5-10-5 diagonals, terrain, elevation, blocked
|
|
creatures excluded. ``budget`` is in feet; None = full field.
|
|
"""
|
|
blocked = self._blocked(state)
|
|
if budget is None:
|
|
field = self._grid.reachable(state.pos, None, blocked)
|
|
else:
|
|
budget_cells = budget // _SQUARE_FT
|
|
if budget_cells < 1:
|
|
return {}
|
|
field = self._grid.reachable(state.pos, budget_cells, blocked)
|
|
return {pos: cost * _SQUARE_FT for pos, cost in field.items()}
|
|
|
|
def movement_cost_ft(self, state: CombatantState, target: CombatantState) -> int | None:
|
|
"""Minimal movement cost in feet for ``state`` to reach a cell from which
|
|
it can melee ``target``, or None if unreachable under its speed.
|
|
|
|
(API publique, lecture pour politique LLM.) Uses the same cost model as
|
|
``_move_path``: a full Dijkstra field from the target's anchor (other
|
|
active states' cells blocked), then the cheapest field value over
|
|
``state``'s footprint, compared against the combatant's land speed.
|
|
Read-only: never mutates state nor consumes RNG.
|
|
"""
|
|
blocked = self._blocked(state, target)
|
|
field = self._grid.reachable(target.pos, None, blocked)
|
|
cost_cells = self._footprint_cost(state.occupied_cells(), field)
|
|
if cost_cells is None:
|
|
return None
|
|
speed_cells = state.combatant.speed_land_ft // _SQUARE_FT
|
|
if cost_cells > speed_cells:
|
|
return None
|
|
return cost_cells * _SQUARE_FT
|
|
|
|
def _resolve_aoo(self, attacker: CombatantState, target: CombatantState) -> bool:
|
|
"""Resolve one attack of opportunity (single melee attack at normal bonus).
|
|
|
|
AoOs are always taken (PF1e allows declining, but the simulator always strikes).
|
|
Returns True if the target dropped.
|
|
"""
|
|
if self.has_condition_flag(attacker, "cant_aoo"):
|
|
return False
|
|
self._aoo_used[attacker.combatant.id] = self._aoo_used.get(attacker.combatant.id, 0) + 1
|
|
weapon = next((w for w in attacker.usable_attacks() if w.kind == "melee"), None)
|
|
if weapon is None:
|
|
return False
|
|
return self._resolve_swing(
|
|
attacker, target, weapon, label="AoO"
|
|
)
|
|
|
|
def _check_move_aoo(self, mover: CombatantState) -> bool:
|
|
"""Resolve AoOs provoked by leaving any threatened cell of the mover's footprint.
|
|
|
|
Each enemy threatening a cell the mover occupies strikes once (capped
|
|
by the per-round AoO allowance). Returns True if the mover dropped.
|
|
"""
|
|
for cell in mover.occupied_cells():
|
|
for enemy in self.enemies_threatening(mover, cell):
|
|
if self._aoo_used.get(enemy.combatant.id, 0) >= self._aoo_allowance(enemy):
|
|
continue
|
|
self._resolve_aoo(enemy, mover)
|
|
if not mover.active:
|
|
return True
|
|
return False
|
|
|
|
def _check_provoked_aoo(self, attacker: CombatantState, weapon: AttackSpec) -> bool:
|
|
"""Check for ranged-AoO: firing a ranged-style weapon in a threatened square provokes.
|
|
|
|
Ranged-style means projectile weapons and touch thrown/fired weapons
|
|
(touch with a range increment); melee touch (no increment) does not provoke.
|
|
Returns True if the attacker dropped (caller must stop attacking).
|
|
"""
|
|
if not self._is_ranged_attack(weapon):
|
|
return False
|
|
for cell in attacker.occupied_cells():
|
|
for enemy in self.enemies_threatening(attacker, cell):
|
|
if self._aoo_used.get(enemy.combatant.id, 0) >= self._aoo_allowance(enemy):
|
|
continue
|
|
self._resolve_aoo(enemy, attacker)
|
|
if not attacker.active:
|
|
return True
|
|
return False
|
|
|
|
def _check_casting_aoo(self, caster: CombatantState, spell_level: int) -> bool:
|
|
"""Check for AoO from casting a spell in a threatened square.
|
|
|
|
Returns True if the caster dropped or failed concentration (spell fizzles).
|
|
"""
|
|
for cell in caster.occupied_cells():
|
|
for enemy in self.enemies_threatening(caster, cell):
|
|
if self._aoo_used.get(enemy.combatant.id, 0) >= self._aoo_allowance(enemy):
|
|
continue
|
|
hp_before = caster.hp
|
|
self._resolve_aoo(enemy, caster)
|
|
if not caster.active:
|
|
self._log(
|
|
f"round {self._current_round} {caster.combatant.id}: "
|
|
f"casting interrupted by AoO from {enemy.combatant.id}"
|
|
)
|
|
return True
|
|
# Concentration check if damaged
|
|
dmg = hp_before - caster.hp
|
|
if dmg > 0 and not self._check_concentration(caster, spell_level, dmg):
|
|
dc = self._concentration_dc(caster, spell_level, dmg)
|
|
self._log(
|
|
f"round {self._current_round} {caster.combatant.id}: "
|
|
f"concentration check failed (DC {dc}), spell fizzles"
|
|
)
|
|
return True
|
|
return False
|
|
|
|
def _concentration_dc(
|
|
self, caster: CombatantState, spell_level: int, damage_taken: int
|
|
) -> int:
|
|
"""Concentration DC for the caster's current situation.
|
|
|
|
- injured while casting: => 10 + damage_taken + spell_level
|
|
- grappled or pinned: => 10 + grappler's CMB + spell_level
|
|
- casting defensively: => 15 + 2 * spell_level
|
|
"""
|
|
if caster.grappled_with is not None:
|
|
grappler = next(
|
|
(s for s in self._states if s.combatant.id == caster.grappled_with), None
|
|
)
|
|
cmb = self._maneuver_cmb(grappler) if grappler is not None else 0
|
|
return 10 + cmb + spell_level
|
|
if damage_taken > 0:
|
|
return 10 + damage_taken + spell_level
|
|
return 15 + 2 * spell_level
|
|
|
|
def _check_concentration(
|
|
self, caster: CombatantState, spell_level: int, damage_taken: int
|
|
) -> bool:
|
|
"""Concentration check: DC depends on situation.
|
|
|
|
Roll = d20 + caster_level + casting ability mod (+4 Combat Casting).
|
|
"""
|
|
dc = self._concentration_dc(caster, spell_level, damage_taken)
|
|
has_combat_casting = any(f.name == "Combat Casting" for f in caster.combatant.features)
|
|
bonus = 4 if has_combat_casting else 0
|
|
roll = self._rng.d20()
|
|
# Use caster level + ability mod for concentration check
|
|
caster_level = caster.combatant.level
|
|
# For concentration, the check is d20 + caster_level + ability_mod (usually Con)
|
|
con_mod = (caster.combatant.abilities.con_score - 10) // 2
|
|
total = roll + caster_level + con_mod + bonus
|
|
return total >= dc
|
|
def can_step5_to_attack(self, state: CombatantState, target: CombatantState) -> bool:
|
|
"""True if a 5-foot step toward ``target`` would put ``state`` in weapon range."""
|
|
path = self._move_path(state, target)
|
|
if not path:
|
|
return False
|
|
orig = state.pos
|
|
state.pos = path[0]
|
|
weapon = self.weapon_for(state, target)
|
|
state.pos = orig
|
|
return weapon is not None
|
|
|
|
def can_charge(self, state: CombatantState, target: CombatantState) -> ChargeResult | None:
|
|
"""Return a ``ChargeResult`` 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.
|
|
|
|
Returns ``None`` only when the attacker has no usable melee weapon at all.
|
|
Otherwise returns a ``ChargeResult``: ``weapon`` set and ``reason`` None on
|
|
success, or ``weapon`` None with ``reason`` explaining the failure
|
|
(``no_line_of_effect`` or ``no_charge_path``). ``distance_ft`` is the
|
|
straight-line distance in feet to the chosen anchor on success, 0 on
|
|
failure. Never mutates state nor consumes RNG.
|
|
"""
|
|
if not has_line_of_effect_cells(
|
|
self._grid, state.occupied_cells(), target.occupied_cells()
|
|
):
|
|
return ChargeResult(weapon=None, distance_ft=0, reason="no_line_of_effect")
|
|
speed_cells = state.combatant.speed_land_ft // _SQUARE_FT
|
|
max_cells = 2 * speed_cells
|
|
blocked = self._blocked(state, target)
|
|
melee_weapons = [w for w in state.usable_attacks() if w.kind == "melee"]
|
|
if not melee_weapons:
|
|
return None
|
|
for weapon in melee_weapons:
|
|
reach_cells = _effective_reach_ft(state, weapon) // _SQUARE_FT
|
|
min_reach_cells = weapon.reach_ft // _SQUARE_FT if weapon.reach_ft > _SQUARE_FT else 0
|
|
end = self._charge_end(
|
|
state, target, reach_cells, max_cells, blocked, min_reach_cells=min_reach_cells
|
|
)
|
|
if end is not None:
|
|
distance_ft = self._grid.distance(state.pos, end) * _SQUARE_FT
|
|
return ChargeResult(weapon=weapon, distance_ft=distance_ft, reason=None)
|
|
return ChargeResult(weapon=None, distance_ft=0, reason="no_charge_path")
|
|
|
|
def _charge_end(
|
|
self,
|
|
state: CombatantState,
|
|
target: CombatantState,
|
|
reach_cells: int,
|
|
max_cells: int,
|
|
blocked: frozenset[Pos],
|
|
*,
|
|
min_reach_cells: int = 0,
|
|
) -> Pos | None:
|
|
"""Find the closest anchor from which ``state`` can melee ``target`` via a charge.
|
|
|
|
Scans anchors within reach of the target, picks the one whose Bresenham
|
|
line from ``state.pos`` is the shortest valid charge path (>=2 cells,
|
|
<=max_cells, no blocking terrain or creatures, footprint fits at the
|
|
end, footprint-to-footprint distance within the weapon's reach band).
|
|
Ties break toward lower distance from state.
|
|
"""
|
|
target_cells = target.occupied_cells()
|
|
candidates: list[tuple[int, Pos]] = []
|
|
for end in self._charge_candidates(target, reach_cells, blocked, state.pos):
|
|
footprint = self._footprint_at(end, state)
|
|
if not self._cells_free(footprint, state):
|
|
continue
|
|
dist = min(
|
|
self._grid.distance(cell_a, cell_b)
|
|
for cell_a in footprint
|
|
for cell_b in target_cells
|
|
)
|
|
if dist > reach_cells or dist < min_reach_cells:
|
|
continue
|
|
path = _bresenham_line(state.pos, end)
|
|
if len(path) < _CHARGE_MIN_CELLS or len(path) > max_cells:
|
|
continue
|
|
if not self._charge_path_clear(path, state):
|
|
continue
|
|
candidates.append((len(path), end))
|
|
if not candidates:
|
|
return None
|
|
candidates.sort(key=lambda x: (x[0], self._grid.distance(state.pos, x[1])))
|
|
return candidates[0][1]
|
|
|
|
def _charge_candidates(
|
|
self,
|
|
target: CombatantState,
|
|
reach_cells: int,
|
|
blocked: frozenset[Pos],
|
|
start: Pos,
|
|
) -> list[Pos]:
|
|
"""Anchors within reach of some target cell, passable, unoccupied, not the start."""
|
|
result: set[Pos] = set()
|
|
for tr, tc in target.occupied_cells():
|
|
for dr in range(-reach_cells, reach_cells + 1):
|
|
for dc in range(-reach_cells, reach_cells + 1):
|
|
if abs(dr) > reach_cells or abs(dc) > reach_cells:
|
|
continue
|
|
if dr == 0 and dc == 0:
|
|
continue
|
|
end = (tr + dr, tc + dc)
|
|
if end == start:
|
|
continue
|
|
if not self._grid.in_bounds(end) or not self._grid.passable(end):
|
|
continue
|
|
if end in blocked:
|
|
continue
|
|
result.add(end)
|
|
return sorted(result)
|
|
|
|
def _charge_path_clear(
|
|
self, path: list[Pos], state: CombatantState
|
|
) -> bool:
|
|
"""True if the footprint fits at every anchor on the charge path."""
|
|
for pos in path:
|
|
if not self._cells_free(self._footprint_at(pos, state), state):
|
|
return False
|
|
terrain = self._grid.terrain_at(pos)
|
|
if terrain.move_cost is not None and terrain.move_cost > 1:
|
|
return False
|
|
return True
|
|
|
|
def _charge(
|
|
self, attacker: CombatantState, target: CombatantState, weapon: AttackSpec
|
|
) -> None:
|
|
"""Execute a charge: move in a straight line, then make a single melee attack at +2.
|
|
|
|
Sets a -2 AC penalty on the attacker that lasts until the start of their next turn
|
|
(cleared in ``_take_turn``). Movement during a charge provokes AoOs step-by-step
|
|
as for any move action.
|
|
"""
|
|
reach_cells = _effective_reach_ft(attacker, weapon) // _SQUARE_FT
|
|
min_reach_cells = weapon.reach_ft // _SQUARE_FT if weapon.reach_ft > _SQUARE_FT else 0
|
|
speed_cells = attacker.combatant.speed_land_ft // _SQUARE_FT
|
|
max_cells = 2 * speed_cells
|
|
blocked = self._blocked(attacker, target)
|
|
end = self._charge_end(
|
|
attacker, target, reach_cells, max_cells, blocked, min_reach_cells=min_reach_cells
|
|
)
|
|
if end is None:
|
|
return
|
|
path = _bresenham_line(attacker.pos, end)
|
|
start = attacker.pos
|
|
actual_path: list[Pos] = []
|
|
for step in path:
|
|
if self._check_move_aoo(attacker):
|
|
break
|
|
if not attacker.active:
|
|
break
|
|
attacker.pos = step
|
|
actual_path.append(step)
|
|
self._check_reactions(
|
|
ReactionEvent(kind="movement_step", actor=attacker)
|
|
)
|
|
if not attacker.active:
|
|
break
|
|
if actual_path:
|
|
attacker.moved_this_turn = True
|
|
coords = "->".join(f"({p[0]},{p[1]})" for p in (start, *actual_path))
|
|
self._log(
|
|
f"round {self._current_round} {attacker.combatant.id}: charge {coords}"
|
|
)
|
|
attacker.active_buffs.append(
|
|
ActiveBuff(
|
|
modifiers=(StatModifier(target="ac", value=-_CHARGE_AC_PENALTY),),
|
|
rounds_left=1,
|
|
source="charge",
|
|
)
|
|
)
|
|
bonus = weapon.attack_bonus + _CHARGE_ATTACK_BONUS
|
|
self._resolve_swing(
|
|
attacker, target, weapon, label="charge", bonus_override=bonus
|
|
)
|
|
|
|
def _execute(self, state: CombatantState, action: Action) -> None:
|
|
target = next((s for s in self._states if s.combatant.id == action.target_id), None)
|
|
kind = action.kind
|
|
if kind == "cast_spell":
|
|
self._execute_cast_spell(state, target, action.spell_name)
|
|
elif kind == "maneuver":
|
|
self._execute_maneuver(
|
|
state, target, action.maneuver,
|
|
direction=action.direction, trick_condition=action.trick_condition,
|
|
)
|
|
elif kind == "retrieve_weapon":
|
|
self._retrieve_weapon(state, action.weapon_id)
|
|
elif kind == "repair_weapon":
|
|
self._repair_weapon(state, action.weapon_id)
|
|
elif kind in ("channel_energy", "hex", "deed", "spell_combat", "inspire_courage"):
|
|
self._execute_class_ability(state, target, action)
|
|
elif kind == "charge":
|
|
self._execute_charge(state, target)
|
|
elif kind == "withdraw":
|
|
self._withdraw(state, target)
|
|
elif kind in ("attack", "full_attack"):
|
|
self._execute_attack(state, target, kind)
|
|
elif kind == "immediate":
|
|
self._execute_immediate(state, target, action)
|
|
else:
|
|
self._execute_movement(state, target, kind)
|
|
|
|
def _execute_class_ability(
|
|
self, state: CombatantState, target: CombatantState | None, action: Action
|
|
) -> None:
|
|
"""Dispatch a class-ability action by kind, reading its carrier field."""
|
|
kind = action.kind
|
|
if kind == "channel_energy":
|
|
self._execute_channel_energy(
|
|
state, target, action.channel_mode or "heal"
|
|
)
|
|
elif kind == "hex":
|
|
self._execute_hex(state, target, action.hex_name)
|
|
elif kind == "deed":
|
|
self._execute_deed(state, target, action.deed_name)
|
|
elif kind == "spell_combat":
|
|
self._execute_spell_combat(state, target, action.spell_name)
|
|
elif kind == "inspire_courage":
|
|
self._execute_inspire_courage(state, target)
|
|
|
|
def _execute_movement(
|
|
self, state: CombatantState, target: CombatantState | None, kind: str
|
|
) -> None:
|
|
"""Execute a movement-style action (5foot_step, move, wait)."""
|
|
if kind == "5foot_step":
|
|
if target is None:
|
|
return
|
|
self._step5(state, target)
|
|
elif kind == "move":
|
|
if target is None:
|
|
return
|
|
self._move(state, target)
|
|
elif kind == "wait":
|
|
self._log(f"round {self._current_round} {state.combatant.id}: wait")
|
|
|
|
def _execute_immediate(
|
|
self, state: CombatantState, target: CombatantState | None, action: Action
|
|
) -> None:
|
|
"""Execute an immediate action (off-turn or in-turn).
|
|
|
|
The immediate action consumes the combatant's next swift action
|
|
(``immediate_used``), which suppresses swift/free bonus actions on the
|
|
following turn. The effect is resolved by the action's carrier fields:
|
|
a spell, a maneuver, a class ability, or a single attack on ``target``.
|
|
"""
|
|
state.immediate_used = True
|
|
if action.spell_name is not None:
|
|
self._execute_cast_spell(state, target, action.spell_name)
|
|
elif action.maneuver is not None:
|
|
self._execute_maneuver(state, target, action.maneuver)
|
|
elif action.class_ability is not None:
|
|
self._execute_class_ability(state, target, action)
|
|
elif target is not None:
|
|
self._execute_attack(state, target, "attack")
|
|
|
|
def _check_reactions(self, event: ReactionEvent) -> None:
|
|
"""Offer an event to the reaction policy; execute the first immediate.
|
|
|
|
One immediate per event: the first active combatant (in state order)
|
|
whose policy returns an action gets to act. Reactions never cascade —
|
|
while an immediate is executing, further events are ignored.
|
|
"""
|
|
if self._reaction_policy is None or self._in_reaction:
|
|
return
|
|
for state in self._states:
|
|
if not state.active or state.immediate_used:
|
|
continue
|
|
action = self._reaction_policy(self, state, event)
|
|
if action is None:
|
|
continue
|
|
self._in_reaction = True
|
|
self._execute(state, action)
|
|
self._in_reaction = False
|
|
return
|
|
|
|
def _execute_cast_spell(
|
|
self,
|
|
state: CombatantState,
|
|
target: CombatantState | None,
|
|
spell_name: str | None,
|
|
) -> None:
|
|
if spell_name is None:
|
|
return
|
|
self._cast_spell(state, target, spell_name)
|
|
|
|
def _execute_maneuver(
|
|
self,
|
|
state: CombatantState,
|
|
target: CombatantState | None,
|
|
maneuver: str | None,
|
|
*,
|
|
direction: tuple[int, int] | None = None,
|
|
trick_condition: TrickCondition | None = None,
|
|
) -> None:
|
|
"""Execute a combat maneuver (trip, disarm, bull_rush, sunder, grapple,
|
|
escape, overrun, dirty_trick, steal, reposition, drag)."""
|
|
if target is None or not target.active:
|
|
return
|
|
if self.has_condition_flag(state, "cant_attack"):
|
|
self._log(f"round {self._current_round} {state.combatant.id}: wait (cannot attack)")
|
|
return
|
|
if maneuver == "escape":
|
|
self._maneuver_escape(state, target)
|
|
return
|
|
if maneuver not in (
|
|
"trip", "disarm", "bull_rush", "sunder", "grapple",
|
|
"overrun", "dirty_trick", "steal", "reposition", "drag",
|
|
):
|
|
return
|
|
|
|
attacker_cmb = self._maneuver_cmb(state)
|
|
target_cmd = self._maneuver_cmd(target)
|
|
roll = self._rng.d20()
|
|
total = roll + attacker_cmb
|
|
m = _ManeuverRoll(
|
|
roll=roll, attacker_cmb=attacker_cmb, total=total,
|
|
target_cmd=target_cmd, success=roll != 1 and total >= target_cmd,
|
|
)
|
|
|
|
handlers: dict[str, Callable[[], None]] = {
|
|
"trip": lambda: self._maneuver_trip(state, target, m),
|
|
"disarm": lambda: self._maneuver_disarm(state, target, m),
|
|
"bull_rush": lambda: self._maneuver_bull_rush(state, target, m, direction=direction),
|
|
"sunder": lambda: self._maneuver_sunder(state, target, m),
|
|
"grapple": lambda: self._maneuver_grapple(state, target, m),
|
|
"overrun": lambda: self._maneuver_overrun(state, target, m),
|
|
"dirty_trick": lambda: self._maneuver_dirty_trick(
|
|
state, target, m, trick_condition=trick_condition
|
|
),
|
|
"steal": lambda: self._maneuver_steal(state, target, m),
|
|
"reposition": lambda: self._maneuver_reposition(state, target, m, direction=direction),
|
|
"drag": lambda: self._maneuver_drag(state, target, m, direction=direction),
|
|
}
|
|
handlers[maneuver]()
|
|
|
|
def _retrieve_weapon(self, state: CombatantState, weapon_id: str | None) -> None:
|
|
"""Restore a stolen weapon to its owner (remove it from ``weapon_stolen``).
|
|
|
|
No-op (with a log line) when ``weapon_id`` is None or the id was never
|
|
stolen. Once restored, the weapon is usable again via ``usable_attacks()``.
|
|
"""
|
|
if weapon_id is None:
|
|
self._log(
|
|
f"round {self._current_round} {state.combatant.id}: "
|
|
f"retrieve: nothing to retrieve"
|
|
)
|
|
return
|
|
if weapon_id not in state.weapon_stolen:
|
|
self._log(
|
|
f"round {self._current_round} {state.combatant.id}: "
|
|
f"retrieve {weapon_id}: nothing to retrieve"
|
|
)
|
|
return
|
|
state.weapon_stolen.remove(weapon_id)
|
|
self._log(
|
|
f"round {self._current_round} {state.combatant.id}: "
|
|
f"retrieve {weapon_id} (restored)"
|
|
)
|
|
|
|
def _repair_weapon(self, state: CombatantState, weapon_id: str | None) -> None:
|
|
"""Repair a broken weapon: remove it from ``weapon_broken`` and reset damage.
|
|
|
|
No-op (with a log line) when ``weapon_id`` is None or the weapon was never
|
|
damaged. Also removes the transient ``disarmed`` condition originating
|
|
from the sunder that broke the weapon, so the repaired weapon is fully
|
|
usable again.
|
|
"""
|
|
if weapon_id is None:
|
|
self._log(
|
|
f"round {self._current_round} {state.combatant.id}: "
|
|
f"repair: nothing to repair"
|
|
)
|
|
return
|
|
if weapon_id not in state.weapon_damage:
|
|
self._log(
|
|
f"round {self._current_round} {state.combatant.id}: "
|
|
f"repair {weapon_id}: nothing to repair"
|
|
)
|
|
return
|
|
state.weapon_damage.pop(weapon_id, None)
|
|
state.weapon_broken.discard(weapon_id)
|
|
state.active_conditions = [
|
|
c for c in state.active_conditions
|
|
if not (c.condition.name == "disarmed" and c.source == "maneuver: sunder")
|
|
]
|
|
self._log(
|
|
f"round {self._current_round} {state.combatant.id}: "
|
|
f"repair {weapon_id} (restored)"
|
|
)
|
|
|
|
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,
|
|
*,
|
|
direction: tuple[int, int] | None = None,
|
|
) -> None:
|
|
if not m.success:
|
|
self._maneuver_fail(state, target, "bull_rush", m)
|
|
return
|
|
if direction is not None:
|
|
new_pos = (target.pos[0] + direction[0], target.pos[1] + direction[1])
|
|
pushed = self._cells_free(self._footprint_at(new_pos, target), target)
|
|
if pushed:
|
|
target.pos = new_pos
|
|
else:
|
|
pushed = self._bull_rush_push(state, target)
|
|
outcome = "SUCCESS (pushed)" if pushed else "SUCCESS (no space)"
|
|
self._log(
|
|
f"round {self._current_round} {state.combatant.id}: bull rush vs "
|
|
f"{target.combatant.id} d20={m.roll}+{m.attacker_cmb}={m.total} "
|
|
f"CMD {m.target_cmd} -> {outcome}"
|
|
)
|
|
|
|
def _maneuver_sunder(
|
|
self, state: CombatantState, target: CombatantState, m: _ManeuverRoll
|
|
) -> None:
|
|
if not m.success:
|
|
self._maneuver_fail(state, target, "sunder", m)
|
|
return
|
|
weapon = next((w for w in target.usable_attacks() if w.kind == "melee"), None)
|
|
if weapon is None:
|
|
self._log(
|
|
f"round {self._current_round} {state.combatant.id}: sunder vs "
|
|
f"{target.combatant.id} d20={m.roll}+{m.attacker_cmb}={m.total} "
|
|
f"CMD {m.target_cmd} -> SUCCESS (no weapon to damage)"
|
|
)
|
|
return
|
|
dmg = state.combatant.abilities.str_mod + 2
|
|
target.weapon_damage[weapon.id] = target.weapon_damage.get(weapon.id, 0) + dmg
|
|
if target.weapon_damage[weapon.id] >= _WEAPON_DURABILITY:
|
|
target.weapon_broken.add(weapon.id)
|
|
target.active_conditions.append(
|
|
ActiveCondition(
|
|
condition=Condition(
|
|
name="disarmed",
|
|
modifiers=(StatModifier(target="attack", value=-4),),
|
|
),
|
|
rounds_left=1,
|
|
source="maneuver: sunder",
|
|
)
|
|
)
|
|
self._log(
|
|
f"round {self._current_round} {state.combatant.id}: sunder vs "
|
|
f"{target.combatant.id} d20={m.roll}+{m.attacker_cmb}={m.total} "
|
|
f"CMD {m.target_cmd} -> SUCCESS (weapon destroyed)"
|
|
)
|
|
return
|
|
self._log(
|
|
f"round {self._current_round} {state.combatant.id}: sunder vs "
|
|
f"{target.combatant.id} d20={m.roll}+{m.attacker_cmb}={m.total} "
|
|
f"CMD {m.target_cmd} -> SUCCESS ({dmg} damage to weapon)"
|
|
)
|
|
|
|
def _maneuver_grapple(
|
|
self, state: CombatantState, target: CombatantState, m: _ManeuverRoll
|
|
) -> None:
|
|
if not m.success:
|
|
self._maneuver_fail(state, target, "grapple", m)
|
|
return
|
|
target.active_conditions.append(
|
|
ActiveCondition(
|
|
condition=CONDITIONS_BY_NAME["grappled"],
|
|
rounds_left=10,
|
|
source=f"maneuver: grapple by {state.combatant.id}",
|
|
)
|
|
)
|
|
target.grappled_with = state.combatant.id
|
|
state.grappled_with = target.combatant.id
|
|
self._log(
|
|
f"round {self._current_round} {state.combatant.id}: grapple vs "
|
|
f"{target.combatant.id} d20={m.roll}+{m.attacker_cmb}={m.total} "
|
|
f"CMD {m.target_cmd} -> SUCCESS (grappled)"
|
|
)
|
|
|
|
def _maneuver_fail(
|
|
self, state: CombatantState, target: CombatantState, maneuver: str,
|
|
m: _ManeuverRoll,
|
|
) -> None:
|
|
self._log(
|
|
f"round {self._current_round} {state.combatant.id}: {maneuver} vs "
|
|
f"{target.combatant.id} d20={m.roll}+{m.attacker_cmb}={m.total} "
|
|
f"CMD {m.target_cmd} -> FAILED"
|
|
)
|
|
|
|
def _maneuver_overrun(
|
|
self, state: CombatantState, target: CombatantState, m: _ManeuverRoll
|
|
) -> None:
|
|
if not m.success:
|
|
self._maneuver_fail(state, target, "overrun", m)
|
|
return
|
|
target.active_conditions.append(
|
|
ActiveCondition(
|
|
condition=CONDITIONS_BY_NAME["prone"],
|
|
rounds_left=1,
|
|
source="maneuver: overrun",
|
|
)
|
|
)
|
|
beyond = self._beyond_cell(state.pos, target.pos)
|
|
if self._cells_free(self._footprint_at(beyond, state), state):
|
|
state.pos = beyond
|
|
outcome = "SUCCESS (prone, moved through)"
|
|
else:
|
|
outcome = "SUCCESS (prone)"
|
|
self._log(
|
|
f"round {self._current_round} {state.combatant.id}: overrun vs "
|
|
f"{target.combatant.id} d20={m.roll}+{m.attacker_cmb}={m.total} "
|
|
f"CMD {m.target_cmd} -> {outcome}"
|
|
)
|
|
|
|
def _maneuver_dirty_trick(
|
|
self,
|
|
state: CombatantState,
|
|
target: CombatantState,
|
|
m: _ManeuverRoll,
|
|
*,
|
|
trick_condition: TrickCondition | None = None,
|
|
) -> None:
|
|
if not m.success:
|
|
self._maneuver_fail(state, target, "dirty_trick", m)
|
|
return
|
|
condition_name = trick_condition or "dazzled"
|
|
condition = CONDITIONS_BY_NAME[condition_name]
|
|
target.active_conditions.append(
|
|
ActiveCondition(
|
|
condition=condition,
|
|
rounds_left=1,
|
|
source="maneuver: dirty trick",
|
|
)
|
|
)
|
|
self._log(
|
|
f"round {self._current_round} {state.combatant.id}: dirty trick vs "
|
|
f"{target.combatant.id} d20={m.roll}+{m.attacker_cmb}={m.total} "
|
|
f"CMD {m.target_cmd} -> SUCCESS ({condition_name})"
|
|
)
|
|
|
|
def _maneuver_steal(
|
|
self, state: CombatantState, target: CombatantState, m: _ManeuverRoll
|
|
) -> None:
|
|
if not m.success:
|
|
self._maneuver_fail(state, target, "steal", m)
|
|
return
|
|
weapon = next((w for w in target.usable_attacks() if w.kind == "melee"), None)
|
|
if weapon is None:
|
|
outcome = "SUCCESS (nothing to steal)"
|
|
else:
|
|
target.weapon_stolen.add(weapon.id)
|
|
outcome = f"SUCCESS (stole {weapon.id})"
|
|
self._log(
|
|
f"round {self._current_round} {state.combatant.id}: steal vs "
|
|
f"{target.combatant.id} d20={m.roll}+{m.attacker_cmb}={m.total} "
|
|
f"CMD {m.target_cmd} -> {outcome}"
|
|
)
|
|
|
|
def _maneuver_reposition(
|
|
self,
|
|
state: CombatantState,
|
|
target: CombatantState,
|
|
m: _ManeuverRoll,
|
|
*,
|
|
direction: tuple[int, int] | None = None,
|
|
) -> None:
|
|
if not m.success:
|
|
self._maneuver_fail(state, target, "reposition", m)
|
|
return
|
|
new_pos: Pos | None = None
|
|
if direction is not None:
|
|
pos = (target.pos[0] + direction[0], target.pos[1] + direction[1])
|
|
if (
|
|
0 <= pos[0] < self._grid.height
|
|
and 0 <= pos[1] < self._grid.width
|
|
and self._cells_free(self._footprint_at(pos, target), target)
|
|
):
|
|
new_pos = pos
|
|
else:
|
|
for dr, dc in _NEIGHBOR_OFFSETS:
|
|
pos = (target.pos[0] + dr, target.pos[1] + dc)
|
|
if pos == state.pos:
|
|
continue
|
|
if not (0 <= pos[0] < self._grid.height and 0 <= pos[1] < self._grid.width):
|
|
continue
|
|
if self._cells_free(self._footprint_at(pos, target), target):
|
|
new_pos = pos
|
|
break
|
|
if new_pos is not None:
|
|
target.pos = new_pos
|
|
outcome = f"SUCCESS (moved to {new_pos})"
|
|
else:
|
|
outcome = "SUCCESS (no space)"
|
|
self._log(
|
|
f"round {self._current_round} {state.combatant.id}: reposition vs "
|
|
f"{target.combatant.id} d20={m.roll}+{m.attacker_cmb}={m.total} "
|
|
f"CMD {m.target_cmd} -> {outcome}"
|
|
)
|
|
|
|
def _maneuver_drag(
|
|
self,
|
|
state: CombatantState,
|
|
target: CombatantState,
|
|
m: _ManeuverRoll,
|
|
*,
|
|
direction: tuple[int, int] | None = None,
|
|
) -> None:
|
|
if not m.success:
|
|
self._maneuver_fail(state, target, "drag", m)
|
|
return
|
|
ar, ac = state.pos
|
|
tr, tc = target.pos
|
|
adjacent = max(abs(tr - ar), abs(tc - ac)) == 1
|
|
if direction is not None:
|
|
beyond = (tr + direction[0], tc + direction[1])
|
|
else:
|
|
beyond = self._beyond_cell(state.pos, target.pos)
|
|
if adjacent and self._cells_free(self._footprint_at(beyond, target), target):
|
|
target_old = target.pos
|
|
target.pos = beyond
|
|
state.pos = target_old
|
|
outcome = "SUCCESS (dragged)"
|
|
else:
|
|
outcome = "SUCCESS (no space)"
|
|
self._log(
|
|
f"round {self._current_round} {state.combatant.id}: drag vs "
|
|
f"{target.combatant.id} d20={m.roll}+{m.attacker_cmb}={m.total} "
|
|
f"CMD {m.target_cmd} -> {outcome}"
|
|
)
|
|
|
|
def _maneuver_escape(
|
|
self, state: CombatantState, target: CombatantState
|
|
) -> None:
|
|
escape_cmb = self._maneuver_cmb(state)
|
|
escape_skill = (
|
|
self._skill_static_bonus(state, "escape_artist")
|
|
if hasattr(state.combatant, "skills") and "escape_artist" in state.combatant.skills
|
|
else 0
|
|
)
|
|
escape_bonus = max(escape_cmb, escape_skill)
|
|
target_cmd = self._maneuver_cmd(target)
|
|
roll = self._rng.d20()
|
|
total = roll + escape_bonus
|
|
success = roll != 1 and total >= target_cmd
|
|
if success:
|
|
target.active_conditions = [
|
|
ac for ac in target.active_conditions if ac.condition.name != "grappled"
|
|
]
|
|
target.conditions = [c for c in target.conditions if c.name != "grappled"]
|
|
state.active_conditions = [
|
|
ac for ac in state.active_conditions if ac.condition.name != "grappled"
|
|
]
|
|
state.conditions = [c for c in state.conditions if c.name != "grappled"]
|
|
target.grappled_with = None
|
|
state.grappled_with = None
|
|
else:
|
|
self._log(
|
|
f"round {self._current_round} {state.combatant.id}: escape vs "
|
|
f"{target.combatant.id} d20={roll}+{escape_bonus}={total} "
|
|
f"CMD {target_cmd} -> FAILED"
|
|
)
|
|
|
|
def _execute_channel_energy(
|
|
self,
|
|
state: CombatantState,
|
|
target: CombatantState | None,
|
|
channel_mode: Literal["heal", "harm"],
|
|
) -> None:
|
|
"""Execute channel energy (positive energy, burst 30ft, 2d6).
|
|
|
|
Heal mode heals all living creatures in the burst; harm mode damages
|
|
all undead creatures in the burst — the target's side does not matter
|
|
(PF1e Channel Energy: positive energy heals living creatures and
|
|
damages undead creatures within 30 feet).
|
|
"""
|
|
if not state.spend_resource("channel_energy", 1):
|
|
self._log(
|
|
f"round {self._current_round} {state.combatant.id}: "
|
|
f"channel energy failed (no resources)"
|
|
)
|
|
return
|
|
if target is None or not target.active:
|
|
return
|
|
|
|
# Channel energy affects all creatures within 30ft (6 cells)
|
|
radius_cells = 30 // _SQUARE_FT # 6 cells
|
|
affected = self._creatures_in_range(state.pos, radius_cells)
|
|
|
|
if not affected:
|
|
self._log(
|
|
f"round {self._current_round} {state.combatant.id}: "
|
|
f"channel energy - no targets in range"
|
|
)
|
|
return
|
|
|
|
wanted_type = "living" if channel_mode == "heal" else "undead"
|
|
hit = [s for s in affected if s.combatant.creature_type == wanted_type]
|
|
|
|
# 2d6 damage/healing
|
|
dmg = 1 + self._rng.roll_die(11) # 2d6
|
|
|
|
for affected_state in hit:
|
|
if channel_mode == "heal":
|
|
self._channel_heal(state, affected_state, dmg)
|
|
else:
|
|
self._channel_damage(state, affected_state, dmg)
|
|
|
|
self._log(
|
|
f"round {self._current_round} {state.combatant.id}: "
|
|
f"channel energy ({channel_mode}) {dmg} -> {len(hit)} targets"
|
|
)
|
|
|
|
def _channel_heal(
|
|
self, state: CombatantState, affected_state: CombatantState, dmg: int
|
|
) -> None:
|
|
old_hp = affected_state.hp
|
|
affected_state.hp = min(affected_state.hp + dmg, affected_state.combatant.hp_max)
|
|
actual = affected_state.hp - old_hp
|
|
self._stats[state.combatant.id].damage_dealt += actual
|
|
self._log(
|
|
f"round {self._current_round} {state.combatant.id}: "
|
|
f"channel energy heals {affected_state.combatant.id} +{actual} HP"
|
|
)
|
|
|
|
def _channel_damage(
|
|
self, state: CombatantState, affected_state: CombatantState, dmg: int
|
|
) -> None:
|
|
hp_before = affected_state.hp
|
|
affected_state.hp -= dmg
|
|
self._stats[state.combatant.id].damage_dealt += dmg
|
|
self._stats[affected_state.combatant.id].damage_taken += dmg
|
|
self._log(
|
|
f"round {self._current_round} {state.combatant.id}: "
|
|
f"channel energy damages {affected_state.combatant.id} {dmg} "
|
|
f"({hp_before}->{affected_state.hp})"
|
|
)
|
|
if affected_state.dead:
|
|
self._log(
|
|
f"round {self._current_round} {state.combatant.id}: "
|
|
f"{affected_state.combatant.id} dead"
|
|
)
|
|
self._check_reactions(
|
|
ReactionEvent(
|
|
kind="damage_taken",
|
|
actor=state,
|
|
target=affected_state,
|
|
damage=dmg,
|
|
)
|
|
)
|
|
|
|
def _execute_hex(
|
|
self, state: CombatantState, target: CombatantState | None, hex_name: str | None
|
|
) -> None:
|
|
"""Execute a witch hex (evil_eye, slumber, cackle)."""
|
|
if not state.spend_resource("hexes", 1):
|
|
self._log(
|
|
f"round {self._current_round} {state.combatant.id}: hex failed (no resources)"
|
|
)
|
|
return
|
|
if target is None or not target.active:
|
|
return
|
|
if hex_name not in ("evil_eye", "slumber", "cackle"):
|
|
self._log(
|
|
f"round {self._current_round} {state.combatant.id}: unknown hex {hex_name}"
|
|
)
|
|
return
|
|
|
|
hex_dc = 10 + state.combatant.level // 2 + (state.combatant.abilities.int_score - 10) // 2
|
|
|
|
if hex_name == "evil_eye":
|
|
self._hex_evil_eye(state, target, hex_dc)
|
|
elif hex_name == "slumber":
|
|
self._hex_slumber(state, target, hex_dc)
|
|
elif hex_name == "cackle":
|
|
self._hex_cackle(state, target)
|
|
|
|
def _hex_evil_eye(
|
|
self, state: CombatantState, target: CombatantState, hex_dc: int
|
|
) -> None:
|
|
"""Evil Eye: shaken (-2 AC or saves), 1 round, Will negates."""
|
|
save_result = self.resolve_save(target, "will", hex_dc)
|
|
if save_result.success:
|
|
self._log(
|
|
f"round {self._current_round} {state.combatant.id}: evil eye on "
|
|
f"{target.combatant.id} -> SAVED"
|
|
)
|
|
return
|
|
target.active_conditions.append(
|
|
ActiveCondition(
|
|
condition=CONDITIONS_BY_NAME["shaken"],
|
|
rounds_left=1,
|
|
source=f"hex: evil_eye by {state.combatant.id}",
|
|
)
|
|
)
|
|
self._log(
|
|
f"round {self._current_round} {state.combatant.id}: evil eye on "
|
|
f"{target.combatant.id} -> shaken (1r)"
|
|
)
|
|
|
|
def _hex_slumber(
|
|
self, state: CombatantState, target: CombatantState, hex_dc: int
|
|
) -> None:
|
|
"""Slumber: asleep/disabled if Will fails."""
|
|
save_result = self.resolve_save(target, "will", hex_dc)
|
|
if save_result.success:
|
|
self._log(
|
|
f"round {self._current_round} {state.combatant.id}: slumber on "
|
|
f"{target.combatant.id} -> SAVED"
|
|
)
|
|
return
|
|
target.active_conditions.append(
|
|
ActiveCondition(
|
|
condition=CONDITIONS_BY_NAME["asleep"],
|
|
rounds_left=1,
|
|
source=f"hex: slumber by {state.combatant.id}",
|
|
)
|
|
)
|
|
self._log(
|
|
f"round {self._current_round} {state.combatant.id}: slumber on "
|
|
f"{target.combatant.id} -> asleep (1r)"
|
|
)
|
|
|
|
def _hex_cackle(self, state: CombatantState, target: CombatantState) -> None:
|
|
"""Cackle: prolong one hex by 1 round (extends all hex conditions)."""
|
|
extended = False
|
|
for ac in list(target.active_conditions):
|
|
if ac.source.startswith("hex:"):
|
|
target.active_conditions.remove(ac)
|
|
target.active_conditions.append(
|
|
ActiveCondition(
|
|
condition=ac.condition,
|
|
rounds_left=ac.rounds_left + 1,
|
|
source=ac.source,
|
|
)
|
|
)
|
|
extended = True
|
|
if extended:
|
|
self._log(
|
|
f"round {self._current_round} {state.combatant.id}: cackle extends "
|
|
f"hex on {target.combatant.id}"
|
|
)
|
|
else:
|
|
self._log(
|
|
f"round {self._current_round} {state.combatant.id}: cackle - no hex "
|
|
f"to extend on {target.combatant.id}"
|
|
)
|
|
|
|
def _execute_deed(
|
|
self, state: CombatantState, target: CombatantState | None, deed_name: str | None
|
|
) -> None:
|
|
"""Execute a gunslinger deed (up_close_and_deadly, quick_clear)."""
|
|
if deed_name not in ("up_close_and_deadly", "quick_clear"):
|
|
return
|
|
if deed_name == "up_close_and_deadly":
|
|
# Up Close and Deadly: +1d6 damage within 30ft
|
|
if target is None or not target.active:
|
|
return
|
|
dist_ft = self.creature_distance(state, target) * _SQUARE_FT
|
|
if dist_ft > _UP_CLOSE_RANGE_FT:
|
|
self._log(
|
|
f"round {self._current_round} {state.combatant.id}: deed Up Close "
|
|
f"and Deadly - target out of range"
|
|
)
|
|
return
|
|
if not state.spend_resource("grit", 1):
|
|
self._log(
|
|
f"round {self._current_round} {state.combatant.id}: deed failed (no grit)"
|
|
)
|
|
return
|
|
# Apply +1d6 damage to next attack this round
|
|
state.deed_up_close_and_deadly = True
|
|
self._log(
|
|
f"round {self._current_round} {state.combatant.id}: "
|
|
f"deed Up Close and Deadly activated (+1d6 dmg within 30ft)"
|
|
)
|
|
elif deed_name == "quick_clear":
|
|
# Quick Clear: swift action, clears misfire
|
|
if not state.spend_resource("grit", 1):
|
|
self._log(
|
|
f"round {self._current_round} {state.combatant.id}: deed failed (no grit)"
|
|
)
|
|
return
|
|
self._log(
|
|
f"round {self._current_round} {state.combatant.id}: "
|
|
f"deed Quick Clear - firearm cleared"
|
|
)
|
|
# In a full implementation, this would clear the misfire condition
|
|
|
|
def _execute_spell_combat(
|
|
self, state: CombatantState, target: CombatantState | None, spell_name: str | None
|
|
) -> None:
|
|
"""Execute Spell Combat (cast spell + full attack in same round)."""
|
|
if target is None or not target.active or spell_name is None:
|
|
return
|
|
if not state.spend_resource("arcane_pool", 1):
|
|
# Arcane pool not tracked yet, allow for now
|
|
pass
|
|
# Cast the spell
|
|
self._cast_spell(state, target, spell_name)
|
|
# Then make a full attack
|
|
self._execute_attack(state, target, "full_attack")
|
|
|
|
def _execute_inspire_courage(
|
|
self, state: CombatantState, target: CombatantState | None
|
|
) -> None:
|
|
"""Execute Inspire Courage (morale +1 attack/damage, 30ft radius, costs bardic_rounds)."""
|
|
if not state.spend_resource("bardic_rounds", 1):
|
|
self._log(
|
|
f"round {self._current_round} {state.combatant.id}: inspire courage "
|
|
f"failed (no bardic rounds)"
|
|
)
|
|
return
|
|
if target is None:
|
|
target = state
|
|
# Apply to all allies within 30ft (6 cells)
|
|
radius_cells = 30 // _SQUARE_FT
|
|
affected: list[CombatantState] = []
|
|
for state_other in self._states:
|
|
if not state_other.active:
|
|
continue
|
|
if state_other.side == state.side:
|
|
dist = self.creature_distance(state, state_other)
|
|
if dist <= radius_cells:
|
|
affected.append(state_other)
|
|
|
|
if not affected:
|
|
self._log(
|
|
f"round {self._current_round} {state.combatant.id}: inspire courage - "
|
|
f"no allies in range"
|
|
)
|
|
return
|
|
|
|
# Apply +1 morale bonus to attack and damage
|
|
for ally in affected:
|
|
ally.active_buffs.append(
|
|
ActiveBuff(
|
|
modifiers=(
|
|
StatModifier(target="attack", value=1, bonus_type="morale"),
|
|
StatModifier(target="damage", value=1, bonus_type="morale"),
|
|
),
|
|
rounds_left=1,
|
|
source=f"inspire courage by {state.combatant.id}",
|
|
)
|
|
)
|
|
self._log(
|
|
f"round {self._current_round} {state.combatant.id}: "
|
|
f"inspire courage +1 morale attack/damage to {len(affected)} allies"
|
|
)
|
|
def _execute_attack(
|
|
self, state: CombatantState, target: CombatantState | None, kind: str
|
|
) -> None:
|
|
if self.has_condition_flag(state, "cant_attack"):
|
|
self._log(f"round {self._current_round} {state.combatant.id}: wait (cannot attack)")
|
|
return
|
|
if target is None:
|
|
return
|
|
if kind == "attack":
|
|
weapon = self.weapon_for(state, target)
|
|
if weapon is None:
|
|
return
|
|
self._single_attack(state, target, weapon)
|
|
elif kind == "full_attack":
|
|
self._full_attack(state, target)
|
|
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
|
|
result = self.can_charge(state, target)
|
|
if result is None or result.weapon is None:
|
|
return
|
|
self._charge(state, target, result.weapon)
|
|
|
|
def _spell_dc(self, caster: CombatantState, spell: SpellSpec) -> int:
|
|
"""Spell save DC: 10 + spell level + 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
|
|
return 10 + spell.level + ability_mod
|
|
|
|
def _spell_snapshot(self, state: CombatantState, spell_name: str) -> dict[str, object]:
|
|
"""One per-spell dict for the Observation, enriched from the registry."""
|
|
spell = self._spell_registry.get(spell_name)
|
|
if spell is None:
|
|
return {
|
|
"name": spell_name,
|
|
"level": None,
|
|
"school": "",
|
|
"range_ft": 0,
|
|
"save": "none",
|
|
"dc": None,
|
|
"summary": "",
|
|
}
|
|
return {
|
|
"name": spell.name,
|
|
"level": spell.level,
|
|
"school": spell.school,
|
|
"range_ft": spell_range_ft(spell, state.combatant.level),
|
|
"save": spell.save,
|
|
"dc": self._spell_dc(state, spell),
|
|
"summary": _spell_summary(spell),
|
|
}
|
|
|
|
def _cast_spell(
|
|
self, caster: CombatantState, target: CombatantState | None, spell_name: str
|
|
) -> None:
|
|
spell = self._spell_registry.get(spell_name)
|
|
if spell is None:
|
|
self._log(
|
|
f"round {self._current_round} {caster.combatant.id}: "
|
|
f"cast {spell_name} -> UNKNOWN SPELL"
|
|
)
|
|
return
|
|
if target is None or not target.active:
|
|
return
|
|
dist_ft = self.creature_distance(caster, target) * _SQUARE_FT
|
|
max_range = spell_range_ft(spell, caster.combatant.level)
|
|
if dist_ft > max_range:
|
|
self._log(
|
|
f"round {self._current_round} {caster.combatant.id}: "
|
|
f"cast {spell_name} -> OUT OF RANGE"
|
|
)
|
|
return
|
|
if not has_line_of_effect_cells(
|
|
self._grid, caster.occupied_cells(), target.occupied_cells()
|
|
):
|
|
self._log(
|
|
f"round {self._current_round} {caster.combatant.id}: "
|
|
f"cast {spell_name} -> NO LINE OF EFFECT"
|
|
)
|
|
return
|
|
# Check for AoO from casting in threatened square (before resolving spell)
|
|
if self._check_casting_aoo(caster, spell.level):
|
|
return
|
|
caster.spend_spell_slot(spell.level)
|
|
dc = self._spell_dc(caster, spell)
|
|
save_result = (
|
|
self.resolve_save(
|
|
target, spell.save, dc, save_descriptors=spell_save_descriptors(spell)
|
|
)
|
|
if spell.save != "none"
|
|
else None
|
|
)
|
|
caster_live = self._stats[caster.combatant.id]
|
|
target_live = self._stats[target.combatant.id]
|
|
# Apply metamagic feats to spell effects
|
|
metamagic_feats = caster.combatant.metamagic_feats
|
|
metamagic_effects = self._apply_metamagic(spell, metamagic_feats)
|
|
hp_before = target.hp
|
|
parts = self._apply_spell_effects_from_list(
|
|
metamagic_effects, target, save_result, caster_live, target_live,
|
|
caster=caster, spell_name=spell.name,
|
|
empower="Empower Spell" in metamagic_feats,
|
|
maximize="Maximize Spell" in metamagic_feats,
|
|
dc=dc,
|
|
)
|
|
save_str = _save_label(save_result)
|
|
self._log(
|
|
f"round {self._current_round} {caster.combatant.id}: "
|
|
f"cast {spell_name} vs {target.combatant.id} "
|
|
f"DC{dc} {save_str} "
|
|
f"-> {'; '.join(parts)} "
|
|
f"({hp_before}->{target.hp})"
|
|
)
|
|
if target.dead:
|
|
self._log(
|
|
f"round {self._current_round} {caster.combatant.id}: "
|
|
f"{target.combatant.id} dead"
|
|
)
|
|
self._check_reactions(
|
|
ReactionEvent(kind="spell_cast", actor=caster, target=target)
|
|
)
|
|
|
|
def _apply_spell_effects(
|
|
self,
|
|
spell: SpellSpec,
|
|
target: CombatantState,
|
|
save_result: SaveResult | None,
|
|
caster_live: _LiveStats,
|
|
target_live: _LiveStats,
|
|
) -> list[str]:
|
|
"""Legacy wrapper for compatibility."""
|
|
return self._apply_spell_effects_from_list(
|
|
spell.effects, target, save_result, caster_live, target_live,
|
|
caster=target, spell_name=spell.name
|
|
)
|
|
|
|
def _apply_spell_effects_from_list(
|
|
self,
|
|
effects: tuple[SpellEffect, ...],
|
|
target: CombatantState,
|
|
save_result: SaveResult | None,
|
|
caster_live: _LiveStats,
|
|
target_live: _LiveStats,
|
|
*,
|
|
caster: CombatantState,
|
|
spell_name: str,
|
|
empower: bool = False,
|
|
maximize: bool = False,
|
|
dc: int = 15,
|
|
) -> list[str]:
|
|
saved = save_result is not None and save_result.success
|
|
return [
|
|
self._apply_single_spell_effect(
|
|
effect, target, caster_live, target_live,
|
|
caster=caster, spell_name=spell_name,
|
|
empower=empower, maximize=maximize, saved=saved,
|
|
dc=dc,
|
|
)
|
|
for effect in effects
|
|
]
|
|
|
|
def _apply_single_spell_effect(
|
|
self,
|
|
effect: SpellEffect,
|
|
target: CombatantState,
|
|
caster_live: _LiveStats,
|
|
target_live: _LiveStats,
|
|
caster: CombatantState,
|
|
*,
|
|
spell_name: str,
|
|
empower: bool = False,
|
|
maximize: bool = False,
|
|
saved: bool,
|
|
dc: int = 15,
|
|
) -> str:
|
|
if isinstance(effect, AreaEffect):
|
|
return self._apply_area_effect(
|
|
effect, target, caster_live, target_live,
|
|
caster=caster, spell_name=spell_name, saved=saved,
|
|
empower=empower, maximize=maximize, dc=dc,
|
|
)
|
|
if isinstance(effect, SummonEffect):
|
|
return self._apply_summon_effect(effect, caster, caster_live, spell_name)
|
|
if isinstance(effect, DamageEffect):
|
|
return self._apply_damage_effect(
|
|
effect, target, caster_live, target_live,
|
|
saved=saved, empower=empower, maximize=maximize, caster=caster,
|
|
)
|
|
if isinstance(effect, ConditionEffect):
|
|
return self._apply_condition_effect(effect, target, saved=saved, spell_name=spell_name)
|
|
if isinstance(effect, HealEffect):
|
|
return self._apply_heal_effect(effect, target, empower=empower, maximize=maximize)
|
|
return self._apply_buff_effect(effect, target, spell_name=spell_name)
|
|
|
|
def _apply_damage_effect(
|
|
self,
|
|
effect: DamageEffect,
|
|
target: CombatantState,
|
|
caster_live: _LiveStats,
|
|
target_live: _LiveStats,
|
|
*,
|
|
saved: bool,
|
|
empower: bool,
|
|
maximize: bool,
|
|
caster: CombatantState,
|
|
) -> str:
|
|
raw = effect.formula.roll(self._rng)
|
|
if maximize:
|
|
raw = effect.formula.max_value()
|
|
if saved and effect.half_on_save:
|
|
dmg = max(0, raw // 2)
|
|
elif saved:
|
|
dmg = 0
|
|
else:
|
|
dmg = raw
|
|
if empower:
|
|
dmg = int(dmg * 1.5)
|
|
target.hp -= dmg
|
|
caster_live.damage_dealt += dmg
|
|
target_live.damage_taken += dmg
|
|
self._check_reactions(
|
|
ReactionEvent(
|
|
kind="damage_taken", actor=caster, target=target, damage=dmg
|
|
)
|
|
)
|
|
return f"{dmg} damage"
|
|
|
|
def _apply_condition_effect(
|
|
self,
|
|
effect: ConditionEffect,
|
|
target: CombatantState,
|
|
*,
|
|
saved: bool,
|
|
spell_name: str,
|
|
) -> str:
|
|
if saved and effect.save_negates:
|
|
return f"{effect.condition_name} negated"
|
|
cond = CONDITIONS_BY_NAME.get(effect.condition_name)
|
|
if cond is None:
|
|
return ""
|
|
target.active_conditions.append(
|
|
ActiveCondition(
|
|
condition=cond,
|
|
rounds_left=effect.duration_rounds,
|
|
source=f"spell: {spell_name}",
|
|
)
|
|
)
|
|
return f"{effect.condition_name} ({effect.duration_rounds}r)"
|
|
|
|
def _apply_heal_effect(
|
|
self,
|
|
effect: HealEffect,
|
|
target: CombatantState,
|
|
*,
|
|
empower: bool,
|
|
maximize: bool,
|
|
) -> str:
|
|
heal = effect.formula.roll(self._rng)
|
|
if maximize:
|
|
heal = effect.formula.max_value()
|
|
if empower:
|
|
heal = int(heal * 1.5)
|
|
old_hp = target.hp
|
|
target.hp = min(target.hp + heal, target.combatant.hp_max)
|
|
actual = target.hp - old_hp
|
|
return f"+{actual} HP"
|
|
|
|
def _apply_buff_effect(
|
|
self, effect: BuffEffect, target: CombatantState, *, spell_name: str
|
|
) -> str:
|
|
target.active_buffs.append(
|
|
ActiveBuff(
|
|
modifiers=effect.modifiers,
|
|
rounds_left=effect.duration_rounds,
|
|
source=f"spell: {spell_name}",
|
|
)
|
|
)
|
|
return f"buff {len(effect.modifiers)} mods ({effect.duration_rounds}r)"
|
|
|
|
def _apply_area_effect(
|
|
self,
|
|
effect: AreaEffect,
|
|
target: CombatantState,
|
|
caster_live: _LiveStats,
|
|
target_live: _LiveStats,
|
|
*,
|
|
caster: CombatantState,
|
|
spell_name: str,
|
|
saved: bool,
|
|
empower: bool = False,
|
|
maximize: bool = False,
|
|
dc: int = 15,
|
|
) -> str:
|
|
"""Apply an area effect to all creatures within radius.
|
|
|
|
The target parameter is used as the center point for the burst.
|
|
"""
|
|
radius_cells = effect.radius_ft // _SQUARE_FT
|
|
center = target.pos
|
|
affected = self._creatures_in_range(center, radius_cells)
|
|
|
|
if not affected:
|
|
return "no targets in area"
|
|
|
|
total_damage = 0
|
|
total_conditions = 0
|
|
total_buffs = 0
|
|
|
|
for affected_state in affected:
|
|
creature_saved = self._area_save(
|
|
affected_state, target, effect, saved=saved, dc=dc
|
|
)
|
|
for sub_effect in effect.effects:
|
|
target_live = self._stats[affected_state.combatant.id]
|
|
result = self._apply_single_spell_effect(
|
|
sub_effect, affected_state, caster_live, target_live,
|
|
caster=caster, spell_name=spell_name, saved=creature_saved,
|
|
empower=empower, maximize=maximize,
|
|
)
|
|
if "damage" in result:
|
|
total_damage += int(result.split()[0])
|
|
elif "damage" not in result and result:
|
|
if "negated" not in result and "buff" not in result:
|
|
total_conditions += 1
|
|
elif "buff" in result:
|
|
total_buffs += 1
|
|
|
|
return (
|
|
f"area: {len(affected)} targets, {total_damage} total damage, "
|
|
f"{total_conditions} conditions, {total_buffs} buffs"
|
|
)
|
|
|
|
def _creatures_in_range(
|
|
self, center: tuple[int, int], radius_cells: int
|
|
) -> list[CombatantState]:
|
|
"""Return active creatures within radius_cells of center."""
|
|
affected: list[CombatantState] = []
|
|
for state in self._states:
|
|
if not state.active:
|
|
continue
|
|
dist = min(
|
|
self._grid.distance(center, cell) for cell in state.occupied_cells()
|
|
)
|
|
if dist <= radius_cells:
|
|
affected.append(state)
|
|
return affected
|
|
|
|
def _area_save(
|
|
self,
|
|
affected_state: CombatantState,
|
|
target: CombatantState,
|
|
effect: AreaEffect,
|
|
*,
|
|
saved: bool,
|
|
dc: int = 15,
|
|
) -> bool:
|
|
"""Save result for one creature in an area effect."""
|
|
if not effect.save_for_half:
|
|
return False
|
|
if affected_state is target:
|
|
return saved
|
|
save_result = self.resolve_save(
|
|
affected_state, "ref", dc, save_descriptors=save_descriptors_for_effects(effect.effects)
|
|
)
|
|
return save_result.success
|
|
|
|
def _apply_metamagic(
|
|
self, spell: SpellSpec, metamagic_feats: list[str]
|
|
) -> tuple[SpellEffect, ...]:
|
|
"""Apply metamagic feats to spell effects on the fly."""
|
|
if not metamagic_feats:
|
|
return spell.effects
|
|
has_extend = "Extend Spell" in metamagic_feats
|
|
return tuple(
|
|
self._metamagic_transform_sub(effect, has_extend=has_extend)
|
|
for effect in spell.effects
|
|
)
|
|
|
|
def _metamagic_transform_sub(
|
|
self, effect: SpellEffect, *, has_extend: bool
|
|
) -> SpellEffect:
|
|
"""Apply Extend Spell to one effect (recursing into AreaEffect sub-effects)."""
|
|
if isinstance(effect, (DamageEffect, HealEffect)):
|
|
return effect
|
|
if isinstance(effect, ConditionEffect):
|
|
duration = effect.duration_rounds * 2 if has_extend else effect.duration_rounds
|
|
return ConditionEffect(
|
|
condition_name=effect.condition_name,
|
|
duration_rounds=duration,
|
|
save_negates=effect.save_negates,
|
|
)
|
|
if isinstance(effect, BuffEffect):
|
|
duration = effect.duration_rounds * 2 if has_extend else effect.duration_rounds
|
|
return BuffEffect(modifiers=effect.modifiers, duration_rounds=duration)
|
|
if isinstance(effect, AreaEffect):
|
|
return AreaEffect(
|
|
radius_ft=effect.radius_ft,
|
|
effects=tuple(
|
|
self._metamagic_transform_sub(sub, has_extend=has_extend)
|
|
for sub in effect.effects
|
|
),
|
|
save_for_half=effect.save_for_half,
|
|
)
|
|
return effect
|
|
|
|
|
|
def _apply_summon_effect(
|
|
self,
|
|
effect: SummonEffect,
|
|
state: CombatantState,
|
|
_caster_live: _LiveStats,
|
|
_spell_name: str,
|
|
) -> str:
|
|
"""Apply a summoning effect - create a summoned creature."""
|
|
# Load the summoned creature
|
|
try:
|
|
summoned_combatant = load_combatant(Path(effect.creature))
|
|
except CombatantLoadError:
|
|
return f"summon failed: could not load {effect.creature}"
|
|
|
|
# Find a valid adjacent position for the summoned creature
|
|
center = state.pos
|
|
spawn_pos = None
|
|
for dr in (-1, 0, 1):
|
|
for dc in (-1, 0, 1):
|
|
if dr == 0 and dc == 0:
|
|
continue
|
|
pos = (center[0] + dr, center[1] + dc)
|
|
if self._cells_free(self._footprint_at(pos, state), state):
|
|
spawn_pos = pos
|
|
break
|
|
if spawn_pos:
|
|
break
|
|
|
|
if not spawn_pos:
|
|
return "summon failed: no space"
|
|
|
|
# Create summoned creature state
|
|
summoned_state = CombatantState(
|
|
combatant=summoned_combatant,
|
|
side=state.side,
|
|
pos=spawn_pos,
|
|
hp=summoned_combatant.hp_max,
|
|
)
|
|
|
|
# Insert into initiative order right after caster
|
|
caster_idx = next(i for i, s in enumerate(self._states) if s is state)
|
|
self._states.insert(caster_idx + 1, summoned_state)
|
|
|
|
# Set expiration
|
|
summoned_state.summon_duration = effect.duration_rounds
|
|
summoned_state.summon_caster_id = state.combatant.id
|
|
|
|
self._stats[summoned_state.combatant.id] = _LiveStats()
|
|
self._pending_summons.append(summoned_state)
|
|
|
|
self._log(
|
|
f"round {self._current_round} {state.combatant.id}: "
|
|
f"summon {summoned_combatant.id} at {spawn_pos} ({effect.duration_rounds}r)"
|
|
)
|
|
return f"summoned {summoned_combatant.id}"
|
|
|
|
def _resolve_swing(
|
|
self,
|
|
attacker: CombatantState,
|
|
target: CombatantState,
|
|
weapon: AttackSpec,
|
|
*,
|
|
label: str,
|
|
bonus_override: int | None = None,
|
|
) -> bool:
|
|
"""Resolve one swing, log it, update stats. Return True if target dropped."""
|
|
live = self._stats[attacker.combatant.id]
|
|
target_live = self._stats[target.combatant.id]
|
|
hp_before = target.hp
|
|
result = self.resolve_attack(attacker, target, weapon, bonus_override=bonus_override)
|
|
live.hits += int(result.hit)
|
|
live.crits += int(result.crit)
|
|
live.damage_dealt += result.damage
|
|
target_live.damage_taken += result.damage
|
|
outcome = "CRIT" if result.crit else "HIT" if result.hit else "MISS"
|
|
flank_str = f"+{result.flank_bonus}(flank)" if result.flank_bonus else ""
|
|
high_ground_str = (
|
|
f"+{result.high_ground_bonus}(high)" if result.high_ground_bonus else ""
|
|
)
|
|
bonus_str = (
|
|
f"{result.base_bonus}{result.penalty:+d}{flank_str}{high_ground_str}"
|
|
if result.penalty
|
|
else f"{result.base_bonus}{flank_str}{high_ground_str}"
|
|
)
|
|
line = (
|
|
f"round {self._current_round} {attacker.combatant.id}: {label} "
|
|
f"vs {target.combatant.id} "
|
|
f"d20={result.roll}+{bonus_str}={result.total} "
|
|
f"AC {result.ac} -> {outcome}"
|
|
)
|
|
if result.hit:
|
|
line += f" {result.damage} damage ({hp_before}->{target.hp})"
|
|
if self._is_paralyzing_touch(attacker, weapon):
|
|
duration = 1 + self._rng.roll_die(4)
|
|
if not self._has_condition_named(target, "paralyzed"):
|
|
target.active_conditions.append(
|
|
ActiveCondition(
|
|
condition=CONDITIONS_BY_NAME["paralyzed"],
|
|
rounds_left=duration,
|
|
source=f"monster ability: paralyzing touch by {attacker.combatant.id}",
|
|
)
|
|
)
|
|
line += f" paralyzed ({duration}r)"
|
|
self._log(line)
|
|
if target.dead:
|
|
self._log(
|
|
f"round {self._current_round} {attacker.combatant.id}: "
|
|
f"{target.combatant.id} dead"
|
|
)
|
|
return True
|
|
if not target.active:
|
|
self._log(
|
|
f"round {self._current_round} {attacker.combatant.id}: "
|
|
f"{target.combatant.id} down"
|
|
)
|
|
return True
|
|
self._check_reactions(
|
|
ReactionEvent(
|
|
kind="attack_resolved",
|
|
actor=attacker,
|
|
target=target,
|
|
damage=result.damage,
|
|
)
|
|
)
|
|
return False
|
|
|
|
def _attack(
|
|
self, attacker: CombatantState, target: CombatantState, weapon: AttackSpec
|
|
) -> None:
|
|
for swing in range(1, weapon.count + 1):
|
|
if not attacker.active:
|
|
return
|
|
if self._check_provoked_aoo(attacker, weapon):
|
|
return
|
|
label = weapon.name if weapon.count == 1 else f"{weapon.name} #{swing}"
|
|
if self._resolve_swing(attacker, target, weapon, label=label):
|
|
return
|
|
|
|
def _single_attack(
|
|
self, attacker: CombatantState, target: CombatantState, weapon: AttackSpec
|
|
) -> None:
|
|
if not attacker.active:
|
|
return
|
|
if self._check_provoked_aoo(attacker, weapon):
|
|
return
|
|
self._resolve_swing(attacker, target, weapon, label=weapon.name)
|
|
|
|
def _full_attack(
|
|
self, attacker: CombatantState, target: CombatantState
|
|
) -> None:
|
|
melee_weapons = [
|
|
w
|
|
for w in attacker.usable_attacks()
|
|
if w.kind == "melee" and self._melee_weapon_usable(attacker, target, w)
|
|
]
|
|
if not melee_weapons:
|
|
# No melee weapon reaches: fall back to weapon_for (ranged/touch),
|
|
# preserving the pre-L3 single-weapon behavior.
|
|
weapon = self.weapon_for(attacker, target)
|
|
if weapon is None:
|
|
return
|
|
self._full_attack_weapon(attacker, target, weapon)
|
|
return
|
|
for weapon in melee_weapons:
|
|
if not attacker.active or not target.active:
|
|
return
|
|
self._full_attack_weapon(attacker, target, weapon)
|
|
|
|
def _full_attack_weapon(
|
|
self, attacker: CombatantState, target: CombatantState, weapon: AttackSpec
|
|
) -> None:
|
|
"""Resolve one weapon's full-attack swings (count swings or BAB iteratives)."""
|
|
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 not target.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 _melee_weapon_usable(
|
|
self, attacker: CombatantState, target: CombatantState, weapon: AttackSpec
|
|
) -> bool:
|
|
"""True if ``weapon`` (a melee weapon) can reach ``target`` with clear LoE."""
|
|
if not has_line_of_effect_cells(
|
|
self._grid, attacker.occupied_cells(), target.occupied_cells()
|
|
):
|
|
return False
|
|
dist_ft = self.creature_distance(attacker, target) * _SQUARE_FT
|
|
effective_reach_ft = _effective_reach_ft(attacker, weapon)
|
|
if weapon.reach_ft > _SQUARE_FT:
|
|
return _SQUARE_FT < dist_ft <= effective_reach_ft
|
|
return dist_ft <= effective_reach_ft
|
|
|
|
def _step5(self, state: CombatantState, target: CombatantState) -> None:
|
|
if state.moved_this_turn:
|
|
return
|
|
if self.has_condition_flag(state, "cant_move"):
|
|
self._log(f"round {self._current_round} {state.combatant.id}: wait (cannot move)")
|
|
return
|
|
path = self._move_path(state, target)
|
|
start = state.pos
|
|
state.pos = path[0]
|
|
state.moved_this_turn = True
|
|
self._log(
|
|
f"round {self._current_round} {state.combatant.id}: "
|
|
f"5ft step ({start[0]},{start[1]})->({path[0][0]},{path[0][1]})"
|
|
)
|
|
|
|
def _move(self, state: CombatantState, target: CombatantState) -> None:
|
|
if state.moved_this_turn:
|
|
return
|
|
if self.has_condition_flag(state, "cant_move"):
|
|
self._log(f"round {self._current_round} {state.combatant.id}: wait (cannot move)")
|
|
return
|
|
path = self._move_path(state, target)
|
|
start = state.pos
|
|
actual_path: list[Pos] = []
|
|
for step in path:
|
|
if self._check_move_aoo(state):
|
|
break
|
|
if not state.active:
|
|
break
|
|
state.pos = step
|
|
actual_path.append(step)
|
|
self._check_reactions(ReactionEvent(kind="movement_step", actor=state))
|
|
if not state.active:
|
|
break
|
|
if actual_path:
|
|
state.moved_this_turn = True
|
|
coords = "->".join(f"({p[0]},{p[1]})" for p in (start, *actual_path))
|
|
self._log(f"round {self._current_round} {state.combatant.id}: move {coords}")
|
|
|
|
def _move_path(self, state: CombatantState, target: CombatantState) -> list[Pos]:
|
|
"""Full movement path toward the target, up to the combatant's speed.
|
|
|
|
Greedy walk of the anchor over the Dijkstra cost field from the target,
|
|
requiring the whole footprint to fit at each step.
|
|
"""
|
|
speed_mult = self._get_speed_mult(state)
|
|
speed_cells = int(state.combatant.speed_land_ft // _SQUARE_FT * speed_mult)
|
|
if speed_cells <= 0:
|
|
return []
|
|
blocked = self._blocked(state)
|
|
to_target = self._grid.reachable(target.pos, None, blocked)
|
|
if self._footprint_cost(state.occupied_cells(), to_target) is None:
|
|
return []
|
|
path: list[Pos] = []
|
|
current = state.pos
|
|
budget = speed_cells
|
|
while budget > 0:
|
|
best = self._best_move_step(state, current, to_target, budget)
|
|
if best is None:
|
|
break
|
|
_, step_cost, nxt = best
|
|
current = nxt
|
|
path.append(current)
|
|
budget -= step_cost
|
|
return path
|
|
|
|
def _best_move_step(
|
|
self,
|
|
state: CombatantState,
|
|
current: Pos,
|
|
to_target: dict[Pos, int],
|
|
budget: int,
|
|
) -> tuple[int, int, Pos] | None:
|
|
"""Pick the adjacent anchor that best descends the cost field toward the target."""
|
|
current_cost = self._footprint_cost(self._footprint_at(current, state), to_target)
|
|
if current_cost is None:
|
|
return None
|
|
best: tuple[int, int, Pos] | None = None
|
|
row, col = current
|
|
for d_row, d_col in _STEP_DELTAS:
|
|
nxt = (row + d_row, col + d_col)
|
|
footprint = self._footprint_at(nxt, state)
|
|
if not self._cells_free(footprint, state):
|
|
continue
|
|
nxt_cost = self._footprint_cost(footprint, to_target)
|
|
if nxt_cost is None or nxt_cost >= current_cost:
|
|
continue
|
|
step = self._grid.step_cost(current, nxt, 0)
|
|
if step > budget:
|
|
continue
|
|
score = step + nxt_cost
|
|
if best is None or score < best[0]:
|
|
best = (score, step, nxt)
|
|
return best
|
|
|
|
def _flee_path(self, state: CombatantState, threat: CombatantState) -> list[Pos]:
|
|
"""Movement path away from ``threat``, up to double speed.
|
|
|
|
Uses the Dijkstra cost field from the threat and greedily picks the
|
|
step that maximizes distance (cost) from the threat each cell.
|
|
"""
|
|
speed_mult = self._get_speed_mult(state)
|
|
max_cells = int(2 * (state.combatant.speed_land_ft // _SQUARE_FT) * speed_mult)
|
|
if max_cells <= 0:
|
|
return []
|
|
blocked = self._blocked(state)
|
|
from_threat = self._grid.reachable(threat.pos, None, blocked)
|
|
if self._footprint_cost(state.occupied_cells(), from_threat) is None:
|
|
return []
|
|
path: list[Pos] = []
|
|
current = state.pos
|
|
budget = max_cells
|
|
while budget > 0:
|
|
best = self._best_flee_step(state, current, from_threat, budget)
|
|
if best is None:
|
|
break
|
|
_, step_cost, nxt = best
|
|
current = nxt
|
|
path.append(current)
|
|
budget -= step_cost
|
|
return path
|
|
|
|
def _best_flee_step(
|
|
self,
|
|
state: CombatantState,
|
|
current: Pos,
|
|
from_threat: dict[Pos, int],
|
|
budget: int,
|
|
) -> tuple[int, int, Pos] | None:
|
|
"""Pick the adjacent anchor that maximizes distance from the threat."""
|
|
current_cost = self._footprint_cost(self._footprint_at(current, state), from_threat)
|
|
if current_cost is None:
|
|
return None
|
|
best: tuple[int, int, Pos] | None = None
|
|
row, col = current
|
|
for d_row, d_col in _STEP_DELTAS:
|
|
nxt = (row + d_row, col + d_col)
|
|
footprint = self._footprint_at(nxt, state)
|
|
if not self._cells_free(footprint, state):
|
|
continue
|
|
if not self._grid.diagonal_allowed(current, nxt):
|
|
continue
|
|
nxt_cost = self._footprint_cost(footprint, from_threat)
|
|
if nxt_cost is None or nxt_cost <= current_cost:
|
|
continue
|
|
step = self._grid.step_cost(current, nxt, 0)
|
|
if step > budget:
|
|
continue
|
|
score = -(nxt_cost * 100) + step
|
|
if best is None or score < best[0]:
|
|
best = (score, step, nxt)
|
|
return best
|
|
|
|
def _withdraw(self, state: CombatantState, threat: CombatantState | None) -> None:
|
|
"""Full-round action: move up to 2x speed away from the nearest enemy.
|
|
|
|
The starting square is not threatened — no AoO when leaving it.
|
|
Subsequent squares provoke AoOs normally (step-by-step, as for move).
|
|
"""
|
|
if threat is None:
|
|
return
|
|
path = self._flee_path(state, threat)
|
|
if not path:
|
|
return
|
|
start = state.pos
|
|
actual_path: list[Pos] = []
|
|
for i, step in enumerate(path):
|
|
if i > 0 and self._check_move_aoo(state):
|
|
break
|
|
if not state.active:
|
|
break
|
|
state.pos = step
|
|
actual_path.append(step)
|
|
self._check_reactions(ReactionEvent(kind="movement_step", actor=state))
|
|
if not state.active:
|
|
break
|
|
if actual_path:
|
|
state.moved_this_turn = True
|
|
coords = "->".join(f"({p[0]},{p[1]})" for p in (start, *actual_path))
|
|
self._log(
|
|
f"round {self._current_round} {state.combatant.id}: withdraw {coords}"
|
|
)
|
|
|
|
def _log(self, line: str) -> None:
|
|
self._transcript.append(line)
|
|
|
|
|
|
def _size_mod(self, state: CombatantState) -> int:
|
|
"""Size modifier for CMB/CMD: Medium=0, Small=-1, Large=1, etc."""
|
|
return _SIZE_MODIFIERS.get(state.combatant.size.lower(), 0)
|
|
|
|
def _bull_rush_push(self, attacker: CombatantState, target: CombatantState) -> bool:
|
|
"""Push target 5ft away from attacker. Returns True if push succeeded."""
|
|
# Calculate direction away from attacker
|
|
ar, ac = attacker.pos
|
|
tr, tc = target.pos
|
|
dr = tr - ar
|
|
dc = tc - ac
|
|
# Normalize to single step
|
|
if dr != 0:
|
|
dr = 1 if dr > 0 else -1
|
|
if dc != 0:
|
|
dc = 1 if dc > 0 else -1
|
|
new_pos = (tr + dr, tc + dc)
|
|
if not self._cells_free(self._footprint_at(new_pos, target), target):
|
|
return False
|
|
target.pos = new_pos
|
|
return True
|
|
|
|
def _beyond_cell(self, attacker_pos: Pos, target_pos: Pos) -> Pos:
|
|
"""The 1-cell position on the far side of the target from the attacker."""
|
|
ar, ac = attacker_pos
|
|
tr, tc = target_pos
|
|
dr = 1 if tr - ar > 0 else (-1 if tr - ar < 0 else 0)
|
|
dc = 1 if tc - ac > 0 else (-1 if tc - ac < 0 else 0)
|
|
return (tr + dr, tc + dc)
|
|
|
|
def _cell_free(self, pos: Pos, mover: CombatantState) -> bool:
|
|
"""True if pos is in bounds and not occupied by any active state but mover."""
|
|
if not (0 <= pos[0] < self._grid.height and 0 <= pos[1] < self._grid.width):
|
|
return False
|
|
return not any(s.pos == pos for s in self._states if s.active and s is not mover)
|
|
def _find_heal_spell(engine: CombatEngine, spells_known: list[str]) -> str | None:
|
|
"""Find a heal spell in known spells."""
|
|
for name in spells_known:
|
|
spell = engine.spell_registry.get(name)
|
|
if spell and any(isinstance(e, HealEffect) for e in spell.effects):
|
|
return name
|
|
return None
|
|
|
|
|
|
def _find_ally_needing_heal(engine: CombatEngine, caster: CombatantState) -> CombatantState | None:
|
|
"""Find an ally below 50% HP."""
|
|
for ally_state in engine.states:
|
|
below_half = ally_state.hp < ally_state.combatant.hp_max // 2
|
|
if ally_state.side == caster.side and ally_state.active and below_half:
|
|
return ally_state
|
|
return None
|
|
|
|
|
|
def _find_offensive_spell(
|
|
engine: CombatEngine, spells_known: list[str], caster: CombatantState, target: CombatantState
|
|
) -> str | None:
|
|
"""Find an offensive spell that can reach the target."""
|
|
dist_ft = engine.creature_distance(caster, target) * _SQUARE_FT
|
|
for name in spells_known:
|
|
spell = engine.spell_registry.get(name)
|
|
if not spell:
|
|
continue
|
|
# Check for damage effect
|
|
has_damage = any(isinstance(e, DamageEffect) for e in spell.effects)
|
|
if not has_damage:
|
|
continue
|
|
max_range = spell_range_ft(spell, caster.combatant.level)
|
|
if dist_ft <= max_range and has_line_of_effect_cells(
|
|
engine.grid, caster.occupied_cells(), target.occupied_cells()
|
|
):
|
|
return name
|
|
return None
|
|
|
|
|
|
def _find_buff_spell(engine: CombatEngine, spells_known: list[str]) -> str | None:
|
|
"""Find a buff spell."""
|
|
for name in spells_known:
|
|
spell = engine.spell_registry.get(name)
|
|
if spell and any(isinstance(e, BuffEffect) for e in spell.effects):
|
|
return name
|
|
return None
|
|
|
|
|
|
def _has_slot(engine: CombatEngine, caster: CombatantState, spell_name: str) -> bool:
|
|
"""True if the caster still has a spell slot of the given spell's level."""
|
|
spell = engine.spell_registry.get(spell_name)
|
|
return spell is not None and caster.spell_slots_left.get(spell.level, 0) > 0
|
|
|
|
|
|
def _choose_spell_action(
|
|
engine: CombatEngine, state: CombatantState, target: CombatantState
|
|
) -> tuple[Action, ...] | None:
|
|
"""Select and return a spell action if appropriate, else None."""
|
|
caster = state
|
|
spells_known = caster.combatant.spells
|
|
if not spells_known:
|
|
return None
|
|
|
|
# 1. Heal spell if ally below 50% HP
|
|
heal_spell = _find_heal_spell(engine, spells_known)
|
|
if heal_spell and _has_slot(engine, caster, heal_spell):
|
|
ally = _find_ally_needing_heal(engine, caster)
|
|
if ally and ally.hp < ally.combatant.hp_max // 2:
|
|
return (Action(kind="cast_spell", target_id=ally.combatant.id, spell_name=heal_spell),)
|
|
|
|
# 2. Offensive spell if enemy in range
|
|
offensive_spell = _find_offensive_spell(engine, spells_known, caster, target)
|
|
if offensive_spell and _has_slot(engine, caster, offensive_spell):
|
|
return (
|
|
Action(kind="cast_spell", target_id=target.combatant.id, spell_name=offensive_spell),
|
|
)
|
|
|
|
# 3. Buff spell
|
|
buff_spell = _find_buff_spell(engine, spells_known)
|
|
if buff_spell and _has_slot(engine, caster, buff_spell):
|
|
return (Action(kind="cast_spell", target_id=caster.combatant.id, spell_name=buff_spell),)
|
|
|
|
return None
|
|
|
|
|
|
_FULL_ROUND_KINDS: frozenset[str] = frozenset({"full_attack", "charge", "withdraw", "full_round"})
|
|
_STANDARD_KINDS: frozenset[str] = frozenset({
|
|
"attack", "cast_spell", "maneuver", "hex", "deed", "spell_combat", "channel_energy",
|
|
})
|
|
_SWIFT_BONUS_KINDS: frozenset[str] = frozenset({"deed", "inspire_courage", "swift"})
|
|
|
|
|
|
def _without_swift_bonus(actions: tuple[Action, ...]) -> tuple[Action, ...]:
|
|
"""Drop swift/free bonus actions (deed, inspire_courage, swift).
|
|
|
|
An immediate action consumes the combatant's next swift action; the
|
|
policy-returned sequence is filtered so no swift bonus fires that turn.
|
|
"""
|
|
return tuple(a for a in actions if a.kind not in _SWIFT_BONUS_KINDS)
|
|
|
|
|
|
def _staggered_filter(actions: tuple[Action, ...]) -> tuple[Action, ...]:
|
|
"""Staggered: one move OR one standard action per round, no full-round.
|
|
|
|
A leading ``full_attack`` (already in range) becomes a single standard
|
|
``attack``; charge/withdraw/full_round are dropped.
|
|
"""
|
|
for action in actions:
|
|
if action.kind == "full_attack":
|
|
return (Action(kind="attack", target_id=action.target_id),)
|
|
if action.kind in _FULL_ROUND_KINDS:
|
|
continue
|
|
return (action,)
|
|
return ()
|
|
|
|
|
|
def _nauseated_filter(actions: tuple[Action, ...]) -> tuple[Action, ...]:
|
|
"""Nauseated: single standard action only."""
|
|
for action in actions:
|
|
if action.kind in _STANDARD_KINDS:
|
|
return (action,)
|
|
return ()
|
|
|
|
|
|
Policy = Callable[[CombatEngine, CombatantState], tuple[Action, ...]]
|
|
|
|
LLMPolicy = Callable[[Observation, CombatantState], tuple[Action, ...]]
|
|
|
|
ReactionPolicy = Callable[
|
|
[CombatEngine, CombatantState, ReactionEvent], Action | None
|
|
]
|
|
|
|
|
|
def default_policy(engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
|
|
"""Full-attack the nearest enemy; cast spells; charge, 5ft-step, or approach if out of range.
|
|
|
|
Decision order:
|
|
1. Channel energy (oni) if resource available and an ally is hurt or enemies are grouped.
|
|
2. Hex (nairda) if resource available and the target is within close range.
|
|
3. Heal spell if an ally is below 50% HP and a heal spell is available.
|
|
4. Offensive spell if an enemy is in range and spell slots available.
|
|
5. Buff spell if no immediate threat and spell slots available.
|
|
6. Full-attack if a weapon is usable against the nearest enemy this turn (no move needed).
|
|
7. Charge if a straight-line charge path exists (2x speed, +2 attack, -2 AC).
|
|
8. 5-foot step then full-attack if a single step brings the target into weapon range.
|
|
9. Move toward the target then single attack (standard + move economy).
|
|
10. Wait if nothing is possible.
|
|
|
|
Class-ability bonus actions (deed for tammara, inspire_courage for misty) are
|
|
prepended to the normal action sequence whenever their resource is available.
|
|
|
|
Precedence for replacement (standard) abilities: channel_energy and hex are
|
|
evaluated first and replace the whole turn when their resource is available
|
|
and their condition is met; otherwise the normal spell/melee flow runs and the
|
|
swift/free abilities are combined with it. This keeps each class able to act
|
|
each turn while reserving standard-action class abilities for their triggers.
|
|
|
|
Never casts a spell if threatened without Concentration check (handled in _cast_spell).
|
|
"""
|
|
target = engine.nearest_enemy(state)
|
|
if target is None:
|
|
return (Action(kind="wait"),)
|
|
# Must flee: withdraw from the nearest enemy
|
|
if engine.has_condition_flag(state, "must_flee"):
|
|
return (Action(kind="withdraw", target_id=target.combatant.id),)
|
|
|
|
# Channel energy (oni): standard action that heal/harms in a 30ft burst.
|
|
channel_mode = _emit_channel_energy(engine, state)
|
|
if channel_mode is not None:
|
|
return (
|
|
Action(
|
|
kind="channel_energy",
|
|
target_id=target.combatant.id,
|
|
channel_mode=channel_mode,
|
|
),
|
|
)
|
|
|
|
# Hex (nairda): standard action when the target is within close range.
|
|
if _emit_hex(engine, state, target):
|
|
return (Action(kind="hex", target_id=target.combatant.id, hex_name="evil_eye"),)
|
|
|
|
normal = _normal_actions(engine, state, target)
|
|
|
|
# Misty spell_combat (main) + inspire_courage (swift): replaces the normal action.
|
|
spell_combat = _spell_combat_actions(engine, state, target)
|
|
if spell_combat is not None:
|
|
return spell_combat
|
|
|
|
# Deed (tammara) and inspire_courage (misty melee fallback): swift/free bonuses.
|
|
swift_bonus = _swift_bonus_actions(engine, state, target)
|
|
return (*swift_bonus, *normal) if swift_bonus else normal
|
|
|
|
|
|
def _normal_actions(
|
|
engine: CombatEngine, state: CombatantState, target: CombatantState
|
|
) -> tuple[Action, ...]:
|
|
"""The spell/melee action sequence (without swift/free class-ability add-ons)."""
|
|
if state.combatant.spells:
|
|
spell_action = _choose_spell_action(engine, state, target)
|
|
if spell_action is not None:
|
|
return spell_action
|
|
return _melee_action(engine, state, target)
|
|
|
|
|
|
_GROUPED_ENEMY_COUNT = 2
|
|
_HEX_RANGE_FT = 25
|
|
|
|
|
|
def _emit_channel_energy(
|
|
engine: CombatEngine, state: CombatantState
|
|
) -> Literal["heal", "harm"] | None:
|
|
"""Channel mode when oni should channel (heal living / harm undead)."""
|
|
if state.resources_left.get("channel_energy", 0) <= 0:
|
|
return None
|
|
if _find_ally_needing_heal(engine, state) is not None:
|
|
return "heal"
|
|
radius_cells = 30 // _SQUARE_FT
|
|
grouped_undead_enemies = [
|
|
s
|
|
for s in engine.states
|
|
if s.side != state.side
|
|
and s.active
|
|
and s.combatant.creature_type == "undead"
|
|
and engine.creature_distance(state, s) <= radius_cells
|
|
]
|
|
return "harm" if len(grouped_undead_enemies) >= _GROUPED_ENEMY_COUNT else None
|
|
|
|
|
|
def _emit_hex(
|
|
engine: CombatEngine, state: CombatantState, target: CombatantState | None
|
|
) -> bool:
|
|
"""True when nairda should hex: resource available and target within close range."""
|
|
if state.resources_left.get("hexes", 0) <= 0:
|
|
return False
|
|
if target is None or not target.active:
|
|
return False
|
|
return engine.creature_distance(state, target) * _SQUARE_FT <= _HEX_RANGE_FT
|
|
|
|
|
|
def _spell_combat_actions(
|
|
engine: CombatEngine, state: CombatantState, target: CombatantState
|
|
) -> tuple[Action, ...] | None:
|
|
"""Misty's spell_combat (main) + inspire_courage (swift), or None if no offensive spell."""
|
|
if state.resources_left.get("bardic_rounds", 0) <= 0:
|
|
return None
|
|
offensive = _find_offensive_spell(engine, state.combatant.spells, state, target)
|
|
if offensive is None:
|
|
return None
|
|
return (
|
|
Action(kind="inspire_courage", target_id=target.combatant.id),
|
|
Action(kind="spell_combat", target_id=target.combatant.id, spell_name=offensive),
|
|
)
|
|
|
|
|
|
def _swift_bonus_actions(
|
|
engine: CombatEngine, state: CombatantState, target: CombatantState
|
|
) -> tuple[Action, ...]:
|
|
"""Swift/free class-ability actions (deed, inspire_courage) to prepend to the turn."""
|
|
bonus: list[Action] = []
|
|
|
|
# Deed (tammara): swift/free action, emitted alongside the normal action only
|
|
# when the target is within Up Close and Deadly range (30 ft).
|
|
if (
|
|
state.resources_left.get("grit", 0) > 0
|
|
and engine.creature_distance(state, target) * _SQUARE_FT <= _UP_CLOSE_RANGE_FT
|
|
):
|
|
bonus.append(
|
|
Action(
|
|
kind="deed",
|
|
target_id=target.combatant.id,
|
|
deed_name="up_close_and_deadly",
|
|
)
|
|
)
|
|
|
|
# Inspire courage (misty melee fallback): combined with the normal action.
|
|
if state.resources_left.get("bardic_rounds", 0) > 0:
|
|
bonus.append(Action(kind="inspire_courage", target_id=target.combatant.id))
|
|
|
|
return tuple(bonus)
|
|
|
|
|
|
def _melee_action(
|
|
engine: CombatEngine, state: CombatantState, target: CombatantState
|
|
) -> tuple[Action, ...]:
|
|
"""Choose a non-spell action: full-attack, charge, step, or approach."""
|
|
if engine.weapon_for(state, target) is not None:
|
|
return (Action(kind="full_attack", target_id=target.combatant.id),)
|
|
charge = engine.can_charge(state, target)
|
|
if charge is not None and charge.weapon is not None:
|
|
return (Action(kind="charge", target_id=target.combatant.id),)
|
|
if engine.can_step5_to_attack(state, target):
|
|
return (
|
|
Action(kind="5foot_step", target_id=target.combatant.id),
|
|
Action(kind="full_attack", target_id=target.combatant.id),
|
|
)
|
|
return (
|
|
Action(kind="move", target_id=target.combatant.id),
|
|
Action(kind="attack", target_id=target.combatant.id),
|
|
)
|