7933 lines
341 KiB
Python
7933 lines
341 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 enforces the budget on it
|
|
(X4, `_action_economy_filter`): a cast_spell occupies the slot of its spell's
|
|
casting time (long casts are dropped before the budget with
|
|
`downgraded:cast_time`), surplus standards/moves/swifts and a full-round
|
|
action conflicting with an already-spent standard/move are
|
|
dropped in order (earliest legal prefix-subsequence kept) and reported as
|
|
`downgraded:action_economy`; a move action may convert a still-free standard
|
|
slot; the condition-specific filters then apply as
|
|
before. The engine executes the remaining actions 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. A readied action (``Action(kind="ready")``,
|
|
standard) stores a carrier-field payload that fires off-turn through the
|
|
same ``ReactionEvent`` machinery when its ``ready_trigger`` matches an
|
|
enemy event, then 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 base damage (physical dice + flat bonus)
|
|
by crit_mult; energy weapon-special-ability dice (flaming etc.) and
|
|
precision dice are NOT multiplied (CRB Critical Hits exception).
|
|
- DR applies once, after crit multiplication, to the PHYSICAL portion of the
|
|
damage only (bludgeoning/piercing/slashing dice, flat bonuses and precision
|
|
dice) — energy damage dealt along with an attack is never reduced by DR
|
|
(CRB "Damage Reduction"); a single bypass type defeats
|
|
DR; a multi-component "and" bypass (e.g. "bludgeoning and magic") requires
|
|
EVERY component (PF1e "DR X/Y and Z" notation).
|
|
- Death when negative hp reaches the Constitution score (hp <= -CON); PF1e
|
|
has no -10 rule. At exactly 0 hp a living creature is disabled: active,
|
|
staggered (one move OR standard action per round) at half speed, and a
|
|
non-movement action costs 1 hp at its end (hp -> -1, dying).
|
|
Optional rule (``monsters_die_at_zero``, CLI ``--simple-monster-death``):
|
|
monster combatants die at hp <= 0 instead — player characters are never
|
|
affected and the default (flag off) is the PF1e rule above, bit-identical.
|
|
- 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.
|
|
The speed budget is the best of land and fly speed (V4); flying over
|
|
obstacles or difficult terrain is NOT modeled (grid costs still apply).
|
|
- 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 (melee touch included) when an active
|
|
ally THREATENS the target from the opposite border or corner — the ally
|
|
must actually threaten the target's squares with a usable melee weapon
|
|
(a reach weapon's 5-ft dead zone does not threaten) and must not be
|
|
unable to act (stunned/paralyzed allies do not flank). 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. Moving out of more than one square threatened by the same
|
|
opponent in the same round counts as ONE opportunity for that opponent
|
|
(tracked in ``_move_aoo_provoked``, cleared at round start); the ranged,
|
|
casting, stand-up and maneuver triggers are separate and each still
|
|
provokes. AoOs always strike (PF1e allows declining, the simulator
|
|
does not) — except that an opponent with cover or total concealment
|
|
relative to the attacker cannot be struck at all (the opportunity is
|
|
suppressed, not spent; CRB "Cover"/"Concealment"). 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, Literal
|
|
|
|
from pf1e_simulator.areas import cone_cells, line_cells
|
|
from pf1e_simulator.conditions import CONDITIONS_BY_NAME, Condition
|
|
from pf1e_simulator.dice import DiceExpr, 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,
|
|
CastTime,
|
|
ConditionEffect,
|
|
DamageEffect,
|
|
HealEffect,
|
|
RecurringDamageSpec,
|
|
SaveType,
|
|
SpellEffect,
|
|
SummonEffect,
|
|
save_descriptors_for_effects,
|
|
spell_range_ft,
|
|
spell_save_descriptors,
|
|
)
|
|
|
|
if TYPE_CHECKING:
|
|
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"]
|
|
|
|
ReadyTrigger = Literal[
|
|
"enemy_enters_reach", "enemy_casts", "enemy_attacks", "enemy_hits_ally"
|
|
]
|
|
|
|
from pf1e_simulator.los import (
|
|
cover_bonus_cells,
|
|
has_line_of_effect_cells,
|
|
)
|
|
|
|
_SQUARE_FT = 5 # Phase 0 maps use 5-ft squares
|
|
_NATURAL_ONE = 1 # PF1e: natural 1 always misses
|
|
_NATURAL_TWENTY = 20 # PF1e: natural 20 always hits and threatens
|
|
_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
|
|
_FIRING_MELEE_PENALTY = -4 # PF1e: ranged attack into melee
|
|
_FIRING_MELEE_SAFE_FT = 10 # PF1e: 2 cells (10 ft) = the -4 penalty is avoided
|
|
_SNEAK_ATTACK_FEATURE = "Sneak Attack" # PF1e: rogue class feature (precision dice)
|
|
_PRECISE_SHOT_FEATURE = "Precise Shot" # PF1e: feat negating firing-into-melee penalty
|
|
_IMPROVED_CRITICAL_FEATURE = "Improved Critical" # PF1e: feat doubling the threat range
|
|
_CRIT_RANGE_BASE = 21 # threat range top is 21 - min natural die (20 -> 20-20)
|
|
_DYING_DC = 10 # PF1e: DC of the per-round stabilization Constitution check
|
|
_DELAY_INITIATIVE_STEP = 0.5 # PF1e: a delay slots 0.5 below its anchor total
|
|
_TOTAL_DEFENSE_AC_BONUS = 4 # PF1e: total defense grants +4 dodge AC
|
|
_FIGHT_DEFENSIVELY_AC_BONUS = 2 # PF1e: fighting defensively grants +2 dodge AC
|
|
_FIGHT_DEFENSIVELY_ATTACK_PENALTY = -4 # PF1e: and -4 on attacks / crit confirms
|
|
_SPELL_COMBAT_ATTACK_PENALTY = -2 # PF1e: magus Spell Combat melee attacks at -2
|
|
_TOTAL_CONCEALMENT_PCT = 50 # PF1e: a blinded attacker's targets have total concealment
|
|
# PF1e (CRB Damage Reduction): DR does not negate energy damage dealt along
|
|
# with an attack — only these weapon damage types ride the DR path.
|
|
_PHYSICAL_DAMAGE_TYPES = frozenset({"bludgeoning", "piercing", "slashing"})
|
|
# PF1e confused d% table: 01-25 act normally, 26-50 babble, 51-75 hit self,
|
|
# 76-100 attack the nearest creature
|
|
_CONFUSED_ACT_NORMAL_MAX = 25
|
|
_CONFUSED_BABBLE_MAX = 50
|
|
_CONFUSED_SELF_DAMAGE_MAX = 75
|
|
_INVISIBLE_ATTACK_BONUS = 2 # PF1e: +2 attack rolls for an invisible attacker
|
|
_HELPLESS_MELEE_BONUS = 4 # PF1e: +4 melee attack rolls (and crit confirms) vs helpless
|
|
# PF1e: a helpless creature (paralyzed, held, bound, sleeping, unconscious —
|
|
# stunned is NOT helpless) has an effective Dexterity score of 0, i.e. a -5
|
|
# modifier for a Medium creature (CRB, Helpless condition).
|
|
_DEX_ZERO_MOD = -5
|
|
_HELPLESS_CONDITION_NAMES: frozenset[str] = frozenset(
|
|
{"helpless", "paralyzed", "asleep", "unconscious"}
|
|
)
|
|
# PF1e (CRB, Disabled): at exactly 0 hp a creature takes only a single move
|
|
# or standard action (staggered, half speed); move-equivalent and free
|
|
# activities are not "strenuous" and cost no hp — everything else does.
|
|
# retrieve_weapon / repair_weapon are move actions (CRB: drawing or mending a
|
|
# weapon is a move action), so they belong here too.
|
|
_DISABLED_MOVE_KINDS: frozenset[str] = frozenset(
|
|
{
|
|
"move",
|
|
"5foot_step",
|
|
"stand_up",
|
|
"drop_prone",
|
|
"wait",
|
|
"retrieve_weapon",
|
|
"repair_weapon",
|
|
}
|
|
)
|
|
# PF1e (CRB, Combat Maneuver): attempting a combat maneuver provokes an
|
|
# attack of opportunity from the target unless the attacker holds the
|
|
# Improved feat for that maneuver (escape never provokes).
|
|
_IMPROVED_MANEUVER_FEATS: dict[str, str] = {
|
|
"trip": "Improved Trip",
|
|
"disarm": "Improved Disarm",
|
|
"bull_rush": "Improved Bull Rush",
|
|
"sunder": "Improved Sunder",
|
|
"grapple": "Improved Grapple",
|
|
"overrun": "Improved Overrun",
|
|
"dirty_trick": "Improved Dirty Trick",
|
|
"steal": "Improved Steal",
|
|
"reposition": "Improved Reposition",
|
|
"drag": "Improved Drag",
|
|
}
|
|
# PF1e (CRB, Metamagic Feats): a metamagic spell occupies a slot of its
|
|
# level + the feat's adjustment. B7: applied per cast, never blanket-on.
|
|
_METAMAGIC_SLOT_ADJUST: dict[str, int] = {
|
|
"Empower Spell": 2,
|
|
"Maximize Spell": 3,
|
|
"Extend Spell": 1,
|
|
}
|
|
_COMBAT_CASTING_FEAT = "Combat Casting" # PF1e: +4 concentration (defensive/grappled only)
|
|
# PF1e: skill checks that take Dexterity-based penalties (the "dex_skills"
|
|
# modifier target — grappled/pinned/blinded -4). Climb and Swim are the
|
|
# Strength skills the conformance adjudication groups with the Dex skills.
|
|
_DEX_BASED_SKILLS: frozenset[str] = frozenset(
|
|
{
|
|
"acrobatics", "climb", "disable_device", "escape_artist", "fly",
|
|
"ride", "sleight_of_hand", "stealth", "swim",
|
|
}
|
|
)
|
|
# PF1e (CRB): undead and constructs are immune to precision damage (sneak
|
|
# attack). Ooze/plant immunity is not modeled (the model has no such types).
|
|
_PRECISION_IMMUNE_TYPES: frozenset[str] = frozenset({"undead", "construct"})
|
|
_AID_ANOTHER_DC = 10 # PF1e: aid another is an attack roll against AC 10
|
|
_AID_ANOTHER_BONUS = 2 # PF1e: a successful aid grants +2 (next attack or AC)
|
|
_RUN_SPEED_FACTOR = 4 # PF1e: running is a full-round action at 4x speed
|
|
_OVERRUN_PRONE_MARGIN = 5 # PF1e: overrun knocks prone only on a 5+ CMB margin
|
|
_BULL_RUSH_EXTRA_PER_MARGIN = 5 # PF1e: +5 ft push per 5 points above CMD
|
|
_DIRTY_TRICK_EXTRA_ROUND_MARGIN = 5 # PF1e APG: +1 round per 5 points above CMD
|
|
_REPOSITION_EXTRA_PER_MARGIN = 5 # PF1e APG: +5 ft move per 5 points above CMD
|
|
_DRAG_EXTRA_PER_MARGIN = 5 # PF1e APG: +5 ft drag per 5 points above CMD
|
|
_TRIP_RETALIATION_MARGIN = 10 # PF1e: trip failing by 10+ knocks the attacker prone
|
|
_MANEUVER_MAX_SIZE_DIFF = 2 # PF1e: 2+ size categories larger = unmaneuverable
|
|
_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 (SRD Combat Maneuvers): creatures Tiny or smaller use their Dexterity
|
|
# modifier in place of their Strength modifier to determine their CMB.
|
|
_TINY_OR_SMALLER_SIZES: frozenset[str] = frozenset({"tiny", "diminutive", "fine"})
|
|
|
|
# PF1e size order, smallest first (SRD "Size").
|
|
_SIZE_ORDER: tuple[str, ...] = (
|
|
"fine", "diminutive", "tiny", "small", "medium", "large", "huge",
|
|
"gargantuan", "colossal",
|
|
)
|
|
|
|
# PF1e (SRD Trip / Bull Rush / Overrun; APG Reposition / Drag): "You can only
|
|
# [affect/reposition/drag] an opponent [who is] no more than one size category
|
|
# larger than you."
|
|
_SIZE_RESTRICTED_MANEUVERS: frozenset[str] = frozenset(
|
|
{"trip", "bull_rush", "overrun", "reposition", "drag"}
|
|
)
|
|
|
|
|
|
def _size_rank(size: str) -> int:
|
|
"""Index in the PF1e size order; unknown sizes rank as Medium."""
|
|
try:
|
|
return _SIZE_ORDER.index(size.lower())
|
|
except ValueError:
|
|
return _SIZE_ORDER.index("medium")
|
|
|
|
# PF1e natural reach for tall creatures (simplification: no long/quadruped
|
|
# distinction). Tiny, Diminutive and Fine have a natural reach of 0 ft (SRD
|
|
# "Big and Little Creatures in Combat"): "Creatures that take up less than 1
|
|
# square of space typically have a natural reach of 0 feet, meaning they can't
|
|
# reach into adjacent squares." Unknown sizes fall back to 5 ft (the historical
|
|
# behavior).
|
|
_SIZE_NATURAL_REACH_FT: dict[str, int] = {
|
|
"Fine": 0,
|
|
"Diminutive": 0,
|
|
"Tiny": 0,
|
|
"Small": 5,
|
|
"Medium": 5,
|
|
"Large": 10,
|
|
"Huge": 15,
|
|
"Gargantuan": 20,
|
|
"Colossal": 30,
|
|
}
|
|
|
|
# PF1e co-occupancy (SRD "Big and Little Creatures in Combat"): "Very small
|
|
# creatures take up less than 1 square of space. This means that more than one
|
|
# such creature can fit into a single square" and "They must enter an
|
|
# opponent's square to attack in melee" — a Tiny-or-smaller creature shares a
|
|
# square with a strictly larger one (and the larger one may move through and
|
|
# end in the smaller's square). Small and larger creatures occupy a full
|
|
# square and never share. The rank ceiling for "takes up less than 1 square".
|
|
_TINY_OR_SMALLER_MAX_RANK: int = _size_rank("Tiny")
|
|
|
|
|
|
def _can_cooccupy(size_a: str, size_b: str) -> bool:
|
|
"""True when two sizes may share a single grid square (PF1e co-occupancy).
|
|
|
|
Only a Tiny-or-smaller creature ("less than 1 square") and a STRICTLY
|
|
larger creature share; two same-size creatures never do.
|
|
"""
|
|
rank_a = _size_rank(size_a)
|
|
rank_b = _size_rank(size_b)
|
|
if rank_a == rank_b:
|
|
return False
|
|
return min(rank_a, rank_b) <= _TINY_OR_SMALLER_MAX_RANK
|
|
|
|
# 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),
|
|
)
|
|
|
|
|
|
# PF1e (SRD Combat Maneuver Defense): the AC bonus types a creature may add
|
|
# to its CMD. Penalties and other bonus types (armor, shield, natural,
|
|
# enhancement, competence, untyped) stay out of the CMD.
|
|
_CMD_AC_BONUS_TYPES: frozenset[str] = frozenset({
|
|
"circumstance", "deflection", "dodge", "insight", "luck", "morale",
|
|
"profane", "sacred",
|
|
})
|
|
|
|
|
|
def _save_label(result: SaveResult | None) -> str:
|
|
if result is None:
|
|
return "NO SAVE"
|
|
return "SAVED" if result.success else "FAILED"
|
|
|
|
|
|
def _burst_center(
|
|
target: CombatantState | None, center_pos: tuple[int, int] | None
|
|
) -> tuple[int, int] | None:
|
|
"""AoE center: the U7b point-targeting override wins over the target cell."""
|
|
if center_pos is not None:
|
|
return center_pos
|
|
if target is not None:
|
|
return target.pos
|
|
return None
|
|
|
|
|
|
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)
|
|
|
|
|
|
def _stat_modifier_dict(mod: StatModifier) -> dict[str, object]:
|
|
"""Serialize one StatModifier to a JSON-safe dict."""
|
|
return {
|
|
"target": mod.target,
|
|
"value": mod.value,
|
|
"bonus_type": mod.bonus_type,
|
|
"condition": mod.condition,
|
|
"weapon_filter": mod.weapon_filter,
|
|
}
|
|
|
|
|
|
def _condition_definition_dict(cond: Condition) -> dict[str, object]:
|
|
"""Serialize one Condition definition (mods + behavioral flags)."""
|
|
flags = [
|
|
name
|
|
for name, value in (
|
|
("denies_dex_to_ac", cond.denies_dex_to_ac),
|
|
("cant_act", cond.cant_act),
|
|
("cant_move", cond.cant_move),
|
|
("cant_attack", cond.cant_attack),
|
|
("cant_charge", cond.cant_charge),
|
|
("cant_aoo", cond.cant_aoo),
|
|
("must_flee", cond.must_flee),
|
|
)
|
|
if value
|
|
]
|
|
return {
|
|
"mods": [_stat_modifier_dict(m) for m in cond.modifiers],
|
|
"flags": flags,
|
|
"speed_mult": cond.speed_mult,
|
|
"miss_chance": cond.miss_chance,
|
|
}
|
|
|
|
|
|
def _spell_half_on_save(spell: SpellSpec) -> bool:
|
|
"""True if any effect grants half damage on a successful save."""
|
|
for effect in spell.effects:
|
|
if isinstance(effect, DamageEffect) and effect.half_on_save:
|
|
return True
|
|
if isinstance(effect, AreaEffect) and effect.save_for_half:
|
|
return True
|
|
return False
|
|
|
|
|
|
def _spell_declares_projectiles(spell: SpellSpec) -> bool:
|
|
"""True when a top-level damage effect carries a projectile-scaling spec.
|
|
|
|
Such spells (Magic Missile, Scorching Ray) resolve EVERY projectile's
|
|
attack roll inside ``_apply_damage_effect`` — even a single-ray Scorching
|
|
Ray — so the one-shot delivery-gate ranged touch attack is skipped for
|
|
them. Rays without a projectile spec (Acid Arrow, Ray of Frost) keep the
|
|
single delivery attack unchanged.
|
|
"""
|
|
return any(
|
|
isinstance(e, DamageEffect) and e.projectiles is not None
|
|
for e in spell.effects
|
|
)
|
|
|
|
|
|
def _spell_area_dict(spell: SpellSpec) -> dict[str, object] | None:
|
|
"""Structured area template of a spell, or None if it has no top-level AreaEffect.
|
|
|
|
Returns ``{"shape": str, "radius_ft": int}`` for the FIRST AreaEffect found
|
|
in ``spell.effects`` (a spell may declare several effects; only the first
|
|
area is exposed, and effects nested inside an AreaEffect are not scanned).
|
|
"""
|
|
for effect in spell.effects:
|
|
if isinstance(effect, AreaEffect):
|
|
return {"shape": effect.shape, "radius_ft": effect.radius_ft}
|
|
return None
|
|
|
|
|
|
_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:
|
|
"""Outer edge, in feet, of a melee attack's usable reach zone.
|
|
|
|
PF1e distinguishes two cases (CRB "Big and Little Creatures in Combat"):
|
|
a creature's NATURAL reach is a disk, while a reach weapon doubles that
|
|
natural reach but cannot strike at the wielder's natural reach or less
|
|
(a ring). See ``_melee_reach_zone_cells`` for the full zone.
|
|
"""
|
|
natural_ft = _SIZE_NATURAL_REACH_FT.get(state.combatant.size, 5)
|
|
if weapon.reach_weapon:
|
|
return 2 * natural_ft
|
|
return max(natural_ft, weapon.reach_ft)
|
|
|
|
|
|
def _melee_reach_zone_cells(state: CombatantState, weapon: AttackSpec) -> tuple[int, int]:
|
|
"""Inclusive grid-cell reach zone ``(inner, outer)`` of one melee attack.
|
|
|
|
PF1e, CRB "Big and Little Creatures in Combat": "Unlike when someone
|
|
uses a reach weapon, a creature with greater than normal natural reach
|
|
(more than 5 feet) still threatens squares adjacent to it" — natural
|
|
reach is a DISK, so the zone is ``0 .. max(natural, weapon.reach_ft)``
|
|
(a natural attack's ``reach_ft`` IS the creature's natural reach). A
|
|
reach weapon instead "can strike up to double their natural reach but
|
|
can't strike at their natural reach or less" — the zone is the RING
|
|
``natural+1 .. 2 * natural``, whose dead zone is the wielder's natural
|
|
reach.
|
|
"""
|
|
natural_cells = _SIZE_NATURAL_REACH_FT.get(state.combatant.size, 5) // _SQUARE_FT
|
|
inner_cells = natural_cells + 1 if weapon.reach_weapon else 0
|
|
outer_cells = _effective_reach_ft(state, weapon) // _SQUARE_FT
|
|
return inner_cells, outer_cells
|
|
|
|
|
|
def _speed_ft(state: CombatantState) -> int:
|
|
"""Movement budget in feet: the best of the combatant's land and fly speed.
|
|
|
|
Simplification (V4): a creature that has a fly speed is assumed to use it;
|
|
flying OVER obstacles or difficult terrain is NOT modeled — the grid's
|
|
passability and terrain costs still apply to the whole budget. No
|
|
fly maneuverability, no 3D flight. Swim/climb/burrow speeds do not exist
|
|
in the model (fly only).
|
|
"""
|
|
return max(state.combatant.speed_land_ft, state.combatant.speed_fly_ft or 0)
|
|
|
|
|
|
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
|
|
|
|
|
|
_PC_SOURCE_PREFIX = "fiches_personnages/"
|
|
|
|
|
|
def _is_pc_combatant(combatant: Combatant) -> bool:
|
|
"""True for player characters, False for monsters (X5 classification).
|
|
|
|
The unified loader records each combatant's origin in ``source``: every
|
|
PC JSON (``data/pcs/*.json``) points at its Foundry sheet under
|
|
``fiches_personnages/`` (README convention), while monster JSONs carry
|
|
bestiary/summon sources. ``side`` is a user-chosen faction name and
|
|
``cr``/``xp`` are absent from some monster files, so ``source`` is the
|
|
only robust per-combatant marker that survives loading without a schema
|
|
change.
|
|
"""
|
|
return combatant.source.startswith(_PC_SOURCE_PREFIX)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class RecurringDamage:
|
|
"""One active damage-over-time source on a combatant (S2c, Acid Arrow).
|
|
|
|
- ``formula``: dice rolled each recurring round (2d4)
|
|
- ``types``: energy types, reduced by resistance/immunity (never DR)
|
|
- ``rounds_left``: rounds this source still deals damage (decremented and
|
|
removed at the start of the owner's turn once it ticks)
|
|
- ``source``: human-readable origin for the transcript
|
|
"""
|
|
|
|
formula: DiceExpr
|
|
types: frozenset[str]
|
|
rounds_left: int
|
|
source: str
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class PendingCast:
|
|
"""A one-round cast begun this turn, to be resolved next turn.
|
|
|
|
PF1e (SRD "Casting Time"): "A spell that takes 1 round to cast is a
|
|
full-round action. It comes into effect just before the beginning of
|
|
your turn in the round after you began casting the spell. You then act
|
|
normally after the spell is completed." Everything the delivery still
|
|
needs is frozen at the start of the casting (the slot is already
|
|
spent): ``slot_level`` carries the metamagic-inflated level (B7) the
|
|
save DC uses, ``hp_at_cast`` is the caster's hp when the casting began
|
|
— the net hp lost before resolution is the interruption damage that
|
|
forces the concentration check (CRB "Concentration": injured while
|
|
casting, DC 10 + damage + spell level).
|
|
"""
|
|
|
|
spell_name: str
|
|
target_id: str | None
|
|
center_pos: tuple[int, int] | None
|
|
metamagic: tuple[str, ...]
|
|
slot_level: int
|
|
cast_defensively: bool
|
|
hp_at_cast: int
|
|
|
|
|
|
@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
|
|
# PF1e (SRD Immediate Actions): an immediate used ON your turn counts as
|
|
# your swift action for that turn; it also caps the round at one
|
|
# immediate. Reset at the start of your next turn (like moved_this_turn).
|
|
swift_used_this_turn: 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)
|
|
# Dying & stabilization (not in the frozen data model): the hp at which
|
|
# this combatant stabilized while dying; None = not stabilized.
|
|
stabilized_hp: int | None = None
|
|
# Simple monster death (X5, not in the frozen data model): set by the
|
|
# engine when ``monsters_die_at_zero`` is on, for monster combatants only
|
|
# — the combatant dies at hp <= 0 instead of the PF1e hp <= -CON rule.
|
|
dies_at_zero: bool = False
|
|
# Action feedback (not in the frozen data model): the ActionResults of the
|
|
# PREVIOUS turn, assigned at the end of each turn so the Observation built
|
|
# at the next policy invocation can expose them.
|
|
last_action_results: list[ActionResult] = field(default_factory=list)
|
|
# Delay action (not in the frozen data model): set when the combatant took
|
|
# the delay action this round; delay_before is the id of the combatant it
|
|
# chose to act just before (None = end of the round). Reset at round start.
|
|
delayed_this_round: bool = False
|
|
delay_before: str | None = None
|
|
# Ready action (not in the frozen data model): the concrete payload the
|
|
# combatant prepared with its standard action, and the trigger event that
|
|
# fires it. Cleared when the readied action executes (one-shot).
|
|
readied: Action | None = None
|
|
readied_trigger: ReadyTrigger | None = None
|
|
# Defensive actions (not in the frozen data model): the dodge AC bonus
|
|
# granted by total defense (+4) and fighting defensively (+2), and the
|
|
# fighting-defensively flag (-4 on attack and crit confirm). Both reset
|
|
# at the start of the combatant's NEXT turn, so they last through the
|
|
# opponents' turns in between.
|
|
defensive_ac_bonus: int = 0
|
|
fight_defensively: bool = False
|
|
# Aid another & run (not in the frozen data model): aid bonuses granted to
|
|
# this combatant by an ally's aid another — aid_attack_bonus is consumed by
|
|
# its next attack roll, aid_ac_bonus applies against the next attack that
|
|
# targets it. Both (and the running flag, which denies the Dex bonus to AC
|
|
# until the runner's next turn) reset at the start of the combatant's turn.
|
|
aid_attack_bonus: int = 0
|
|
aid_ac_bonus: int = 0
|
|
running: bool = False
|
|
# Bleeding (V5b, not in the frozen data model): damage taken at the start
|
|
# of each turn; any effect that restores hit points clears it.
|
|
bleed_damage: int = 0
|
|
# PF1e recurring spell damage (S2c, not in the frozen data model): each
|
|
# entry is one damage-over-time source (Acid Arrow's lingering acid) that
|
|
# deals its dice at the start of this combatant's turn for `rounds_left`
|
|
# rounds, reduced by energy resistance/immunity (never DR).
|
|
recurring_damage: list[RecurringDamage] = field(default_factory=list)
|
|
# PF1e one-round casting (SRD "Casting Time", not in the frozen data
|
|
# model): the spell begun on this combatant's turn, resolved just before
|
|
# its NEXT turn begins; None when no casting is in progress.
|
|
pending_cast: PendingCast | None = None
|
|
|
|
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:
|
|
"""PF1e: hp > 0 acts normally; hp == 0 is DISABLED (CRB, Effects of
|
|
Death: a living creature at exactly 0 hp can still take a single move
|
|
or standard action), so it remains active. hp < 0 is dying/dead.
|
|
Under the simple monster-death rule (``dies_at_zero``) hp <= 0 is
|
|
dead, so the disabled band never applies."""
|
|
return self.hp >= 0 and not self.dead
|
|
|
|
@property
|
|
def dead(self) -> bool:
|
|
return self.hp <= self.death_threshold
|
|
|
|
@property
|
|
def death_threshold(self) -> int:
|
|
"""PF1e (CRB): death when negative hp reaches the CON score (no -10 rule).
|
|
The optional simple monster-death rule raises the threshold to 0."""
|
|
if self.dies_at_zero:
|
|
return 0
|
|
return -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
|
|
melee_penalty: 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. ``ranged_threatened`` is a height x width boolean grid whose
|
|
cells are True when under enemy RANGED fire: reachable within a usable
|
|
ranged-style weapon (``ranged``, or ``touch`` with a range increment) of
|
|
an active enemy of the acting combatant, with a line of effect from that
|
|
enemy's occupied cells; bounded to the grid. PF1e ranged weapons do NOT
|
|
threaten for attacks of opportunity — this is a tactical field-of-fire
|
|
map, not an AoO threat, and it leaves ``threatened`` untouched.
|
|
``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``). ``initiative_order`` lists the combatant ids in the
|
|
current round's initiative order (``_build_round_order()``, read-only,
|
|
no RNG): the first entry acts next this round.
|
|
"""
|
|
|
|
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, ...], ...] = ()
|
|
ranged_threatened: tuple[tuple[bool, ...], ...] = ()
|
|
terrain_semantics: tuple[dict[str, object], ...] = ()
|
|
condition_definitions: tuple[tuple[str, dict[str, object]], ...] = ()
|
|
initiative_order: tuple[str, ...] = ()
|
|
|
|
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,
|
|
"ranged_threatened": self.ranged_threatened,
|
|
"terrain_semantics": self.terrain_semantics,
|
|
"condition_definitions": self.condition_definitions,
|
|
"initiative_order": self.initiative_order,
|
|
}
|
|
|
|
|
|
@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. ``delay`` is a standard action that forgoes the turn to act later
|
|
in the same round (end of order, or just before ``target_id``). The engine
|
|
executes a policy-returned sequence per turn. ``ready`` is a standard
|
|
action that prepares the carrier-field payload (spell / maneuver / attack
|
|
on ``target_id``) to fire off-turn when ``ready_trigger`` matches a
|
|
``ReactionEvent``; the readied action is then consumed.
|
|
``total_defense`` and ``fight_defensively`` are standard defensive actions
|
|
granting a temporary dodge AC bonus until the combatant's next turn;
|
|
``fight_defensively`` also takes -4 on its attacks. ``stand_up`` is a move
|
|
action ending the prone condition (it provokes an AoO from each enemy
|
|
threatening the combatant); ``drop_prone`` is a free action going prone
|
|
until the combatant stands up. ``move`` and ``5foot_step`` accept an
|
|
explicit ``destination`` cell ``(row, col)``: with one they walk to that
|
|
cell instead of auto-pathing toward ``target_id`` (the 5-foot step's
|
|
destination must be exactly one cell away). ``attack`` and ``full_attack``
|
|
accept ``weapon_id`` to strike with THAT weapon instead of the
|
|
auto-selected one; a choice that is unknown, broken, stolen, or unable to
|
|
hit the target (range / line of effect) is rejected
|
|
(``weapon_unavailable``).
|
|
``cast_spell`` accepts ``target_pos``: an explicit ``(row, col)`` cell to
|
|
center the spell's ``AreaEffect`` on instead of the target creature's
|
|
position (a point-targeted Fireball). It is meaningful only for spells
|
|
with a top-level ``AreaEffect`` — for every other effect the resolved
|
|
target is used exactly as before, and ``target_pos`` is ignored. A point
|
|
outside the map or without line of effect from the caster is rejected
|
|
(``invalid_destination``); with ``target_pos`` and no resolved target the
|
|
spell is cast on the point and every creature in the area rolls its own
|
|
save. ``aid_another`` is a standard action helping a resolved ally: the
|
|
aider makes an attack roll (d20 + its first usable melee weapon's bonus)
|
|
against AC 10; on success the ally gains +2 on its next attack roll
|
|
(``aid_mode`` "attack" or None) or +2 AC against the next attack
|
|
targeting it (``aid_mode`` "ac"). ``run`` is a full-round action moving
|
|
up to 4x speed toward ``target_id`` or an explicit ``destination`` (U5c),
|
|
reusing the move machinery (step-by-step, AoO-provoking); it sets
|
|
``running``, which lasts until the runner's next turn and denies the
|
|
runner its Dexterity bonus to AC.
|
|
``cast_spell`` accepts ``metamagic`` (B7): the metamagic feats to apply
|
|
to THIS cast — PF1e metamagic is chosen per cast, so only the feats
|
|
listed here AND known by the caster (in ``Combatant.metamagic_feats``)
|
|
apply; the spell then occupies a slot of base level + adjustment
|
|
(Empower +2, Maximize +3, Extend +1) and its save DC uses that slot
|
|
level. Without ``metamagic``, casting is unchanged. ``cast_spell`` also
|
|
accepts ``cast_defensively`` (B24): the caster forgoes the casting AoO
|
|
but must pass a concentration check (DC 15 + 2 x spell level, +4 with
|
|
Combat Casting) or the spell fizzles.
|
|
"""
|
|
|
|
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",
|
|
"activate_power_attack", "delay", "ready",
|
|
"total_defense", "fight_defensively", "stand_up", "drop_prone",
|
|
"aid_another", "run",
|
|
]
|
|
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 for attack / full_attack / retrieve_weapon / repair_weapon
|
|
weapon_id: str | None = None
|
|
ready_trigger: ReadyTrigger | None = None # ready action trigger event
|
|
destination: tuple[int, int] | None = None # explicit (row, col) cell for move / 5foot_step
|
|
target_pos: tuple[int, int] | None = None # explicit (row, col) AoE center for cast_spell
|
|
aid_mode: Literal["attack", "ac"] | None = None # aid another: bonus to grant (default attack)
|
|
# B7: metamagic feats to apply to THIS cast (only known ones apply)
|
|
metamagic: tuple[str, ...] = ()
|
|
# B24: cast defensively — no AoO, but a concentration check or the spell fizzles
|
|
cast_defensively: bool = False
|
|
|
|
|
|
# Normalized action-result reason vocabulary. U1 emits ok, unsupported_kind,
|
|
# missing_target and the downgraded:* reasons; no_weapon, out_of_range,
|
|
# no_line_of_effect are reused by U6b to explain, per pair, why an attack or
|
|
# a charge is unavailable. no_slot and insufficient_resource are declared
|
|
# here for the deeper execution-feedback package. U3 adds delayed /
|
|
# already_delayed for the delay action. U4 adds ready for the ready action.
|
|
# U5b adds not_prone / already_prone for the prone actions. U5c adds
|
|
# unreachable_destination for move / 5foot_step with an explicit destination.
|
|
# U5d adds weapon_unavailable for attack / full_attack with an explicit
|
|
# weapon_id that cannot be honoured. U7b adds invalid_destination for
|
|
# cast_spell with an explicit target_pos that cannot be honoured. U5e reuses
|
|
# no_weapon (aid another without a usable melee weapon) and
|
|
# unreachable_destination (run to an unhonourable destination). X4 adds
|
|
# downgraded:action_economy for actions the general per-turn budget filter
|
|
# drops from the policy sequence (second standard, second move when no
|
|
# standard is left to convert, second swift, full-round conflicting with
|
|
# standard+move). The cast-time package adds downgraded:cast_time for
|
|
# cast_spell actions whose spell has a long casting time (more than 1 round): the
|
|
# engine cannot schedule them within a turn, so they are dropped before the
|
|
# budget filter runs. S3a adds downgraded:surprise for actions the surprise
|
|
# round allowance (SRD "Surprise": one standard OR move plus free) drops from
|
|
# an aware combatant's sequence during the surprise round.
|
|
ACTION_RESULT_REASONS: frozenset[str] = frozenset({
|
|
"ok", "unsupported_kind", "missing_target", "no_weapon", "out_of_range",
|
|
"no_line_of_effect", "no_slot", "insufficient_resource",
|
|
"downgraded:staggered", "downgraded:nauseated",
|
|
"downgraded:action_economy", "downgraded:cast_time",
|
|
"downgraded:surprise",
|
|
"delayed", "already_delayed", "ready",
|
|
"not_prone", "already_prone", "unreachable_destination",
|
|
"weapon_unavailable", "invalid_destination",
|
|
})
|
|
_REASON_OK = "ok"
|
|
_REASON_UNSUPPORTED_KIND = "unsupported_kind"
|
|
_REASON_MISSING_TARGET = "missing_target"
|
|
_REASON_DOWNGRADED_STAGGERED = "downgraded:staggered"
|
|
_REASON_DOWNGRADED_NAUSEATED = "downgraded:nauseated"
|
|
_REASON_DOWNGRADED_IMMEDIATE_SWIFT = "downgraded:immediate_swift"
|
|
_REASON_DOWNGRADED_ACTION_ECONOMY = "downgraded:action_economy"
|
|
_REASON_DOWNGRADED_CAST_TIME = "downgraded:cast_time"
|
|
_REASON_DOWNGRADED_SURPRISE = "downgraded:surprise"
|
|
_REASON_DELAYED = "delayed"
|
|
_REASON_ALREADY_DELAYED = "already_delayed"
|
|
_REASON_READY = "ready"
|
|
_REASON_NOT_PRONE = "not_prone"
|
|
_REASON_ALREADY_PRONE = "already_prone"
|
|
_REASON_UNREACHABLE_DESTINATION = "unreachable_destination"
|
|
_REASON_WEAPON_UNAVAILABLE = "weapon_unavailable"
|
|
_REASON_INVALID_DESTINATION = "invalid_destination"
|
|
_REASON_NO_WEAPON = "no_weapon"
|
|
_REASON_OUT_OF_RANGE = "out_of_range"
|
|
_REASON_NO_LINE_OF_EFFECT = "no_line_of_effect"
|
|
|
|
# Action kinds that are declared but have no engine dispatch (U1 surfaces
|
|
# them as explicit rejections instead of silent no-ops).
|
|
_UNSUPPORTED_ACTION_KINDS: frozenset[str] = frozenset({"swift", "free", "full_round"})
|
|
# Action kinds whose helper is a silent no-op (or worse) without a resolved
|
|
# target; _execute rejects them up front with missing_target instead.
|
|
# move / 5foot_step / run are exempt when an explicit destination is provided
|
|
# (U5c / U5e).
|
|
_TARGET_REQUIRED_ACTION_KINDS: frozenset[str] = frozenset({
|
|
"attack", "full_attack", "charge", "withdraw", "move", "5foot_step",
|
|
"cast_spell", "maneuver", "aid_another", "run",
|
|
})
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ActionResult:
|
|
"""Explicit outcome of executing one Action (feedback for the policy).
|
|
|
|
``ok`` is True only when the action was dispatched to its helper;
|
|
rejections (``unsupported_kind``, ``missing_target``) and filter
|
|
downgrades (``downgraded:*``) carry ok=False and a reason drawn from
|
|
``ACTION_RESULT_REASONS``. ``detail`` is reserved for future packages.
|
|
"""
|
|
|
|
kind: str
|
|
ok: bool
|
|
reason: str = ""
|
|
detail: str = ""
|
|
|
|
|
|
@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,
|
|
monsters_die_at_zero: bool = False,
|
|
) -> 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._monsters_die_at_zero = monsters_die_at_zero
|
|
if monsters_die_at_zero:
|
|
for st in states:
|
|
self._apply_simple_monster_death(st)
|
|
self._in_reaction = False
|
|
# The combatant currently executing `_take_turn` (None between turns):
|
|
# lets an immediate action tell in-turn from off-turn use (SRD
|
|
# Immediate Actions).
|
|
self._turn_actor: CombatantState | None = None
|
|
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] = {}
|
|
# Movement-triggered opportunities already taken this round, keyed by
|
|
# mover id: PF1e counts leaving several squares threatened by the same
|
|
# opponent as ONE opportunity for that opponent.
|
|
self._move_aoo_provoked: dict[str, set[str]] = {}
|
|
self._initiative_keys: dict[str, tuple[float, int, int]] = {}
|
|
self._pending_summons: list[CombatantState] = []
|
|
# True while the surprise round runs (SRD "Surprise"): the turn filter
|
|
# then degrades every aware combatant's sequence to a single standard
|
|
# OR move action plus free actions.
|
|
self._surprise_round = False
|
|
|
|
@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 _apply_simple_monster_death(self, state: CombatantState) -> None:
|
|
"""Opt-in X5 rule: with ``monsters_die_at_zero``, monster combatants
|
|
die at hp <= 0 (no dying/disabled band); PCs are never marked."""
|
|
if self._monsters_die_at_zero and not _is_pc_combatant(state.combatant):
|
|
state.dies_at_zero = True
|
|
|
|
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)
|
|
ranged_threatened = self._ranged_threat_grid(state)
|
|
terrain_semantics = self._terrain_semantics()
|
|
condition_definitions = tuple(
|
|
(name, _condition_definition_dict(cond))
|
|
for name, cond in sorted(CONDITIONS_BY_NAME.items())
|
|
)
|
|
initiative_order = tuple(
|
|
s.combatant.id for s in self._build_round_order()
|
|
)
|
|
return Observation(
|
|
round=self._current_round,
|
|
acting_combatant=acting_index,
|
|
combatants=combatants,
|
|
pairs=pairs,
|
|
terrain=terrain,
|
|
elevation=elevation,
|
|
threatened=threatened,
|
|
ranged_threatened=ranged_threatened,
|
|
terrain_semantics=terrain_semantics,
|
|
condition_definitions=condition_definitions,
|
|
initiative_order=initiative_order,
|
|
)
|
|
|
|
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 _ranged_threat_grid(self, state: CombatantState) -> tuple[tuple[bool, ...], ...]:
|
|
"""Build the height x width bool grid of cells under enemy ranged fire.
|
|
|
|
Sibling of ``_threat_grid`` for RANGED weapons: the union over every
|
|
active enemy ``e`` of ``state`` (opposite side) of the cells each of
|
|
e's usable ranged-style weapons can reach (see
|
|
``_cell_under_ranged_fire``). Bounded to the grid. Read-only, no RNG.
|
|
PF1e ranged weapons grant no attacks of opportunity — this is a
|
|
tactical field-of-fire map, not an AoO threat; the melee
|
|
``threatened`` grid is unchanged by it.
|
|
"""
|
|
covered = self._ranged_covered_cells(state)
|
|
return tuple(
|
|
tuple((r, c) in covered for c in range(self._grid.width))
|
|
for r in range(self._grid.height)
|
|
)
|
|
|
|
def _ranged_covered_cells(self, state: CombatantState) -> set[Pos]:
|
|
"""Cells covered by any usable ranged-style weapon of state's enemies."""
|
|
covered: set[Pos] = set()
|
|
for enemy in self._states:
|
|
if enemy is state or not enemy.active or enemy.side == state.side:
|
|
continue
|
|
weapons = [w for w in enemy.usable_attacks() if self._is_ranged_attack(w)]
|
|
if not weapons:
|
|
continue
|
|
occupied = enemy.occupied_cells()
|
|
for r in range(self._grid.height):
|
|
for c in range(self._grid.width):
|
|
cell = (r, c)
|
|
if cell not in covered and self._cell_under_ranged_fire(
|
|
occupied, cell, weapons
|
|
):
|
|
covered.add(cell)
|
|
return covered
|
|
|
|
def _cell_under_ranged_fire(
|
|
self,
|
|
occupied: frozenset[Pos],
|
|
cell: Pos,
|
|
weapons: list[AttackSpec],
|
|
) -> bool:
|
|
"""True if some ranged weapon in ``weapons`` reaches ``cell`` with LoE.
|
|
|
|
Reach uses the same maximum-range rule as ``_weapon_usable_against``:
|
|
``range_increment_ft`` x 10 increments (x 5 for touch), measured from
|
|
the shooter's closest occupied cell under the 5-10-5 rule; the line of
|
|
effect runs from the shooter's footprint to the bare cell.
|
|
"""
|
|
dist_ft = (
|
|
min(self._grid.distance(from_cell, cell) for from_cell in occupied)
|
|
* _SQUARE_FT
|
|
)
|
|
for weapon in weapons:
|
|
increment = weapon.range_increment_ft
|
|
if increment is None:
|
|
continue
|
|
max_increments = (
|
|
_MAX_TOUCH_RANGE_INCREMENTS
|
|
if weapon.kind == "touch"
|
|
else _MAX_RANGE_INCREMENTS
|
|
)
|
|
if dist_ft > increment * max_increments:
|
|
continue
|
|
if has_line_of_effect_cells(self._grid, occupied, frozenset({cell})):
|
|
return True
|
|
return False
|
|
|
|
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,
|
|
"cover_bonus": cell.cover_bonus,
|
|
}
|
|
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
|
|
dr = None
|
|
if state.combatant.dr is not None:
|
|
dr = {
|
|
"amount": state.combatant.dr.amount,
|
|
"bypass": ",".join(sorted(state.combatant.dr.bypass)),
|
|
}
|
|
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,
|
|
"dying": state.hp < 0 and not state.dead,
|
|
"stabilized": state.stabilized_hp is not None,
|
|
"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,
|
|
"defensive_ac_bonus": state.defensive_ac_bonus,
|
|
"fight_defensively": state.fight_defensively,
|
|
"aid_attack_bonus": state.aid_attack_bonus,
|
|
"aid_ac_bonus": state.aid_ac_bonus,
|
|
"running": state.running,
|
|
"bleed_damage": state.bleed_damage,
|
|
"recurring_damage": [
|
|
{
|
|
"formula": dice_to_str(entry.formula),
|
|
"types": tuple(sorted(entry.types)),
|
|
"rounds_left": entry.rounds_left,
|
|
}
|
|
for entry in state.recurring_damage
|
|
],
|
|
"aoo_remaining": max(
|
|
0,
|
|
self._aoo_allowance(state) - self._aoo_used.get(state.combatant.id, 0),
|
|
),
|
|
"name": state.combatant.name,
|
|
"level": state.combatant.level,
|
|
"size": state.combatant.size,
|
|
"natural_reach_ft": _SIZE_NATURAL_REACH_FT.get(state.combatant.size, 5),
|
|
"aware": state.combatant.aware,
|
|
"creature_type": state.combatant.creature_type,
|
|
"bab": state.combatant.bab,
|
|
"initiative_mod": state.combatant.initiative_mod,
|
|
"initiative_total": self._initiative_keys.get(
|
|
state.combatant.id, (0, 0, -999)
|
|
)[0],
|
|
"casting_ability": (
|
|
state.combatant.casting_ability if state.combatant.spells else None
|
|
),
|
|
"dr": dr,
|
|
"abilities": {
|
|
"str": state.combatant.abilities.str_score,
|
|
"dex": state.combatant.abilities.dex_score,
|
|
"con": state.combatant.abilities.con_score,
|
|
"int": state.combatant.abilities.int_score,
|
|
"wis": state.combatant.abilities.wis_score,
|
|
"cha": state.combatant.abilities.cha_score,
|
|
},
|
|
"skills": {
|
|
name: {
|
|
"rank": rank.rank,
|
|
"class_skill": rank.class_skill,
|
|
"armor_penalty": rank.armor_penalty,
|
|
}
|
|
for name, rank in state.combatant.skills.items()
|
|
if rank.rank > 0
|
|
},
|
|
"metamagic_feats": tuple(sorted(state.combatant.metamagic_feats)),
|
|
"weapon_damage": dict(state.weapon_damage),
|
|
"summon_duration": state.summon_duration or None,
|
|
"immediate_used": state.immediate_used,
|
|
"deed_up_close_and_deadly": state.deed_up_close_and_deadly,
|
|
"active_buffs": tuple(
|
|
{
|
|
"source": buff.source,
|
|
"rounds_left": buff.rounds_left,
|
|
"mods": tuple(
|
|
{
|
|
"target": mod.target,
|
|
"value": mod.value,
|
|
"bonus_type": mod.bonus_type,
|
|
}
|
|
for mod in buff.modifiers
|
|
),
|
|
}
|
|
for buff in state.active_buffs
|
|
),
|
|
"speed_land_ft": state.combatant.speed_land_ft,
|
|
"speed_fly_ft": state.combatant.speed_fly_ft,
|
|
"speed_effective_ft": _speed_ft(state),
|
|
"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)),
|
|
"feature_details": tuple(
|
|
{
|
|
"name": feature.name,
|
|
"category": feature.category,
|
|
"source_detail": feature.source_detail,
|
|
"effects": tuple(
|
|
_stat_modifier_dict(mod) for mod in feature.effects
|
|
),
|
|
}
|
|
for feature in sorted(
|
|
state.combatant.features, key=lambda f: f.name
|
|
)
|
|
),
|
|
"spells": tuple(
|
|
self._spell_snapshot(state, name) for name in state.combatant.spells
|
|
),
|
|
"spell_resistance": state.combatant.spell_resistance,
|
|
"resistances": dict(state.combatant.resistances),
|
|
"immunities": tuple(state.combatant.immunities),
|
|
"sneak_dice": (
|
|
(state.combatant.level + 1) // 2
|
|
if self._has_feat(state, _SNEAK_ATTACK_FEATURE)
|
|
else 0
|
|
),
|
|
"engaged_in_melee": self._engaged_in_melee(state),
|
|
"last_turn_results": tuple(
|
|
{"kind": r.kind, "ok": r.ok, "reason": r.reason}
|
|
for r in state.last_action_results
|
|
),
|
|
"readied": (
|
|
{"kind": state.readied.kind, "trigger": state.readied_trigger}
|
|
if state.readied is not None
|
|
else None
|
|
),
|
|
"pending_cast": (
|
|
None
|
|
if state.pending_cast is None
|
|
else {
|
|
"spell_name": state.pending_cast.spell_name,
|
|
"target_id": state.pending_cast.target_id,
|
|
"slot_level": state.pending_cast.slot_level,
|
|
}
|
|
),
|
|
}
|
|
|
|
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()
|
|
line_of_effect = has_line_of_effect_cells(
|
|
self._grid, occupied, target.occupied_cells()
|
|
)
|
|
weapon_reason = self._weapon_failure_reason(
|
|
state, weapon, line_of_effect=line_of_effect
|
|
)
|
|
charge_reason: str | None = None
|
|
if not can_charge:
|
|
# can_charge() returns None only when no melee weapon is usable.
|
|
charge_reason = charge.reason if charge is not None else _REASON_NO_WEAPON
|
|
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)
|
|
cover_bonus = 0
|
|
if weapon is not None:
|
|
others = frozenset(
|
|
cell
|
|
for s in self._states
|
|
if s.active and s is not state and s is not target
|
|
for cell in s.occupied_cells()
|
|
)
|
|
cover_bonus = cover_bonus_cells(
|
|
self._grid,
|
|
state.occupied_cells(),
|
|
target.occupied_cells(),
|
|
ranged=self._is_ranged_attack(weapon),
|
|
occupied=others,
|
|
)
|
|
return {
|
|
"target_index": self._states.index(target),
|
|
"distance_ft": distance_ft,
|
|
"weapon_for": weapon_name,
|
|
"weapon_reason": weapon_reason,
|
|
"range_penalty": range_penalty,
|
|
"can_charge": can_charge,
|
|
"charge_reason": charge_reason,
|
|
"can_step5_to_attack": self.can_step5_to_attack(state, target),
|
|
"has_line_of_effect": line_of_effect,
|
|
"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),
|
|
"cover_bonus": cover_bonus,
|
|
}
|
|
|
|
def _weapon_failure_reason(
|
|
self,
|
|
attacker: CombatantState,
|
|
weapon: AttackSpec | None,
|
|
*,
|
|
line_of_effect: bool,
|
|
) -> str | None:
|
|
"""Why ``weapon_for`` found no weapon against a target, or None if it did.
|
|
|
|
U6b: the cause is drawn from the ``ACTION_RESULT_REASONS`` vocabulary
|
|
— no usable weapon at all, no line of effect, or every usable weapon
|
|
out of range — so a policy learns which obstacle to remove.
|
|
"""
|
|
if weapon is not None:
|
|
return None
|
|
if not attacker.usable_attacks():
|
|
return _REASON_NO_WEAPON
|
|
if not line_of_effect:
|
|
return _REASON_NO_LINE_OF_EFFECT
|
|
return _REASON_OUT_OF_RANGE
|
|
|
|
def _resolve_pending_cast(self, caster: CombatantState) -> None:
|
|
"""PF1e (SRD "Casting Time"): a one-round cast "comes into effect just
|
|
before the beginning of your turn in the round after you began casting
|
|
the spell. You then act normally after the spell is completed." The
|
|
caster is busy casting through the intervening round: net hp lost
|
|
between the start of the casting and this moment forces the
|
|
injured-while-casting concentration check (CRB "Concentration":
|
|
DC 10 + damage + spell level, no Combat Casting bonus) or the spell
|
|
is lost.
|
|
Bounded model (documented): interruption is measured as NET hp loss
|
|
at resolution instead of a hook at every damage site, so healing in
|
|
between can mask damage taken.
|
|
"""
|
|
pending = caster.pending_cast
|
|
if pending is None:
|
|
return
|
|
caster.pending_cast = None
|
|
spell = self._spell_registry.get(pending.spell_name)
|
|
if spell is None:
|
|
return
|
|
damage = max(0, pending.hp_at_cast - caster.hp)
|
|
if damage > 0 and not self._check_concentration(
|
|
caster, pending.slot_level, damage
|
|
):
|
|
dc = self._concentration_dc(caster, pending.slot_level, damage)
|
|
self._log(
|
|
f"round {self._current_round} {caster.combatant.id}: "
|
|
f"one-round cast of {pending.spell_name} interrupted: "
|
|
f"concentration check failed (DC {dc}), spell fizzles"
|
|
)
|
|
return
|
|
self._log(
|
|
f"round {self._current_round} {caster.combatant.id}: "
|
|
f"finishes casting {pending.spell_name}"
|
|
)
|
|
target = (
|
|
next(
|
|
(s for s in self._states if s.combatant.id == pending.target_id),
|
|
None,
|
|
)
|
|
if pending.target_id is not None
|
|
else None
|
|
)
|
|
self._deliver_cast(
|
|
caster, target, spell, pending.spell_name, pending.center_pos,
|
|
metamagic=pending.metamagic, slot_level=pending.slot_level,
|
|
)
|
|
|
|
def _forfeit_pending_cast(self, caster: CombatantState) -> None:
|
|
"""PF1e (SRD "Casting Time"): "you must continue the concentration
|
|
from the current round to just before your turn in the next round
|
|
[...] If you lose concentration before the casting is complete, you
|
|
lose the spell." A caster that reaches its initiative slot unable to
|
|
act (dead or dying) never gets to resolve the casting."""
|
|
pending = caster.pending_cast
|
|
if pending is None:
|
|
return
|
|
caster.pending_cast = None
|
|
self._log(
|
|
f"round {self._current_round} {caster.combatant.id}: "
|
|
f"one-round cast of {pending.spell_name} lost (cannot act)"
|
|
)
|
|
|
|
def _take_turn(self, state: CombatantState) -> bool:
|
|
"""Execute one combatant's full turn; return True if the battle is over."""
|
|
# A freshly-delayed combatant is re-inserted into the round order and
|
|
# ``_take_turn`` runs a second time in the SAME round. The start-of-turn
|
|
# block below (duration decrement, the bleed/confusion/cant_act gate,
|
|
# the fear aura and the per-turn flag resets) must run only ONCE per
|
|
# round, so it is skipped when ``delayed_this_round`` is already set at
|
|
# entry (it was cleared at round start and only a delay action sets it).
|
|
# The delayed turn still gets its policy call, budget and execution.
|
|
if not state.delayed_this_round:
|
|
self._expire_readied_action(state)
|
|
self._decrement_durations(state)
|
|
# SRD "Casting Time": the one-round cast "comes into effect just
|
|
# before the beginning of your turn" — resolved here, before the
|
|
# start-of-turn gate, then the turn proceeds normally.
|
|
self._resolve_pending_cast(state)
|
|
gate = self._turn_gate(state)
|
|
if gate is not None:
|
|
# A start-of-turn effect (bleed, confusion, cant_act) forfeited
|
|
# the turn; the value is the battle-over flag.
|
|
state.last_action_results = []
|
|
return gate
|
|
self._apply_fear_aura(state)
|
|
state.moved_this_turn = False
|
|
state.power_attack = False
|
|
state.defensive_ac_bonus = 0
|
|
state.fight_defensively = False
|
|
state.aid_attack_bonus = 0
|
|
state.aid_ac_bonus = 0
|
|
state.running = False
|
|
state.swift_used_this_turn = False
|
|
original = self._policy(self, state)
|
|
results: list[ActionResult] = []
|
|
actions = self._filter_turn_actions(state, original, results)
|
|
before = len(self._transcript)
|
|
was_delayed = state.delayed_this_round
|
|
disabled = self._is_disabled(state)
|
|
self._turn_actor = state
|
|
try:
|
|
for action in actions:
|
|
if not state.active:
|
|
break
|
|
if state.swift_used_this_turn and (
|
|
_effective_slot(action, self._cast_time_of) == "swift"
|
|
):
|
|
# PF1e (SRD Immediate Actions): the in-turn immediate
|
|
# already spent this turn's swift slot.
|
|
results.append(
|
|
ActionResult(
|
|
kind=action.kind,
|
|
ok=False,
|
|
reason=_REASON_DOWNGRADED_IMMEDIATE_SWIFT,
|
|
)
|
|
)
|
|
continue
|
|
results.append(self._execute(state, action))
|
|
if disabled and state.hp == 0 and action.kind not in _DISABLED_MOVE_KINDS:
|
|
# PF1e (CRB, Disabled): completing a standard (or otherwise
|
|
# strenuous) action costs 1 hp — the creature slips to -1 and
|
|
# is dying. Simplification: if the action itself healed the
|
|
# creature above 0 hp the cost is not applied.
|
|
state.hp -= 1
|
|
self._log(
|
|
f"round {self._current_round} {state.combatant.id}: "
|
|
f"disabled: strenuous action costs 1 hp (hp 0 -> -1, dying)"
|
|
)
|
|
if state.delayed_this_round and not was_delayed:
|
|
# A fresh delay forfeits the rest of the turn.
|
|
break
|
|
if self._winner_side() is not None:
|
|
break
|
|
finally:
|
|
self._turn_actor = None
|
|
if state.active and len(self._transcript) == before:
|
|
self._log(f"round {self._current_round} {state.combatant.id}: wait")
|
|
# Assigned at the END of the turn (never reset at the start): the
|
|
# Observation is built inside the policy call at the START of the next
|
|
# turn, so it must still see THIS turn's results at that moment.
|
|
state.last_action_results = results
|
|
return self._winner_side() is not None
|
|
|
|
def _cast_time_of(self, action: Action) -> CastTime:
|
|
"""Resolve a cast_spell action's casting time from the registry.
|
|
|
|
Unknown spell names (and non-cast actions) default to "standard".
|
|
Future hook (out of scope here): for a spontaneous caster this
|
|
closure would read state.combatant.spontaneous_caster and
|
|
action.metamagic — a spontaneous cast with a non-empty metamagic
|
|
feat and no Quickened Spell becomes full_round (unless the spell is
|
|
already one_round or long). The Callable[[Action], CastTime]
|
|
resolver signature already accommodates the state-aware variant.
|
|
"""
|
|
spell = self._spell_registry.get(action.spell_name or "")
|
|
return spell.cast_time if spell is not None else "standard"
|
|
|
|
def _filter_turn_actions(
|
|
self,
|
|
state: CombatantState,
|
|
actions: tuple[Action, ...],
|
|
results: list[ActionResult],
|
|
) -> tuple[Action, ...]:
|
|
"""Downgrade the policy's action sequence to the legal action economy.
|
|
|
|
Composition order: a cast-time pre-pass first drops every cast_spell
|
|
whose spell has a long casting time (more than 1 round — the engine cannot
|
|
schedule it within a turn; reported as downgraded:cast_time). Then
|
|
the general per-turn budget (CRB, "Actions in Combat": one standard
|
|
+ one move, or one full-round, plus one swift; a move action may be
|
|
taken instead of the standard) is enforced on every sequence, with
|
|
each cast_spell occupying the slot of its resolved casting time. The
|
|
condition-specific specializations then run on the budget-legal
|
|
remainder (their output is a single action, so the budget never
|
|
re-drops it). Swift consumed by an immediate action; PF1e (CRB,
|
|
Disabled): a creature at exactly 0 hp is staggered — one move OR one
|
|
standard action per round, no full-round actions (including
|
|
full-round and one-round casts and spell_combat); nauseated: single
|
|
move only. Dropped kinds are reported into ``results``.
|
|
"""
|
|
schedulable = tuple(
|
|
a
|
|
for a in actions
|
|
if not (a.kind == "cast_spell" and self._cast_time_of(a) == "long")
|
|
)
|
|
results.extend(
|
|
ActionResult(kind="cast_spell", ok=False, reason=_REASON_DOWNGRADED_CAST_TIME)
|
|
for _ in range(len(actions) - len(schedulable))
|
|
)
|
|
actions = schedulable
|
|
# PF1e (SRD "Move Actions"): "If you have a base attack bonus of +1 or
|
|
# higher, you may draw a weapon as a free action combined with a
|
|
# regular move." The draw rides the turn's move action instead of
|
|
# consuming the move slot.
|
|
free_draw = state.combatant.bab >= 1 and any(
|
|
a.kind == "move" for a in actions
|
|
)
|
|
kept = _action_economy_filter(
|
|
actions, cast_time=self._cast_time_of, free_draw=free_draw
|
|
)
|
|
results.extend(
|
|
ActionResult(
|
|
kind=kind, ok=False, reason=_REASON_DOWNGRADED_ACTION_ECONOMY
|
|
)
|
|
for kind in _dropped_kinds_multiset(actions, kept)
|
|
)
|
|
actions = kept
|
|
if self._surprise_round:
|
|
# PF1e (SRD "Surprise"): the surprise round allowance replaces the
|
|
# normal budget — one standard OR move action plus free actions.
|
|
kept = _surprise_filter(
|
|
actions, cast_time=self._cast_time_of, free_draw=free_draw
|
|
)
|
|
results.extend(
|
|
ActionResult(kind=kind, ok=False, reason=_REASON_DOWNGRADED_SURPRISE)
|
|
for kind in _dropped_kinds(actions, kept)
|
|
)
|
|
return kept
|
|
if state.immediate_used:
|
|
kept = _without_swift_bonus(actions, cast_time=self._cast_time_of)
|
|
results.extend(
|
|
ActionResult(kind=kind, ok=False, reason=_REASON_DOWNGRADED_IMMEDIATE_SWIFT)
|
|
for kind in _dropped_kinds(actions, kept)
|
|
)
|
|
actions = kept
|
|
state.immediate_used = False
|
|
if self._is_staggered_limited(state):
|
|
kept = _staggered_filter(
|
|
actions, cast_time=self._cast_time_of, free_draw=free_draw
|
|
)
|
|
results.extend(
|
|
ActionResult(kind=kind, ok=False, reason=_REASON_DOWNGRADED_STAGGERED)
|
|
for kind in _dropped_kinds(actions, kept)
|
|
)
|
|
return kept
|
|
if self.has_condition_flag(state, "cant_attack"):
|
|
kept = _nauseated_filter(actions)
|
|
results.extend(
|
|
ActionResult(kind=kind, ok=False, reason=_REASON_DOWNGRADED_NAUSEATED)
|
|
for kind in _dropped_kinds(actions, kept)
|
|
)
|
|
return kept
|
|
return actions
|
|
|
|
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 _is_disabled(self, state: CombatantState) -> bool:
|
|
"""PF1e (CRB, Effects of Death): a living creature at exactly 0 hp is
|
|
disabled — staggered, half speed, and a strenuous action costs 1 hp."""
|
|
return state.hp == 0
|
|
|
|
def _is_staggered_limited(self, state: CombatantState) -> bool:
|
|
"""True when the creature is limited to one move OR one standard
|
|
action per round (CRB "Conditions — Staggered"; a disabled creature
|
|
at 0 hp is staggered). Such a creature cannot take full-round actions
|
|
— except Restricted Withdraw (SRD "Full-Round Actions"), which it may
|
|
take as a standard action moving up to its speed."""
|
|
return self._is_disabled(state) or self._has_condition_named(
|
|
state, "staggered"
|
|
)
|
|
|
|
def _is_helpless(self, state: CombatantState) -> bool:
|
|
"""PF1e (CRB): helpless = paralyzed, held, bound, sleeping, unconscious.
|
|
|
|
Stunned is NOT helpless. A helpless creature has effective Dexterity 0
|
|
and melee attackers gain +4 (resolve_attack)."""
|
|
return any(
|
|
self._has_condition_named(state, name)
|
|
for name in _HELPLESS_CONDITION_NAMES
|
|
)
|
|
|
|
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.
|
|
|
|
PF1e (CRB, Disabled): a disabled creature (exactly 0 hp) moves at
|
|
half speed.
|
|
"""
|
|
speed_mult = 0.5 if state.hp == 0 else 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.
|
|
|
|
A bleeding condition's last round ends with its bleed tick: the DoT
|
|
lives on ``bleed_damage`` (CRB Conditions — bleeding), so the final
|
|
damage is applied as the last condition instance expires and the
|
|
field is cleared (mirrors ``_stop_bleeding`` on healing).
|
|
"""
|
|
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
|
|
]
|
|
kept_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
|
|
]
|
|
bleeding_expired = (
|
|
any(ac.condition.name == "bleeding" for ac in state.active_conditions)
|
|
and not any(ac.condition.name == "bleeding" for ac in kept_conditions)
|
|
)
|
|
state.active_conditions[:] = kept_conditions
|
|
if bleeding_expired and state.bleed_damage > 0:
|
|
self._apply_bleed_damage(state)
|
|
state.bleed_damage = 0
|
|
|
|
def _turn_gate(self, state: CombatantState) -> bool | None:
|
|
"""Start-of-turn effects: bleed tick, recurring spell damage, confused
|
|
d% roll, cant_act flag.
|
|
|
|
Returns None when the turn proceeds to the policy; otherwise the turn
|
|
is forfeited and the value is the battle-over flag.
|
|
"""
|
|
self._apply_bleed_damage(state)
|
|
self._apply_recurring_damage(state)
|
|
if not state.active:
|
|
# Bleeding / acid knocked the combatant out (dying or dead); the
|
|
# winner check covers the death case.
|
|
return self._winner_side() is not None
|
|
confused_over = self._apply_confusion(state)
|
|
if confused_over is not None:
|
|
return confused_over
|
|
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
|
|
return None
|
|
|
|
def _apply_bleed_damage(self, state: CombatantState) -> None:
|
|
"""PF1e bleeding: take ``bleed_damage`` at the beginning of the turn (CRB)."""
|
|
if state.bleed_damage <= 0:
|
|
return
|
|
old_hp = state.hp
|
|
state.hp -= state.bleed_damage
|
|
self._log(
|
|
f"round {self._current_round} {state.combatant.id}: "
|
|
f"bleed {state.bleed_damage} (hp {old_hp}->{state.hp})"
|
|
)
|
|
|
|
def _apply_recurring_damage(self, state: CombatantState) -> None:
|
|
"""PF1e recurring spell damage (S2c, Acid Arrow): at the start of the
|
|
owner's turn each active source rolls its dice, is reduced by energy
|
|
resistance/immunity (never DR — it is spell damage), deals that damage,
|
|
then loses one round; sources that reach 0 rounds are cleared."""
|
|
if not state.recurring_damage:
|
|
return
|
|
remaining: list[RecurringDamage] = []
|
|
for entry in state.recurring_damage:
|
|
raw = entry.formula.roll(self._rng)
|
|
dmg = self._apply_energy_resistance(raw, entry.types, state)
|
|
old_hp = state.hp
|
|
state.hp -= dmg
|
|
self._log(
|
|
f"round {self._current_round} {state.combatant.id}: "
|
|
f"{entry.source} {dmg} (hp {old_hp}->{state.hp})"
|
|
)
|
|
if entry.rounds_left > 1:
|
|
remaining.append(
|
|
RecurringDamage(
|
|
formula=entry.formula,
|
|
types=entry.types,
|
|
rounds_left=entry.rounds_left - 1,
|
|
source=entry.source,
|
|
)
|
|
)
|
|
state.recurring_damage[:] = remaining
|
|
|
|
def _apply_confusion(self, state: CombatantState) -> bool | None:
|
|
"""PF1e confused: roll d% at the start of the turn to act randomly (CRB).
|
|
|
|
Returns None when the subject acts normally (01-25, the turn proceeds);
|
|
otherwise the turn is consumed and the value is the battle-over flag.
|
|
Simplification: the "if attacked, attack the attacker" clause is
|
|
reduced to attacking the nearest creature.
|
|
"""
|
|
if not self._has_condition_named(state, "confused"):
|
|
return None
|
|
pct = self._rng.d100()
|
|
if pct <= _CONFUSED_ACT_NORMAL_MAX:
|
|
return None
|
|
if pct <= _CONFUSED_BABBLE_MAX:
|
|
self._log(
|
|
f"round {self._current_round} {state.combatant.id}: confused: babbles"
|
|
)
|
|
elif pct <= _CONFUSED_SELF_DAMAGE_MAX:
|
|
self._confused_self_damage(state)
|
|
else:
|
|
self._confused_attack_nearest(state)
|
|
return self._winner_side() is not None
|
|
|
|
def _confused_self_damage(self, state: CombatantState) -> None:
|
|
"""Confused 51-75: 1d8 + Str modifier damage to self with item in hand."""
|
|
dmg = self._rng.roll_die(8) + state.combatant.abilities.str_mod
|
|
old_hp = state.hp
|
|
state.hp -= dmg
|
|
self._log(
|
|
f"round {self._current_round} {state.combatant.id}: "
|
|
f"confused: hits self for {dmg} (hp {old_hp}->{state.hp})"
|
|
)
|
|
|
|
def _confused_attack_nearest(self, state: CombatantState) -> None:
|
|
"""Confused 76-100: attack the nearest creature (friend OR foe) once."""
|
|
target = self._nearest_creature(state)
|
|
weapon = None
|
|
if target is not None:
|
|
weapon = next(
|
|
(
|
|
w
|
|
for w in state.usable_attacks()
|
|
if w.kind == "melee" and self._melee_weapon_usable(state, target, w)
|
|
),
|
|
None,
|
|
)
|
|
if target is None or weapon is None:
|
|
self._log(
|
|
f"round {self._current_round} {state.combatant.id}: "
|
|
f"confused: attacks nearest but nothing in reach"
|
|
)
|
|
return
|
|
self._log(
|
|
f"round {self._current_round} {state.combatant.id}: "
|
|
f"confused: attacks {target.combatant.id}"
|
|
)
|
|
self._single_attack(state, target, weapon)
|
|
|
|
def _nearest_creature(self, state: CombatantState) -> CombatantState | None:
|
|
"""Closest active creature — friend OR foe — by grid distance."""
|
|
others = [s for s in self._states if s is not state and s.active]
|
|
if not others:
|
|
return None
|
|
return min(
|
|
others,
|
|
key=lambda s: (self.creature_distance(state, s), self._states.index(s)),
|
|
)
|
|
|
|
def _stop_bleeding(self, state: CombatantState) -> None:
|
|
"""PF1e: applying a spell that cures hit point damage stops bleeding (CRB).
|
|
|
|
Simplification: the DC 15 Heal check alternative is not modeled
|
|
(the engine has no Heal skill action).
|
|
"""
|
|
state.bleed_damage = 0
|
|
state.active_conditions[:] = [
|
|
ac for ac in state.active_conditions if ac.condition.name != "bleeding"
|
|
]
|
|
state.conditions[:] = [
|
|
cond for cond in state.conditions if cond.name != "bleeding"
|
|
]
|
|
|
|
def run(self) -> CombatResult:
|
|
"""Run the battle and return the final result and transcript."""
|
|
self._roll_initiative()
|
|
if self._surprise_round_applies():
|
|
self._run_surprise_round()
|
|
round_no = 1
|
|
while round_no <= self._round_cap:
|
|
self._current_round = round_no
|
|
self._aoo_used.clear()
|
|
self._move_aoo_provoked.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 _surprise_round_applies(self) -> bool:
|
|
"""PF1e (SRD "Surprise"): "If some but not all of the combatants are
|
|
aware of their opponents, a surprise round happens before regular
|
|
rounds begin"; if no one or everyone is surprised, none occurs."""
|
|
active = [s for s in self._states if s.active]
|
|
return any(not s.combatant.aware for s in active) and any(
|
|
s.combatant.aware for s in active
|
|
)
|
|
|
|
def _run_surprise_round(self) -> None:
|
|
"""PF1e (SRD "Surprise"): one partial round before round 1. In
|
|
initiative order (highest to lowest), aware combatants each take a
|
|
standard OR move action plus free actions (``_surprise_filter``);
|
|
"Combatants who are unaware at the start of battle don't get to act
|
|
in the surprise round."
|
|
Simplification: unaware combatants are not flagged flat-footed during
|
|
the surprise round — the engine never injects the flat-footed
|
|
condition itself (see README limitations).
|
|
"""
|
|
self._current_round = 0
|
|
self._aoo_used.clear()
|
|
self._move_aoo_provoked.clear()
|
|
self._log("surprise round")
|
|
self._surprise_round = True
|
|
try:
|
|
for state in self._build_round_order():
|
|
if not state.active:
|
|
continue
|
|
if not state.combatant.aware:
|
|
self._log(
|
|
f"round 0 {state.combatant.id}: unaware — "
|
|
"no action in surprise round"
|
|
)
|
|
continue
|
|
if self._take_turn(state):
|
|
return
|
|
finally:
|
|
self._surprise_round = False
|
|
|
|
def _run_round(self) -> bool:
|
|
"""Execute one round; return True if the battle ended.
|
|
|
|
The round order is mutable: a combatant that took a fresh delay action
|
|
is re-inserted right before its ``delay_before`` target when that
|
|
target is still ahead, otherwise at the end of the round.
|
|
"""
|
|
for state in self._states:
|
|
state.delayed_this_round = False
|
|
order = self._build_round_order()
|
|
i = 0
|
|
while i < len(order):
|
|
state = order[i]
|
|
if not state.active:
|
|
self._forfeit_pending_cast(state)
|
|
self._process_dying(state)
|
|
i += 1
|
|
continue
|
|
was_delayed = state.delayed_this_round
|
|
if self._take_turn(state):
|
|
return True
|
|
if self._drive_pending_summons():
|
|
return True
|
|
i += 1
|
|
if (
|
|
state.delayed_this_round
|
|
and not was_delayed
|
|
and state not in order[i:]
|
|
):
|
|
self._reinsert_delayed(order, i, state)
|
|
self._pending_summons.clear()
|
|
return self._winner_side() is not None
|
|
|
|
def _reinsert_delayed(
|
|
self, order: list[CombatantState], start: int, state: CombatantState
|
|
) -> None:
|
|
"""Put a freshly-delayed combatant back into the round order.
|
|
|
|
Inserted immediately before its ``delay_before`` target if that target
|
|
still has to act (search from ``start``); appended at the end otherwise.
|
|
"""
|
|
insert_at = len(order)
|
|
if state.delay_before is not None:
|
|
for j in range(start, len(order)):
|
|
if order[j].combatant.id == state.delay_before:
|
|
insert_at = j
|
|
break
|
|
order.insert(insert_at, state)
|
|
|
|
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 _process_dying(self, state: CombatantState) -> None:
|
|
"""Advance the dying condition on the creature's own initiative slot.
|
|
|
|
PF1e dying: a combatant below 0 hp but not dead is unconscious and, on
|
|
its turn each round, makes a DC 10 Constitution check
|
|
(d20 + Con modifier - negative hp total); a natural 20 auto-succeeds.
|
|
Failure bleeds 1 more hp; success stabilizes (no further loss while
|
|
stable). New damage below the stabilizing hp resumes dying; healing to
|
|
hp >= 0 resets stabilization for any future downing. A creature taking
|
|
continuous damage (bleed) instead ticks the bleed and AUTOMATICALLY
|
|
FAILS the check (CRB, Stable Characters and Recovery).
|
|
"""
|
|
if state.dead:
|
|
return
|
|
if state.hp >= 0:
|
|
state.stabilized_hp = None
|
|
return
|
|
if state.stabilized_hp is not None:
|
|
if state.hp >= state.stabilized_hp:
|
|
return
|
|
state.stabilized_hp = None
|
|
# PF1e (CRB, Stable Characters and Recovery): "Characters taking
|
|
# continuous damage, such as from an acid arrow or a bleed effect,
|
|
# automatically fail all Constitution checks made to stabilize. Such
|
|
# characters lose 1 hit point per round in addition to the continuous
|
|
# damage." This is the inactive creature's once-per-round slot (the
|
|
# active path already ticked bleed in _turn_gate), so the bleed damage
|
|
# is applied here and the d20 check is skipped (auto-fail).
|
|
if state.bleed_damage > 0:
|
|
self._apply_bleed_damage(state)
|
|
hp_before = state.hp
|
|
state.hp -= 1
|
|
self._log(
|
|
f"round {self._current_round} {state.combatant.id}: dying — continuous "
|
|
f"damage auto-fails the Constitution check, loses 1 hp "
|
|
f"(hp {hp_before} -> {state.hp})"
|
|
)
|
|
return
|
|
con_mod = (state.combatant.abilities.con_score - 10) // 2
|
|
penalty = -state.hp
|
|
roll = self._rng.d20()
|
|
total = roll + con_mod - penalty
|
|
check = f"d20({roll}) + CON({con_mod}) - {penalty} = {total} vs DC {_DYING_DC}"
|
|
if roll == _NATURAL_TWENTY or total >= _DYING_DC:
|
|
state.stabilized_hp = state.hp
|
|
nat20 = " — natural 20" if roll == _NATURAL_TWENTY else ""
|
|
self._log(
|
|
f"round {self._current_round} {state.combatant.id}: "
|
|
f"stabilized ({check}{nat20})"
|
|
)
|
|
else:
|
|
hp_before = state.hp
|
|
state.hp -= 1
|
|
self._log(
|
|
f"round {self._current_round} {state.combatant.id}: dying — Constitution "
|
|
f"check {check} failed, loses 1 hp (hp {hp_before} -> {state.hp})"
|
|
)
|
|
|
|
def _blocked(self, *exclude: CombatantState) -> frozenset[Pos]:
|
|
"""Occupied cells that block the mover (the first excluded state).
|
|
|
|
Union of the active creatures' cells except those in ``exclude``. PF1e
|
|
co-occupancy (SRD "Big and Little Creatures in Combat") lets a
|
|
Tiny-or-smaller creature share a square with a strictly larger one, so
|
|
a co-occupiable creature's cells do NOT block the mover (it may move
|
|
through and end on them); two same-size creatures (or two Small-or-
|
|
larger) never share, so their cells still block.
|
|
"""
|
|
mover = exclude[0] if exclude else None
|
|
blocked: set[Pos] = set()
|
|
for s in self._states:
|
|
if not s.active or any(s is e for e in exclude):
|
|
continue
|
|
if mover is not None and _can_cooccupy(
|
|
mover.combatant.size, s.combatant.size
|
|
):
|
|
continue
|
|
blocked |= set(s.occupied_cells())
|
|
return frozenset(blocked)
|
|
|
|
def _blocked_hostile(self, mover: CombatantState) -> frozenset[Pos]:
|
|
"""Occupied cells of active creatures hostile to ``mover``.
|
|
|
|
Same-side allies are excluded: PF1e (SRD Movement, Position and
|
|
Distance) — "You can move through a square occupied by a friendly
|
|
character, unless you are charging." Co-occupiable creatures (a
|
|
Tiny-or-smaller mover and a strictly larger occupant, or the reverse)
|
|
are excluded too — the mover may pass through and end on their squares
|
|
(SRD "Big and Little Creatures in Combat").
|
|
"""
|
|
return frozenset(
|
|
cell
|
|
for s in self._states
|
|
if s.active
|
|
and s is not mover
|
|
and s.side != mover.side
|
|
and not _can_cooccupy(mover.combatant.size, s.combatant.size)
|
|
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 _cells_walkable(self, cells: Iterable[Pos], mover: CombatantState) -> bool:
|
|
"""True if a move/run path may enter these cells (allies allowed).
|
|
|
|
Inbounds, passable, and not occupied by a hostile creature; a
|
|
friendly creature's square may be crossed through (SRD Movement),
|
|
though the movement may not END there (``_cells_free`` gates that).
|
|
"""
|
|
hostile = self._blocked_hostile(mover)
|
|
return all(
|
|
self._grid.passable(cell) and cell not in hostile 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."""
|
|
for weapon in attacker.usable_attacks():
|
|
if self._weapon_usable_against(attacker, target, weapon):
|
|
return weapon
|
|
return None
|
|
|
|
def _weapon_usable_against(
|
|
self, attacker: CombatantState, target: CombatantState, weapon: AttackSpec
|
|
) -> bool:
|
|
"""True if ``weapon`` can strike ``target`` at this range with clear LoE.
|
|
|
|
The per-weapon half of ``weapon_for``'s auto-selection, exposed for
|
|
the explicit weapon choice of ``attack`` / ``full_attack`` (U5d):
|
|
melee/touch reach (with the reach dead zone) or ranged/touch range
|
|
within the max increments, behind a line-of-effect gate.
|
|
"""
|
|
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
|
|
if weapon.kind in ("melee", "touch"):
|
|
inner_cells, outer_cells = _melee_reach_zone_cells(attacker, weapon)
|
|
if inner_cells * _SQUARE_FT <= dist_ft <= outer_cells * _SQUARE_FT:
|
|
return True
|
|
max_increments = (
|
|
_MAX_TOUCH_RANGE_INCREMENTS
|
|
if weapon.kind == "touch"
|
|
else _MAX_RANGE_INCREMENTS
|
|
)
|
|
return (
|
|
weapon.kind in ("ranged", "touch")
|
|
and weapon.range_increment_ft is not None
|
|
and dist_ft <= weapon.range_increment_ft * max_increments
|
|
)
|
|
|
|
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 _is_melee_attack(self, weapon: AttackSpec) -> bool:
|
|
"""PF1e: a melee touch attack (kind "touch" with no range increment,
|
|
e.g. the lich's Paralyzing Touch) is a melee attack — it gains the
|
|
melee benefits (flanking, high ground, vs-helpless +4, sneak attack).
|
|
"""
|
|
return weapon.kind == "melee" or (
|
|
weapon.kind == "touch" and weapon.range_increment_ft is 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. PF1e (CRB):
|
|
the ally must actually THREATEN the target — a usable melee weapon
|
|
whose reach covers a target square (a reach weapon's 5-ft dead zone
|
|
does not), and no condition that stops it from acting (stunned,
|
|
paralyzed, ...).
|
|
"""
|
|
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 self.has_condition_flag(ally, "cant_act"):
|
|
continue
|
|
if not (ally.occupied_cells() & opposite):
|
|
continue
|
|
if self.threatened_squares(ally) & target.occupied_cells():
|
|
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)
|
|
)
|
|
# PF1e: prone and helpless (paralyzed/asleep/unconscious) AC
|
|
# adjustments depend on the weapon kind and are applied in
|
|
# resolve_attack, where it is known — not unconditionally here.
|
|
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 _attack_ac_mods(
|
|
self, target: CombatantState, weapon: AttackSpec
|
|
) -> list[StatModifier]:
|
|
"""AC modifiers for one attack — the weapon-kind-dependent ones included.
|
|
|
|
PF1e (CRB): a prone defender takes -4 AC against melee attacks but
|
|
gains +4 AC against ranged attacks; a helpless defender (paralyzed,
|
|
asleep, unconscious, helpless) has an effective Dexterity score of 0,
|
|
so its whole Dex modifier gives way to the Dex-0 penalty (-5) —
|
|
flat-footed AC already drops a positive Dex bonus, a native Dex
|
|
penalty must still be replaced."""
|
|
mods = self._stat_modifiers(target, "ac")
|
|
if self._has_condition_named(target, "prone"):
|
|
mods.append(
|
|
StatModifier(
|
|
target="ac", value=4 if self._is_ranged_attack(weapon) else -4
|
|
)
|
|
)
|
|
if self._is_helpless(target):
|
|
dex_penalty = min(0, target.combatant.abilities.dex_mod)
|
|
mods.append(StatModifier(target="ac", value=_DEX_ZERO_MOD - dex_penalty))
|
|
return mods
|
|
|
|
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)
|
|
# PF1e: melee touch attacks are melee attacks — flanking and high
|
|
# ground apply to them (see _is_melee_attack).
|
|
flank = self._flanking_bonus(attacker, target) if self._is_melee_attack(weapon) else 0
|
|
high_ground = (
|
|
_HIGH_GROUND_BONUS
|
|
if self._is_melee_attack(weapon)
|
|
and self._elevation(attacker) > self._elevation(target)
|
|
else 0
|
|
)
|
|
base_bonus = bonus_override if bonus_override is not None else weapon.attack_bonus
|
|
fim_penalty = (
|
|
self._firing_into_melee_penalty(attacker, target)
|
|
if self._is_ranged_attack(weapon)
|
|
else 0
|
|
)
|
|
atk_mods = self._stat_modifiers(
|
|
attacker, "attack", weapon_name=weapon.name, weapon=weapon, dist_ft=dist_ft
|
|
)
|
|
# PF1e (CRB): a prone attacker takes -4 on melee attack rolls. Ranged
|
|
# weapons cannot be used while prone at all, EXCEPT crossbows, which
|
|
# stay usable at -4. The model cannot tell a crossbow from a bow, so
|
|
# every ranged weapon is treated as the crossbow-exempt case: usable,
|
|
# but at -4 (documented simplification).
|
|
if self._has_condition_named(attacker, "prone"):
|
|
atk_mods.append(StatModifier(target="attack", value=-4))
|
|
# PF1e: an invisible attacker strikes from an unknown direction (+2 on
|
|
# attack and crit confirmation, target loses its Dex bonus to AC).
|
|
if self._has_condition_named(attacker, "invisible"):
|
|
atk_mods.append(StatModifier(target="attack", value=_INVISIBLE_ATTACK_BONUS))
|
|
# PF1e (CRB): melee attacks (melee touch included) against a helpless
|
|
# target gain +4; the helpless state includes paralyzed, asleep and
|
|
# unconscious (stunned is NOT helpless). Ranged attacks gain no bonus.
|
|
if self._is_melee_attack(weapon) and self._is_helpless(target):
|
|
atk_mods.append(StatModifier(target="attack", value=_HELPLESS_MELEE_BONUS))
|
|
attack_mod = resolve_modifiers(atk_mods)
|
|
# PF1e (CRB Power Attack): the -X penalty rides ALL melee attack rolls
|
|
# — a melee touch (no range increment) is a melee attack. The damage
|
|
# bonus it buys stays weapon-only (see _resolve_hit_damage).
|
|
pa_amt = (
|
|
self._power_attack_amt(attacker) if self._is_melee_attack(weapon) else 0
|
|
)
|
|
fd_penalty = (
|
|
_FIGHT_DEFENSIVELY_ATTACK_PENALTY if attacker.fight_defensively else 0
|
|
)
|
|
total = (
|
|
roll + base_bonus + penalty + flank + high_ground + attack_mod - pa_amt
|
|
+ fim_penalty + fd_penalty + attacker.aid_attack_bonus
|
|
)
|
|
# Check if target denies DEX to AC (flat-footed); a running target
|
|
# loses its DEX bonus too (U5e), reusing the same AC path. An
|
|
# invisible attacker also denies the target's DEX bonus to AC.
|
|
denies_dex = (
|
|
self.has_condition_flag(target, "denies_dex_to_ac")
|
|
or target.running
|
|
or self._has_condition_named(attacker, "invisible")
|
|
)
|
|
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_mods = self._attack_ac_mods(target, weapon)
|
|
ac = base_ac + resolve_modifiers(ac_mods)
|
|
ac += target.defensive_ac_bonus
|
|
ac += target.aid_ac_bonus
|
|
ac += self._attack_cover_bonus(
|
|
attacker, target, ranged=self._is_ranged_attack(weapon)
|
|
)
|
|
hit = roll == _NATURAL_TWENTY or (roll != _NATURAL_ONE and total >= ac)
|
|
damage = 0
|
|
concealment_miss = False
|
|
crit = False
|
|
if hit:
|
|
crit, concealment_miss, damage = self._resolve_hit_damage(
|
|
attacker, target, weapon, roll=roll, ac=ac, base_bonus=base_bonus,
|
|
penalty=penalty, flank=flank, high_ground=high_ground,
|
|
attack_mod=attack_mod, pa_amt=pa_amt, fim_penalty=fim_penalty,
|
|
dist_ft=dist_ft, denies_dex=denies_dex,
|
|
)
|
|
if not concealment_miss:
|
|
target.hp -= damage
|
|
damage = self._consume_up_close_and_deadly(
|
|
attacker, target, weapon, dist_ft, damage, hit=hit
|
|
)
|
|
attacker.aid_attack_bonus = 0
|
|
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, melee_penalty=fim_penalty,
|
|
)
|
|
|
|
def _resolve_hit_damage(
|
|
self,
|
|
attacker: CombatantState,
|
|
target: CombatantState,
|
|
weapon: AttackSpec,
|
|
*,
|
|
roll: int,
|
|
ac: int,
|
|
base_bonus: int,
|
|
penalty: int,
|
|
flank: int,
|
|
high_ground: int,
|
|
attack_mod: int,
|
|
pa_amt: int,
|
|
fim_penalty: int,
|
|
dist_ft: int,
|
|
denies_dex: bool,
|
|
) -> tuple[bool, bool, int]:
|
|
"""Resolve a successful attack roll: crit confirm, concealment miss
|
|
chance, sneak attack dice, then damage (PF1e order).
|
|
|
|
Returns ``(crit, concealment_miss, damage)``. Sneak precision dice are
|
|
added after the crit multiplier and before DR / energy resistance.
|
|
Improved Critical doubles the threat range of every weapon (engine
|
|
simplification: no per-weapon-type feat selection); it never affects
|
|
the confirmation roll.
|
|
"""
|
|
crit = False
|
|
threat_range = weapon.crit_range
|
|
if self._has_feat(attacker, _IMPROVED_CRITICAL_FEATURE):
|
|
threat_range = _CRIT_RANGE_BASE - 2 * (_CRIT_RANGE_BASE - weapon.crit_range)
|
|
if roll != _NATURAL_ONE and roll >= threat_range:
|
|
confirm = self._rng.d20()
|
|
confirm_total = (
|
|
confirm + base_bonus + penalty + flank + high_ground
|
|
+ attack_mod - pa_amt + fim_penalty + attacker.aid_attack_bonus
|
|
+ (_FIGHT_DEFENSIVELY_ATTACK_PENALTY if attacker.fight_defensively else 0)
|
|
)
|
|
# PF1e (CRB): the confirmation roll is an attack roll — a natural
|
|
# 20 on it is an automatic hit (auto-confirm), a natural 1 miss.
|
|
crit = confirm == _NATURAL_TWENTY or (
|
|
confirm != _NATURAL_ONE and confirm_total >= ac
|
|
)
|
|
concealment = self._concealment_chance(target)
|
|
# PF1e: a blinded character's opponents count as having total
|
|
# concealment against it — the blinded attacker itself misses 50%.
|
|
attacker_blinded = self._has_condition_named(attacker, "blinded")
|
|
miss_chance = (
|
|
max(concealment, _TOTAL_CONCEALMENT_PCT) if attacker_blinded else concealment
|
|
)
|
|
if miss_chance > 0:
|
|
pct = self._rng.d100()
|
|
missed = pct <= miss_chance
|
|
source = "attacker blinded" if attacker_blinded else "concealment"
|
|
self._log(
|
|
f"round {self._current_round} {attacker.combatant.id}: "
|
|
f"{source} vs {target.combatant.id}: "
|
|
f"d100({pct}) {'<=' if missed else '>'} {miss_chance}% "
|
|
f"— attack {'misses' if missed else 'lands'}"
|
|
)
|
|
if missed:
|
|
return False, True, 0
|
|
sneak_total = 0
|
|
sneak_dice = self._sneak_attack_dice(
|
|
attacker, target, weapon, dist_ft=dist_ft,
|
|
denies_dex=denies_dex, concealment=concealment,
|
|
)
|
|
if sneak_dice > 0:
|
|
sneak_total = sum(self._rng.roll_die(6) for _ in range(sneak_dice))
|
|
self._log(
|
|
f"round {self._current_round} {attacker.combatant.id}: "
|
|
f"sneak attack vs {target.combatant.id}: "
|
|
f"{sneak_dice}d6 = {sneak_total} precision damage"
|
|
)
|
|
dmg_mods = self._stat_modifiers(
|
|
attacker, "damage", weapon_name=weapon.name, weapon=weapon, dist_ft=dist_ft
|
|
)
|
|
rolls = [c.formula.roll(self._rng) for c in weapon.damage]
|
|
# PF1e (CRB, Critical Hits): "Precision damage ... and additional
|
|
# damage dice from weapon special abilities (such as flaming) are not
|
|
# multiplied when you score a critical hit." Only the base physical
|
|
# dice ride the multiplier with the flat bonuses; energy-typed
|
|
# component dice (flaming/frost/shock/...) are added at 1x.
|
|
physical_dice = sum(
|
|
roll
|
|
for comp, roll in zip(weapon.damage, rolls, strict=True)
|
|
if _PHYSICAL_DAMAGE_TYPES.issuperset(comp.types)
|
|
)
|
|
energy_dice = sum(rolls) - physical_dice
|
|
damage = physical_dice + weapon.damage_bonus
|
|
damage += resolve_modifiers(dmg_mods)
|
|
# PF1e (CRB Power Attack): +2X damage with a melee weapon, +3X (the
|
|
# bonus increased by half) with a two-handed weapon, a one-handed
|
|
# weapon used in two hands, or a primary natural with 1.5x Strength
|
|
# (modeled via the weapon's `two_handed` flag). The off-hand /
|
|
# secondary-natural halving (-50%, +1X) is NOT modeled: two-weapon
|
|
# fighting is not in the engine (documented simplification). The
|
|
# bonus damage never applies to touch attacks (CRB), which is why
|
|
# this gate is kind == "melee" while the -X penalty also rides melee
|
|
# touch rolls.
|
|
if weapon.kind == "melee":
|
|
damage += pa_amt * (3 if weapon.two_handed else 2)
|
|
if crit:
|
|
damage *= weapon.crit_mult
|
|
damage += sneak_total + energy_dice
|
|
# PF1e (CRB Damage Reduction): "Damage reduction does not negate ...
|
|
# energy damage dealt along with an attack" — DR reduces only the
|
|
# non-energy share of the damage (physical dice, flat bonuses and
|
|
# precision dice); energy components are cut solely by energy
|
|
# resistance/immunity in the per-component step below. The energy
|
|
# share is its 1x dice: special-ability dice never ride the crit
|
|
# multiplier (CRB Critical Hits exception).
|
|
energy_share = sum(
|
|
roll
|
|
for comp, roll in zip(weapon.damage, rolls, strict=True)
|
|
if not _PHYSICAL_DAMAGE_TYPES.issuperset(comp.types)
|
|
)
|
|
physical = max(0, damage - energy_share)
|
|
damage = self._apply_dr(physical, weapon, target) + energy_share
|
|
return crit, False, self._apply_energy_resistance_per_component(
|
|
damage, weapon, rolls, target=target
|
|
)
|
|
|
|
def _attack_cover_bonus(
|
|
self, attacker: CombatantState, target: CombatantState, *, ranged: bool
|
|
) -> int:
|
|
"""Cover the target has against one attack from ``attacker`` (terrain +
|
|
soft cover from interposed creatures on ranged lines only)."""
|
|
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()
|
|
)
|
|
return cover_bonus_cells(
|
|
self._grid,
|
|
attacker.occupied_cells(),
|
|
target.occupied_cells(),
|
|
ranged=ranged,
|
|
occupied=occupied,
|
|
)
|
|
|
|
def _concealment_chance(self, target: CombatantState) -> int:
|
|
"""Highest miss chance (%) among the target's active concealment conditions."""
|
|
chances = [ac.condition.miss_chance for ac in target.active_conditions]
|
|
chances += [cond.miss_chance for cond in target.conditions]
|
|
return max((c for c in chances if c > 0), default=0)
|
|
|
|
def _sneak_attack_dice(
|
|
self,
|
|
attacker: CombatantState,
|
|
target: CombatantState,
|
|
weapon: AttackSpec,
|
|
*,
|
|
dist_ft: int,
|
|
denies_dex: bool,
|
|
concealment: int,
|
|
) -> int:
|
|
"""Sneak attack d6 count for this swing (0 when the sneak does not apply).
|
|
|
|
PF1e: precision damage when the target is flanked (melee only, melee
|
|
touch included) or denied its Dex bonus to AC (melee, or ranged within
|
|
30 ft); dice = (level + 1) // 2. Only TOTAL concealment (miss chance
|
|
>= 50%) denies the sneak attack; partial concealment (blurred 20%)
|
|
does not (CRB rogue "Sneak Attack": "cannot sneak attack while
|
|
striking a creature with total concealment"). A BLINDED attacker faces
|
|
total concealment against ALL its opponents (CRB "Conditions —
|
|
Blinded": "All opponents are considered to have total concealment
|
|
(50% miss chance) against the blinded character"), so it cannot sneak
|
|
attack either — same max(target, 50) convention as _resolve_hit_damage.
|
|
PF1e (CRB): undead and constructs are immune to precision damage —
|
|
ooze/plant immunity is not modeled (no such creature types exist in
|
|
the data model).
|
|
"""
|
|
effective_concealment = concealment
|
|
if self._has_condition_named(attacker, "blinded"):
|
|
effective_concealment = max(concealment, _TOTAL_CONCEALMENT_PCT)
|
|
if (
|
|
effective_concealment >= _TOTAL_CONCEALMENT_PCT
|
|
or not self._has_feat(attacker, _SNEAK_ATTACK_FEATURE)
|
|
):
|
|
return 0
|
|
if target.combatant.creature_type.lower() in _PRECISION_IMMUNE_TYPES:
|
|
return 0
|
|
dice = (attacker.combatant.level + 1) // 2
|
|
is_melee = self._is_melee_attack(weapon)
|
|
if is_melee and self._flanking_bonus(attacker, target) > 0:
|
|
return dice
|
|
if denies_dex and (
|
|
is_melee
|
|
or (self._is_ranged_attack(weapon) and dist_ft <= _POINT_BLANK_RANGE_FT)
|
|
):
|
|
return dice
|
|
return 0
|
|
|
|
def _firing_into_melee_penalty(
|
|
self, attacker: CombatantState, target: CombatantState
|
|
) -> int:
|
|
"""PF1e firing into melee: -4 on a ranged attack whose target is
|
|
engaged in melee, avoided entirely (0) when the target or every
|
|
threatening opponent stands 10+ ft from the attacker. Precise Shot
|
|
removes it.
|
|
|
|
PF1e (SRD "Attack" — Shooting or Throwing into a Melee): "If your
|
|
target (or the part of your target you're aiming at, if it's a big
|
|
target) is at least 10 feet away from the nearest friendly character,
|
|
you can avoid the -4 penalty" — the 10+ ft clause is therefore 0, not
|
|
-2. The -2 applies only when the target is two size categories larger
|
|
than the friendly characters it is engaged with (and 0 at three or
|
|
more): size-category reduction is NOT modeled (documented
|
|
simplification).
|
|
|
|
The target is engaged when an active enemy of the target (not the
|
|
attacker itself) threatens the target's occupied cells with a melee
|
|
weapon.
|
|
"""
|
|
if self._has_feat(attacker, _PRECISE_SHOT_FEATURE):
|
|
return 0
|
|
threats = [
|
|
s
|
|
for s in self._states
|
|
if s is not attacker
|
|
and s.active
|
|
and s.side != target.side
|
|
and self.threatened_squares(s) & target.occupied_cells()
|
|
]
|
|
if not threats:
|
|
return 0
|
|
if self.creature_distance(attacker, target) * _SQUARE_FT >= _FIRING_MELEE_SAFE_FT:
|
|
return 0
|
|
for threat in threats:
|
|
dist = self.creature_distance(attacker, threat) * _SQUARE_FT
|
|
if dist >= _FIRING_MELEE_SAFE_FT:
|
|
return 0
|
|
return _FIRING_MELEE_PENALTY
|
|
|
|
def _engaged_in_melee(self, state: CombatantState) -> bool:
|
|
"""True when an active enemy with a melee weapon threatens this
|
|
combatant's occupied cells (same threat machinery as AoO/flanking)."""
|
|
occupied = state.occupied_cells()
|
|
return any(
|
|
self.threatened_squares(s) & occupied
|
|
for s in self._states
|
|
if s is not state and s.active and s.side != state.side
|
|
)
|
|
|
|
def _consume_up_close_and_deadly(
|
|
self,
|
|
attacker: CombatantState,
|
|
target: CombatantState,
|
|
weapon: AttackSpec,
|
|
dist_ft: int,
|
|
damage: int,
|
|
*,
|
|
hit: bool,
|
|
) -> int:
|
|
"""Consume the Up Close and Deadly deed on the first attack resolved.
|
|
|
|
The deed applies +1d6 to a ranged attack within 30 ft; the flag is
|
|
reset after the first attack in all cases (the benefit is lost if the
|
|
attack is out of range or not ranged).
|
|
"""
|
|
if not attacker.deed_up_close_and_deadly:
|
|
return damage
|
|
if hit and self._is_ranged_attack(weapon) and dist_ft <= _UP_CLOSE_RANGE_FT:
|
|
bonus = self._rng.roll_die(6)
|
|
damage += bonus
|
|
target.hp -= bonus
|
|
attacker.deed_up_close_and_deadly = False
|
|
return damage
|
|
|
|
def _dr_bypassed(self, dr: DamageReduction, types: frozenset[str]) -> bool:
|
|
"""PF1e (CRB): "DR X/Y and Z" requires EVERY listed component to bypass.
|
|
|
|
A single-component bypass behaves as before (that type defeats the
|
|
DR); a multi-component bypass is an "and" list — all components must
|
|
be present on the attack. Documented gap: the weapon model exposes no
|
|
"magic" property, so a bypass list containing "magic" (the lich's
|
|
DR 15/bludgeoning and magic) can never be satisfied by a mundane
|
|
weapon — correctly, a non-magical weapon must NOT bypass it.
|
|
"""
|
|
if not dr.bypass:
|
|
return False
|
|
return dr.bypass <= types
|
|
|
|
def _apply_dr(self, damage: int, weapon: AttackSpec, target: CombatantState) -> int:
|
|
dr: DamageReduction | None = target.combatant.dr
|
|
if dr is None:
|
|
return damage
|
|
types = frozenset(t for comp in weapon.damage for t in comp.types)
|
|
if self._dr_bypassed(dr, types):
|
|
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 self._dr_bypassed(dr, damage_types):
|
|
return damage
|
|
return max(0, damage - dr.amount)
|
|
|
|
def _check_spell_resistance(
|
|
self, caster: CombatantState, target: CombatantState
|
|
) -> bool:
|
|
"""SR check: d20 + caster level vs target spell_resistance.
|
|
|
|
Returns True if the spell affects the target (SR passed or no SR).
|
|
Returns False if the target resists the spell (all effects skipped).
|
|
"""
|
|
sr = target.combatant.spell_resistance
|
|
if sr == 0:
|
|
return True
|
|
caster_level = caster.combatant.level
|
|
roll = self._rng.d20()
|
|
total = roll + caster_level
|
|
if total >= sr:
|
|
return True
|
|
self._log(
|
|
f"round {self._current_round} {caster.combatant.id}: "
|
|
f"spell resistance {target.combatant.id}: "
|
|
f"d20({roll}) + CL({caster_level}) = {total} vs SR {sr} "
|
|
f"— resisted"
|
|
)
|
|
return False
|
|
|
|
def _apply_energy_resistance_per_component(
|
|
self,
|
|
damage: int,
|
|
weapon: AttackSpec,
|
|
rolls: list[int],
|
|
*,
|
|
target: CombatantState,
|
|
) -> int:
|
|
"""Energy resistance / immunity applied PER DAMAGE COMPONENT (B21).
|
|
|
|
PF1e (CRB "Energy Resistance"/"Immunity to Energy Damage"): each
|
|
damage type's resistance reduces only the dice of that type — a
|
|
weapon dealing 1d6 slashing + 1d6 fire against fire resistance 5
|
|
loses only its fire portion; the slashing dice, the flat bonuses
|
|
(damage_bonus, ability/damage modifiers, Power Attack) and the
|
|
untyped sneak precision dice are never eaten by energy resistance.
|
|
Each component's share of the final damage is its own roll at 1x:
|
|
energy (weapon special ability) dice are never multiplied on a crit
|
|
(CRB Critical Hits exception), so no crit factor rides this step.
|
|
A component carrying several energy types uses the largest matching
|
|
resistance (documented simplification: one die roll cannot be split
|
|
across the listed types). Physical-only components match nothing in
|
|
the target's resistance/immunity maps — they rode the DR path just
|
|
before this step, which reduces only the non-energy share of the
|
|
damage (CRB: DR does not negate energy damage dealt along with an
|
|
attack); single-component weapons keep the exact arithmetic of the
|
|
old lumped-total path.
|
|
"""
|
|
for comp, roll in zip(weapon.damage, rolls, strict=True):
|
|
damage -= self._energy_reduction(roll, frozenset(comp.types), target)
|
|
return max(0, damage)
|
|
|
|
def _energy_reduction(
|
|
self, damage: int, damage_types: frozenset[str], target: CombatantState
|
|
) -> int:
|
|
"""Reduction the target's immunity/resistance applies to one
|
|
damage portion of the given types, capped at the portion itself."""
|
|
immunities = target.combatant.immunities
|
|
if immunities and damage_types & frozenset(immunities):
|
|
return damage
|
|
resistances = target.combatant.resistances
|
|
if not resistances:
|
|
return 0
|
|
max_resist = max(
|
|
(resistances[t] for t in damage_types if t in resistances),
|
|
default=0,
|
|
)
|
|
return min(damage, max_resist)
|
|
|
|
def _apply_energy_resistance(
|
|
self,
|
|
damage: int,
|
|
damage_types: frozenset[str],
|
|
target: CombatantState,
|
|
) -> int:
|
|
"""Energy resistance / immunity applied to one whole damage portion.
|
|
|
|
Used by the spell-damage path: a ``DamageEffect`` is a single
|
|
component (one formula, one type list), so the per-component rule
|
|
(see ``_apply_energy_resistance_per_component``) reduces to exactly
|
|
this. Immunity: if any damage type matches → 0. Resistance:
|
|
subtract the max resistance among matching energy types, floored
|
|
at 0. Physical types (slashing/piercing/bludgeoning/physical) are
|
|
ignored — they stay on the DR path.
|
|
"""
|
|
return max(0, damage - self._energy_reduction(damage, damage_types, target))
|
|
|
|
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. PF1e: modifiers targeting "dex_skills"
|
|
# (grappled/pinned/blinded -4) apply to the Dexterity-based skills
|
|
# listed in _DEX_BASED_SKILLS — they are stored under that aggregate
|
|
# target, not the concrete skill name, so collect them explicitly.
|
|
mods = self._stat_modifiers(state, skill)
|
|
if skill in _DEX_BASED_SKILLS:
|
|
mods.extend(self._stat_modifiers(state, "dex_skills"))
|
|
mod_bonus = resolve_modifiers(mods)
|
|
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[float, 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.) Natural reach (plain
|
|
weapons and natural attacks) is a disk: every square up to the
|
|
effective reach, adjacency included. A reach weapon threatens only
|
|
the ring beyond the wielder's natural reach up to double it (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 natural reach, ring for reach weapons.
|
|
|
|
The union over every occupied cell of the creature's footprint; the
|
|
creature's own cells are never threatened.
|
|
"""
|
|
inner_cells, outer_cells = _melee_reach_zone_cells(state, weapon)
|
|
threatened: set[Pos] = set()
|
|
for r, c in state.occupied_cells():
|
|
threatened |= self._reach_band_cells((r, c), inner_cells, outer_cells)
|
|
threatened -= state.occupied_cells()
|
|
return threatened
|
|
|
|
def _reach_band_cells(self, center: Pos, inner: int, outer: int) -> set[Pos]:
|
|
"""Cells whose 5-10-5 distance from ``center`` lies in ``[inner, outer]``.
|
|
|
|
``inner == 0`` gives the plain disk (the center itself is never
|
|
included); ``inner > 0`` gives the reach-weapon ring dead-zone band.
|
|
"""
|
|
r, c = center
|
|
return {
|
|
(r + dr, c + dc)
|
|
for dr in range(-outer, outer + 1)
|
|
for dc in range(-outer, outer + 1)
|
|
if (dr, dc) != (0, 0)
|
|
and inner <= self._grid.distance(center, (r + dr, c + dc)) <= outer
|
|
}
|
|
|
|
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 effective
|
|
(land-or-fly) 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 = _speed_ft(state) // _SQUARE_FT
|
|
if cost_cells > speed_cells:
|
|
return None
|
|
return cost_cells * _SQUARE_FT
|
|
|
|
def _aoo_defense_block(
|
|
self, attacker: CombatantState, target: CombatantState
|
|
) -> str | None:
|
|
"""Why an AoO cannot be executed against the target, or None.
|
|
|
|
PF1e (CRB "Cover"): "You can't execute an attack of opportunity
|
|
against an opponent with cover relative to you"; (CRB "Concealment"):
|
|
"You can't execute an attack of opportunity against an opponent with
|
|
total concealment" (miss chance >= 50%). A BLINDED attacker considers
|
|
ALL its opponents to have total concealment (CRB "Conditions —
|
|
Blinded": "All opponents are considered to have total concealment
|
|
(50% miss chance) against the blinded character"), so its AoOs are
|
|
suppressed too. Melee line: terrain cover only — creatures grant no
|
|
melee cover.
|
|
"""
|
|
concealment = self._concealment_chance(target)
|
|
if self._has_condition_named(attacker, "blinded"):
|
|
concealment = max(concealment, _TOTAL_CONCEALMENT_PCT)
|
|
if concealment >= _TOTAL_CONCEALMENT_PCT:
|
|
return "total concealment"
|
|
if cover_bonus_cells(
|
|
self._grid,
|
|
attacker.occupied_cells(),
|
|
target.occupied_cells(),
|
|
ranged=False,
|
|
occupied=frozenset(),
|
|
):
|
|
return "cover"
|
|
return None
|
|
|
|
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).
|
|
A target with cover or total concealment relative to the attacker cannot be
|
|
struck at all — the opportunity is suppressed without spending it.
|
|
Returns True if the target dropped.
|
|
"""
|
|
if self.has_condition_flag(attacker, "cant_aoo"):
|
|
return False
|
|
blocked = self._aoo_defense_block(attacker, target)
|
|
if blocked is not None:
|
|
self._log(
|
|
f"round {self._current_round} {attacker.combatant.id}: "
|
|
f"AoO vs {target.combatant.id} suppressed ({blocked})"
|
|
)
|
|
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, *, movement: bool = True
|
|
) -> 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). PF1e (CRB "Attacks of Opportunity —
|
|
Combat Reflexes and Additional Attacks of Opportunity"): "Moving out of
|
|
more than one square threatened by the same opponent in the same round
|
|
doesn't count as more than one opportunity for that opponent" — with
|
|
``movement`` the per-round per-opponent dedupe in
|
|
``_move_aoo_provoked`` skips a second movement-triggered strike from
|
|
the same enemy. Stand-up (``movement=False``) is a separate trigger and
|
|
always provokes, subject to the allowance. Returns True if the mover
|
|
dropped.
|
|
"""
|
|
provoked = self._move_aoo_provoked.setdefault(mover.combatant.id, set())
|
|
for cell in mover.occupied_cells():
|
|
for enemy in self.enemies_threatening(mover, cell):
|
|
if movement and enemy.combatant.id in provoked:
|
|
continue
|
|
if self._aoo_used.get(enemy.combatant.id, 0) >= self._aoo_allowance(enemy):
|
|
continue
|
|
if movement:
|
|
provoked.add(enemy.combatant.id)
|
|
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.
|
|
|
|
PF1e (CRB): the check is d20 + caster level + the ability modifier
|
|
used to determine bonus spells (Int/Wis/Cha) — NOT Constitution.
|
|
Combat Casting's +4 applies only when casting defensively or while
|
|
grappled/pinned, never for damage taken while casting.
|
|
"""
|
|
dc = self._concentration_dc(caster, spell_level, damage_taken)
|
|
injured_while_casting = damage_taken > 0 and caster.grappled_with is None
|
|
has_combat_casting = self._has_feat(caster, _COMBAT_CASTING_FEAT)
|
|
bonus = 0 if injured_while_casting else (4 if has_combat_casting else 0)
|
|
roll = self._rng.d20()
|
|
total = (
|
|
roll + caster.combatant.level + self._casting_ability_mod(caster) + 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 = _speed_ft(state) // _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:
|
|
inner_cells, reach_cells = _melee_reach_zone_cells(state, weapon)
|
|
end = self._charge_end(
|
|
state, target, reach_cells, max_cells, blocked, min_reach_cells=inner_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. Like every other movement it is mutually exclusive
|
|
with movement already spent this turn (``moved_this_turn``).
|
|
"""
|
|
if attacker.moved_this_turn:
|
|
return
|
|
inner_cells, reach_cells = _melee_reach_zone_cells(attacker, weapon)
|
|
speed_cells = _speed_ft(attacker) // _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=inner_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) -> ActionResult:
|
|
"""Execute one action and return its explicit result.
|
|
|
|
Rejections (unsupported kinds, missing target) are logged and never
|
|
reach the helpers; everything else dispatches exactly as before and
|
|
reports ok.
|
|
"""
|
|
kind = action.kind
|
|
if kind in _UNSUPPORTED_ACTION_KINDS:
|
|
self._log(
|
|
f"round {self._current_round} {state.combatant.id}: "
|
|
f"{kind} rejected ({_REASON_UNSUPPORTED_KIND})"
|
|
)
|
|
return ActionResult(
|
|
kind=kind, ok=False, reason=_REASON_UNSUPPORTED_KIND
|
|
)
|
|
target = next((s for s in self._states if s.combatant.id == action.target_id), None)
|
|
rejection = self._target_rejection(state, kind, target, action)
|
|
if rejection is not None:
|
|
return rejection
|
|
direct = self._execute_direct(state, target, action)
|
|
if direct is not None:
|
|
return direct
|
|
override = self._dispatch_action(state, target, action)
|
|
return override if override is not None else ActionResult(
|
|
kind=kind, ok=True, reason=_REASON_OK
|
|
)
|
|
|
|
def _execute_direct(
|
|
self, state: CombatantState, target: CombatantState | None, action: Action
|
|
) -> ActionResult | None:
|
|
"""Execute the kinds with a dedicated (non-target-dispatch) helper.
|
|
|
|
delay/ready (U3/U4), the self-affecting defensive and prone actions
|
|
(U5a/U5b) and the aid another / run pair (U5e) each own an explicit
|
|
result; None means the action is not one of these kinds and the
|
|
caller must fall through to ``_dispatch_action``.
|
|
"""
|
|
kind = action.kind
|
|
if kind == "delay":
|
|
return self._execute_delay(state, target, action)
|
|
if kind == "ready":
|
|
return self._execute_ready(state, action)
|
|
if kind in ("total_defense", "fight_defensively", "stand_up", "drop_prone"):
|
|
return self._execute_self_action(state, kind)
|
|
if kind == "aid_another":
|
|
return self._execute_aid_another(state, target, action)
|
|
if kind == "run":
|
|
return self._execute_run(state, target, action)
|
|
return None
|
|
|
|
def _target_rejection(
|
|
self,
|
|
state: CombatantState,
|
|
kind: str,
|
|
target: CombatantState | None,
|
|
action: Action,
|
|
) -> ActionResult | None:
|
|
"""U1 missing-target gate; move/5foot_step/run accept a destination instead
|
|
(U5c / U5e); cast_spell accepts a target_pos instead (U7b)."""
|
|
if target is not None or kind not in _TARGET_REQUIRED_ACTION_KINDS:
|
|
return None
|
|
if kind in ("move", "5foot_step", "run") and action.destination is not None:
|
|
return None
|
|
if kind == "cast_spell" and action.target_pos is not None:
|
|
return None
|
|
self._log(
|
|
f"round {self._current_round} {state.combatant.id}: "
|
|
f"{kind} rejected ({_REASON_MISSING_TARGET})"
|
|
)
|
|
return ActionResult(kind=kind, ok=False, reason=_REASON_MISSING_TARGET)
|
|
|
|
def _execute_delay(
|
|
self, state: CombatantState, target: CombatantState | None, action: Action
|
|
) -> ActionResult:
|
|
"""Execute the delay action (PF1e): forgo this turn, act later this round.
|
|
|
|
With no target the combatant acts at the end of the round; with one it
|
|
acts immediately before it. The new initiative total (target total
|
|
minus 0.5, or the minimum total minus 0.5) is written into
|
|
``_initiative_keys`` so ``_build_round_order`` keeps the position for
|
|
the following rounds. A second delay in the same round is rejected
|
|
(``already_delayed``) to prevent an infinite delay loop.
|
|
"""
|
|
kind = action.kind
|
|
if state.delayed_this_round:
|
|
self._log(
|
|
f"round {self._current_round} {state.combatant.id}: "
|
|
f"{kind} rejected ({_REASON_ALREADY_DELAYED})"
|
|
)
|
|
return ActionResult(
|
|
kind=kind, ok=False, reason=_REASON_ALREADY_DELAYED
|
|
)
|
|
key = self._initiative_keys.get(state.combatant.id)
|
|
prior_index = key[2] if key is not None else self._states.index(state)
|
|
if target is not None:
|
|
target_key = self._initiative_keys.get(target.combatant.id)
|
|
target_total = target_key[0] if target_key is not None else 0.0
|
|
new_total = target_total - _DELAY_INITIATIVE_STEP
|
|
else:
|
|
totals = [k[0] for k in self._initiative_keys.values()]
|
|
new_total = (
|
|
min(totals) - _DELAY_INITIATIVE_STEP if totals else 0.0
|
|
)
|
|
self._initiative_keys[state.combatant.id] = (
|
|
new_total,
|
|
state.combatant.initiative_mod,
|
|
prior_index,
|
|
)
|
|
state.delayed_this_round = True
|
|
state.delay_before = target.combatant.id if target is not None else None
|
|
if target is not None:
|
|
self._log(
|
|
f"round {self._current_round} {state.combatant.id}: "
|
|
f"{kind} before {target.combatant.id}"
|
|
)
|
|
else:
|
|
self._log(f"round {self._current_round} {state.combatant.id}: {kind}")
|
|
return ActionResult(kind=kind, ok=True, reason=_REASON_DELAYED)
|
|
|
|
def _execute_ready(self, state: CombatantState, action: Action) -> ActionResult:
|
|
"""Execute the ready action (PF1e): spend the standard action preparing a
|
|
payload that fires off-turn when its trigger event occurs.
|
|
|
|
The payload is derived from the carrier fields like
|
|
``_execute_immediate`` (spell / maneuver / attack on ``target_id``;
|
|
class-ability payloads are out of scope for U4). A new ready replaces
|
|
any pending one. The engine fires it through ``_check_readied``, then
|
|
the readied action is consumed. The turn loop keeps running (a policy
|
|
may still spend the move action).
|
|
"""
|
|
kind = action.kind
|
|
state.readied = _ready_payload(action)
|
|
state.readied_trigger = action.ready_trigger
|
|
label = action.ready_trigger or "no trigger"
|
|
self._log(
|
|
f"round {self._current_round} {state.combatant.id}: {kind} ({label})"
|
|
)
|
|
return ActionResult(kind=kind, ok=True, reason=_REASON_READY)
|
|
|
|
def _execute_self_action(self, state: CombatantState, kind: str) -> ActionResult:
|
|
"""Dispatch a target-free self-affecting action to its helper."""
|
|
if kind == "total_defense":
|
|
return self._execute_total_defense(state)
|
|
if kind == "fight_defensively":
|
|
return self._execute_fight_defensively(state)
|
|
if kind == "stand_up":
|
|
return self._execute_stand_up(state)
|
|
return self._execute_drop_prone(state)
|
|
|
|
def _execute_total_defense(self, state: CombatantState) -> ActionResult:
|
|
"""Execute the total defense action (PF1e): standard action granting a
|
|
+4 dodge bonus to AC until the start of the combatant's next turn.
|
|
"""
|
|
state.defensive_ac_bonus += _TOTAL_DEFENSE_AC_BONUS
|
|
self._log(
|
|
f"round {self._current_round} {state.combatant.id}: "
|
|
f"total defense (+{_TOTAL_DEFENSE_AC_BONUS} AC)"
|
|
)
|
|
return ActionResult(kind="total_defense", ok=True, reason=_REASON_OK)
|
|
|
|
def _execute_fight_defensively(self, state: CombatantState) -> ActionResult:
|
|
"""Execute the fighting defensively action (PF1e): standard action
|
|
granting a +2 dodge bonus to AC until the start of the combatant's
|
|
next turn, at the cost of -4 on attack and crit confirmation rolls.
|
|
"""
|
|
state.fight_defensively = True
|
|
state.defensive_ac_bonus += _FIGHT_DEFENSIVELY_AC_BONUS
|
|
self._log(
|
|
f"round {self._current_round} {state.combatant.id}: "
|
|
f"fighting defensively ({_FIGHT_DEFENSIVELY_ATTACK_PENALTY} attack, "
|
|
f"+{_FIGHT_DEFENSIVELY_AC_BONUS} AC)"
|
|
)
|
|
return ActionResult(kind="fight_defensively", ok=True, reason=_REASON_OK)
|
|
|
|
def _execute_stand_up(self, state: CombatantState) -> ActionResult:
|
|
"""Execute the stand up action (PF1e): move action that ends prone.
|
|
|
|
Standing up provokes an AoO from each enemy threatening the
|
|
combatant's squares (``_check_move_aoo`` with ``movement=False``:
|
|
standing is a separate trigger, never deduped against a movement
|
|
opportunity already taken this round);
|
|
the AoOs resolve while the combatant is still prone (-4 AC). The
|
|
prone condition is then removed from both representations: the
|
|
duration-tracked ``active_conditions`` (trip/overrun) and the
|
|
duration-less ``conditions`` list (drop_prone). If a provoked AoO
|
|
takes the combatant out of the fight, the removal is a formality —
|
|
inactive combatants do not act.
|
|
"""
|
|
kind = "stand_up"
|
|
if not self._has_condition_named(state, "prone"):
|
|
self._log(
|
|
f"round {self._current_round} {state.combatant.id}: "
|
|
f"{kind} rejected ({_REASON_NOT_PRONE})"
|
|
)
|
|
return ActionResult(kind=kind, ok=False, reason=_REASON_NOT_PRONE)
|
|
self._check_move_aoo(state, movement=False)
|
|
state.active_conditions = [
|
|
ac for ac in state.active_conditions if ac.condition.name != "prone"
|
|
]
|
|
state.conditions = [c for c in state.conditions if c.name != "prone"]
|
|
self._log(f"round {self._current_round} {state.combatant.id}: stands up")
|
|
return ActionResult(kind=kind, ok=True, reason=_REASON_OK)
|
|
|
|
def _execute_drop_prone(self, state: CombatantState) -> ActionResult:
|
|
"""Execute the drop prone action (PF1e): free action that goes prone.
|
|
|
|
The prone condition is appended to the duration-less ``conditions``
|
|
list (trip/overrun use the duration-tracked ``active_conditions``
|
|
instead), so it persists until the combatant stands up. Both lists
|
|
are read by ``_has_condition_named``, so the prone modifiers
|
|
(-4 AC, -4 ranged attacks) apply immediately.
|
|
"""
|
|
kind = "drop_prone"
|
|
if self._has_condition_named(state, "prone"):
|
|
self._log(
|
|
f"round {self._current_round} {state.combatant.id}: "
|
|
f"{kind} rejected ({_REASON_ALREADY_PRONE})"
|
|
)
|
|
return ActionResult(kind=kind, ok=False, reason=_REASON_ALREADY_PRONE)
|
|
state.conditions.append(CONDITIONS_BY_NAME["prone"])
|
|
self._log(f"round {self._current_round} {state.combatant.id}: drops prone")
|
|
return ActionResult(kind=kind, ok=True, reason=_REASON_OK)
|
|
|
|
def _execute_aid_another(
|
|
self, state: CombatantState, target: CombatantState | None, action: Action
|
|
) -> ActionResult:
|
|
"""Execute the aid another action (PF1e, U5e simplification).
|
|
|
|
The aider needs a usable melee weapon and makes an attack roll
|
|
(d20 + the first usable melee weapon's bonus) against AC 10; natural
|
|
20 always succeeds and natural 1 always fails (attack-roll
|
|
convention). On success the target stacks +2 on its next attack roll
|
|
(``aid_mode`` "attack" or None) or +2 AC against the next attack
|
|
targeting it (``aid_mode`` "ac"); multiple aids add up. Documented
|
|
simplifications: no adjacency/line-of-effect check on the ally, and
|
|
any resolved target may be aided (the aider's side is not enforced).
|
|
"""
|
|
kind = "aid_another"
|
|
if target is None:
|
|
return ActionResult(kind=kind, ok=False, reason=_REASON_MISSING_TARGET)
|
|
weapon = next((w for w in state.usable_attacks() if w.kind == "melee"), None)
|
|
if weapon is None:
|
|
self._log(
|
|
f"round {self._current_round} {state.combatant.id}: "
|
|
f"{kind} rejected ({_REASON_NO_WEAPON})"
|
|
)
|
|
return ActionResult(kind=kind, ok=False, reason=_REASON_NO_WEAPON)
|
|
mode = "ac" if action.aid_mode == "ac" else "attack"
|
|
roll = self._rng.d20()
|
|
total = roll + weapon.attack_bonus
|
|
success = (
|
|
roll == _NATURAL_TWENTY
|
|
or (roll != _NATURAL_ONE and total >= _AID_ANOTHER_DC)
|
|
)
|
|
if success:
|
|
if mode == "ac":
|
|
target.aid_ac_bonus += _AID_ANOTHER_BONUS
|
|
else:
|
|
target.aid_attack_bonus += _AID_ANOTHER_BONUS
|
|
self._log(
|
|
f"round {self._current_round} {state.combatant.id}: "
|
|
f"aid another {target.combatant.id} ({mode}) "
|
|
f"d20={roll}+{weapon.attack_bonus}={total} vs AC {_AID_ANOTHER_DC} "
|
|
f"-> {'success' if success else 'fail'}"
|
|
)
|
|
return ActionResult(kind=kind, ok=True, reason=_REASON_OK)
|
|
|
|
def _execute_run(
|
|
self, state: CombatantState, target: CombatantState | None, action: Action
|
|
) -> ActionResult:
|
|
"""Execute the run action (PF1e, U5e): full-round movement at 4x speed.
|
|
|
|
PF1e (SRD "Full-Round Actions — Run"): "When you run, you can move up
|
|
to four times your speed in a straight line" — a run is NOT a ``move``:
|
|
it cannot turn a corner. The path is the straight Bresenham line from
|
|
the combatant's position toward an explicit destination (U5c) or the
|
|
resolved target, walked under a 4x speed budget with the usual step-by-
|
|
step AoO provocation, and truncated at the first obstacle (impassable
|
|
terrain, a difficult-terrain cell, or a blocking creature) exactly like
|
|
the charge's clear-path rule (``_run_line_path``). PF1e forbids running
|
|
across difficult terrain (CRB "Movement, Position and Distance —
|
|
Difficult Terrain": "You can't run or charge across difficult
|
|
terrain"), so a difficult cell stops the run short. An auto-directed
|
|
run advances until the obstacle and stops there (deterministic); a
|
|
hostile creature (the target included) halts it one cell short, so it
|
|
ends adjacent to the target. Friendly squares are still crossed, never
|
|
ended on (SRD Movement, conformance L5). On completion the combatant is
|
|
``running`` until the start of its next turn, which denies it its
|
|
Dexterity bonus to AC. An explicit destination that the straight line
|
|
cannot honour within the 4x budget (blocked by terrain, difficult
|
|
terrain, a creature, or out of range) is rejected with
|
|
``unreachable_destination``; with neither target nor destination the
|
|
U1 gate rejects the action with ``missing_target``.
|
|
"""
|
|
kind = "run"
|
|
if state.moved_this_turn:
|
|
# Defense in depth, mirroring the _move/_5foot_step no-op-ok
|
|
# convention. Unreachable through the filtered path: run is a
|
|
# full-round action, so no move-slot action can precede it in a
|
|
# kept sequence (the budget filter would have dropped one).
|
|
return ActionResult(kind=kind, ok=True, reason=_REASON_OK)
|
|
if self.has_condition_flag(state, "cant_move"):
|
|
self._log(f"round {self._current_round} {state.combatant.id}: wait (cannot move)")
|
|
return ActionResult(kind=kind, ok=True, reason=_REASON_OK)
|
|
if action.destination is not None:
|
|
if action.destination == state.pos:
|
|
path: list[Pos] = [] # already there: a valid zero-length run
|
|
else:
|
|
path = self._run_line_path(state, action.destination)
|
|
if not path or path[-1] != action.destination:
|
|
path = [] # the straight line could not honour it
|
|
if not path and action.destination != state.pos:
|
|
self._log(
|
|
f"round {self._current_round} {state.combatant.id}: "
|
|
f"{kind} rejected ({_REASON_UNREACHABLE_DESTINATION})"
|
|
)
|
|
return ActionResult(
|
|
kind=kind, ok=False, reason=_REASON_UNREACHABLE_DESTINATION
|
|
)
|
|
self._walk_steps(state, path)
|
|
state.moved_this_turn = True
|
|
elif target is not None:
|
|
path = self._run_line_path(state, target.pos)
|
|
if self._walk_steps(state, path):
|
|
state.moved_this_turn = True
|
|
state.running = True
|
|
self._log(f"round {self._current_round} {state.combatant.id}: run")
|
|
return ActionResult(kind=kind, ok=True, reason=_REASON_OK)
|
|
|
|
def _dispatch_action(
|
|
self, state: CombatantState, target: CombatantState | None, action: Action
|
|
) -> ActionResult | None:
|
|
"""Route a validated action to its execution helper.
|
|
|
|
Returns an explicit rejection when a destination-bearing movement
|
|
cannot be honoured (U5c), an attack's chosen weapon is unavailable
|
|
(U5d), or a spell's point target is off-map / without line of effect
|
|
(U7b); None means the helper ran and the caller reports ok.
|
|
"""
|
|
kind = action.kind
|
|
if kind == "cast_spell":
|
|
return self._execute_cast_spell(
|
|
state, target, action.spell_name, target_pos=action.target_pos,
|
|
metamagic=action.metamagic, cast_defensively=action.cast_defensively,
|
|
)
|
|
if 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"):
|
|
return self._execute_attack(state, target, kind, action.weapon_id)
|
|
elif kind == "immediate":
|
|
self._execute_immediate(state, target, action)
|
|
else:
|
|
return self._execute_movement(state, target, action)
|
|
return None
|
|
|
|
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, action: Action
|
|
) -> ActionResult | None:
|
|
"""Execute a movement-style action (5foot_step, move, wait).
|
|
|
|
With an explicit destination, move/5foot_step walk to that cell and
|
|
report unreachable_destination when it cannot be honoured (U5c);
|
|
without one they auto-path toward the target as before.
|
|
"""
|
|
kind = action.kind
|
|
if kind == "5foot_step":
|
|
if action.destination is not None:
|
|
return self._step5_to(state, action.destination)
|
|
if target is None:
|
|
return None
|
|
self._step5(state, target)
|
|
elif kind == "move":
|
|
if action.destination is not None:
|
|
return self._move_to(state, action.destination)
|
|
if target is None:
|
|
return None
|
|
self._move(state, target)
|
|
elif kind == "wait":
|
|
self._log(f"round {self._current_round} {state.combatant.id}: wait")
|
|
elif kind == "activate_power_attack":
|
|
self._execute_activate_power_attack(state)
|
|
return None
|
|
|
|
def _execute_immediate(
|
|
self, state: CombatantState, target: CombatantState | None, action: Action
|
|
) -> None:
|
|
"""Execute an immediate action (off-turn or in-turn).
|
|
|
|
PF1e (SRD Immediate Actions): "Using an immediate action on your turn
|
|
is the same as using a swift action and counts as your swift action
|
|
for that turn." An immediate fired during the creature's OWN turn
|
|
therefore spends the CURRENT turn's swift (``swift_used_this_turn``:
|
|
swift actions later in the sequence are dropped, and no further
|
|
immediate fires this round). Off-turn, the immediate consumes the
|
|
NEXT turn's swift (``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``.
|
|
"""
|
|
if self._turn_actor is state:
|
|
state.swift_used_this_turn = True
|
|
else:
|
|
state.immediate_used = True
|
|
if action.spell_name is not None:
|
|
self._execute_cast_spell(
|
|
state, target, action.spell_name, target_pos=action.target_pos,
|
|
metamagic=action.metamagic, cast_defensively=action.cast_defensively,
|
|
)
|
|
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 _execute_activate_power_attack(self, state: CombatantState) -> None:
|
|
"""Activate Power Attack as a free action (requires the feat)."""
|
|
if not self._has_feat(state, "Power Attack"):
|
|
self._log(
|
|
f"round {self._current_round} {state.combatant.id}: "
|
|
f"activate_power_attack: no Power Attack feat"
|
|
)
|
|
return
|
|
state.power_attack = True
|
|
self._log(
|
|
f"round {self._current_round} {state.combatant.id}: "
|
|
f"Power Attack activated"
|
|
)
|
|
|
|
def _check_reactions(self, event: ReactionEvent) -> None:
|
|
"""Fire readied actions first, then offer the event to the reaction policy.
|
|
|
|
A pending readied action fires independently of ``reaction_policy``;
|
|
at most one trigger resolves per event (a fired readied action
|
|
suppresses the policy loop). One immediate per event: the first active
|
|
combatant (in state order) whose policy returns an action gets to act.
|
|
PF1e (SRD "Immediate Actions"): "You also cannot use an immediate
|
|
action if you are flat-footed" — flat-footed states are skipped.
|
|
Reactions never cascade — while a reaction is executing, further
|
|
events are ignored.
|
|
"""
|
|
if self._in_reaction:
|
|
return
|
|
if self._check_readied(event):
|
|
return
|
|
if self._reaction_policy is None:
|
|
return
|
|
for state in self._states:
|
|
if (
|
|
not state.active
|
|
or state.immediate_used
|
|
or state.swift_used_this_turn
|
|
or self._has_condition_named(state, "flat_footed")
|
|
):
|
|
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 _expire_readied_action(self, state: CombatantState) -> None:
|
|
"""PF1e (SRD "Ready — Initiative Consequences of Readying"): "If you
|
|
come to your next action and have not yet performed your readied
|
|
action, you don't get to take the readied action (though you can
|
|
ready the same action again)." An untriggered readied action expires
|
|
at the START of the combatant's next turn, before the policy runs (a
|
|
forfeited turn still expires it)."""
|
|
if state.readied is None:
|
|
return
|
|
self._log(
|
|
f"round {self._current_round} {state.combatant.id}: "
|
|
"ready expired (trigger never occurred)"
|
|
)
|
|
state.readied = None
|
|
state.readied_trigger = None
|
|
|
|
def _check_readied(self, event: ReactionEvent) -> bool:
|
|
"""Fire the first matching readied action; True when one fired.
|
|
|
|
Any active combatant (in state order) whose pending trigger matches
|
|
the event executes its payload off-turn with no swift cost. The
|
|
pending action is cleared BEFORE the payload runs, so a readied
|
|
action is one-shot and never re-fires on its own events; the payload's
|
|
own events are additionally suppressed by ``_in_reaction``.
|
|
"""
|
|
for state in self._states:
|
|
payload = state.readied
|
|
if not state.active or payload is None:
|
|
continue
|
|
if not self._ready_trigger_matches(state, event):
|
|
continue
|
|
trigger = state.readied_trigger
|
|
state.readied = None
|
|
state.readied_trigger = None
|
|
self._log(
|
|
f"round {self._current_round} {state.combatant.id}: "
|
|
f"ready fires ({trigger})"
|
|
)
|
|
self._ready_shift_initiative(state, event.actor)
|
|
target = next(
|
|
(s for s in self._states if s.combatant.id == payload.target_id),
|
|
None,
|
|
)
|
|
self._in_reaction = True
|
|
self._dispatch_action(state, target, payload)
|
|
self._in_reaction = False
|
|
return True
|
|
return False
|
|
|
|
def _ready_shift_initiative(
|
|
self, state: CombatantState, trigger_actor: CombatantState
|
|
) -> None:
|
|
"""Move the readier's initiative to the count it acted on (PF1e).
|
|
|
|
PF1e (SRD "Ready — Initiative Consequences of Readying"): "Your
|
|
initiative result becomes the count on which you took the readied
|
|
action" and you "act immediately ahead of the character whose action
|
|
triggered your readied action" — for the rest of the encounter. The
|
|
slot is written into ``_initiative_keys`` with the same tuple shape
|
|
``_execute_delay`` uses (a 0.5 step off the anchor total, keeping the
|
|
readier's initiative modifier and prior list index) so
|
|
``_build_round_order`` holds the new position for every following
|
|
round. The step is ADDED (not subtracted as for delay): a readied
|
|
action interrupts and acts immediately AHEAD of the trigger, whereas a
|
|
delay postpones to act after — the two intents are opposite.
|
|
Deterministic: no RNG is consumed and the current round's order
|
|
(already built) is untouched — only subsequent rounds reorder.
|
|
"""
|
|
key = self._initiative_keys.get(state.combatant.id)
|
|
prior_index = key[2] if key is not None else self._states.index(state)
|
|
trigger_key = self._initiative_keys.get(trigger_actor.combatant.id)
|
|
trigger_total = trigger_key[0] if trigger_key is not None else 0.0
|
|
self._initiative_keys[state.combatant.id] = (
|
|
trigger_total + _DELAY_INITIATIVE_STEP,
|
|
state.combatant.initiative_mod,
|
|
prior_index,
|
|
)
|
|
|
|
def _ready_trigger_matches(
|
|
self, state: CombatantState, event: ReactionEvent
|
|
) -> bool:
|
|
"""True when the event matches the combatant's pending readied trigger.
|
|
|
|
Firing requires the event actor to be an ENEMY of the readier
|
|
(different side); ``enemy_hits_ally`` additionally requires the
|
|
damage target to share the readier's side. ``enemy_enters_reach``
|
|
fires on any enemy movement step — documented simplification: reach
|
|
is not re-computed per step.
|
|
"""
|
|
trigger = state.readied_trigger
|
|
if trigger is None:
|
|
return False
|
|
hostile = event.actor.side != state.side
|
|
if trigger == "enemy_enters_reach":
|
|
return hostile and event.kind == "movement_step"
|
|
if trigger == "enemy_casts":
|
|
return hostile and event.kind == "spell_cast"
|
|
if trigger == "enemy_attacks":
|
|
return hostile and event.kind == "attack_resolved"
|
|
return (
|
|
hostile
|
|
and event.kind == "damage_taken"
|
|
and event.target is not None
|
|
and event.target.side == state.side
|
|
)
|
|
|
|
def _execute_cast_spell(
|
|
self,
|
|
state: CombatantState,
|
|
target: CombatantState | None,
|
|
spell_name: str | None,
|
|
*,
|
|
target_pos: tuple[int, int] | None = None,
|
|
metamagic: tuple[str, ...] = (),
|
|
cast_defensively: bool = False,
|
|
) -> ActionResult | None:
|
|
"""Cast the carried spell; an explicit ``target_pos`` (U7b) is
|
|
validated first — an off-map point or one without line of effect
|
|
from the caster is rejected without casting. ``metamagic`` (B7)
|
|
selects the feats applied to THIS cast; ``cast_defensively`` (B24)
|
|
trades the casting AoO for a concentration check."""
|
|
if self.has_condition_flag(state, "cant_attack"):
|
|
# PF1e (CRB): a nauseated creature cannot cast spells or
|
|
# concentrate; the cant_attack flag blocks the cast entirely.
|
|
self._log(
|
|
f"round {self._current_round} {state.combatant.id}: "
|
|
f"cast_spell rejected (cannot attack)"
|
|
)
|
|
return None
|
|
if target_pos is not None:
|
|
rejection = self._point_target_rejection(state, target_pos)
|
|
if rejection is not None:
|
|
return rejection
|
|
if spell_name is None:
|
|
return None
|
|
return self._cast_spell(
|
|
state, target, spell_name, center_pos=target_pos,
|
|
metamagic=metamagic, cast_defensively=cast_defensively,
|
|
)
|
|
|
|
def _point_target_rejection(
|
|
self, state: CombatantState, target_pos: tuple[int, int]
|
|
) -> ActionResult | None:
|
|
"""U7b gate: a point-targeted area must be in bounds and reachable."""
|
|
if self._grid.in_bounds(target_pos) and has_line_of_effect_cells(
|
|
self._grid, state.occupied_cells(), frozenset({target_pos})
|
|
):
|
|
return None
|
|
self._log(
|
|
f"round {self._current_round} {state.combatant.id}: "
|
|
f"cast_spell rejected ({_REASON_INVALID_DESTINATION})"
|
|
)
|
|
return ActionResult(
|
|
kind="cast_spell", ok=False, reason=_REASON_INVALID_DESTINATION
|
|
)
|
|
|
|
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
|
|
# PF1e (SRD Trip / Bull Rush / Overrun; APG Reposition / Drag): "You
|
|
# can only [affect] an opponent who is no more than one size category
|
|
# larger than you." The attempt is illegal outright: no roll, no
|
|
# provoked AoO.
|
|
if maneuver in _SIZE_RESTRICTED_MANEUVERS and (
|
|
_size_rank(target.combatant.size) - _size_rank(state.combatant.size)
|
|
>= _MANEUVER_MAX_SIZE_DIFF
|
|
):
|
|
self._log(
|
|
f"round {self._current_round} {state.combatant.id}: {maneuver} vs "
|
|
f"{target.combatant.id} rejected (target too large)"
|
|
)
|
|
return
|
|
|
|
# PF1e (CRB, Combat Maneuver): attempting the maneuver provokes an
|
|
# attack of opportunity from the target — resolved BEFORE the CMB
|
|
# check — unless the attacker holds the Improved feat for it. Damage
|
|
# taken from that AoO is applied as a penalty to the maneuver check
|
|
# (CRB: "apply that amount as a penalty to the attack roll to perform
|
|
# the maneuver"); being knocked below 0 hp by it forfeits the
|
|
# maneuver.
|
|
aoo_damage = 0
|
|
if not self._has_feat(state, _IMPROVED_MANEUVER_FEATS[maneuver]):
|
|
hp_before = state.hp
|
|
self._maneuver_provoked_aoo(state, target)
|
|
aoo_damage = max(0, hp_before - state.hp)
|
|
if not state.active:
|
|
return
|
|
|
|
# PF1e (CRB Power Attack): the -X penalty applies to all melee attack
|
|
# rolls AND combat maneuver checks while the feat is active. The
|
|
# escape option that substitutes an Acrobatics/Escape Artist skill
|
|
# check is not penalized (a skill check is not a CMB check) — see
|
|
# _maneuver_escape.
|
|
pa_amt = self._power_attack_amt(state)
|
|
attacker_cmb = self._maneuver_cmb(state) - pa_amt
|
|
target_cmd = self._maneuver_cmd(target)
|
|
roll = self._rng.d20()
|
|
# PF1e (CRB Combat Maneuvers): the provoked-AoO damage rides the
|
|
# check total (floored at 0); natural 20/1 keep their rules below.
|
|
total = max(0, roll + attacker_cmb - aoo_damage)
|
|
if aoo_damage:
|
|
self._log(
|
|
f"round {self._current_round} {state.combatant.id}: "
|
|
f"maneuver AoO dealt {aoo_damage} damage "
|
|
f"(-{aoo_damage} on the check)"
|
|
)
|
|
m = _ManeuverRoll(
|
|
roll=roll, attacker_cmb=attacker_cmb, total=total,
|
|
target_cmd=target_cmd,
|
|
# PF1e: the CMB check is an attack roll — natural 20 always
|
|
# succeeds, natural 1 always fails.
|
|
success=roll == _NATURAL_TWENTY
|
|
or (roll != _NATURAL_ONE 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 _maneuver_provoked_aoo(
|
|
self, state: CombatantState, target: CombatantState
|
|
) -> None:
|
|
"""Resolve the single AoO a combat maneuver provokes from its target.
|
|
|
|
The target strikes only if it threatens the attacker, is able to
|
|
make AoOs (``_resolve_aoo`` honours ``cant_aoo``) and has per-round
|
|
AoO allowance left — same budget as movement/ranged provocation.
|
|
"""
|
|
if not self.threatened_squares(target) & state.occupied_cells():
|
|
return
|
|
if self._aoo_used.get(target.combatant.id, 0) >= self._aoo_allowance(target):
|
|
return
|
|
self._resolve_aoo(target, state)
|
|
|
|
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:
|
|
# PF1e (SRD Combat Maneuvers): "Creatures that are size Tiny or
|
|
# smaller use their Dexterity modifier in place of their Strength
|
|
# modifier to determine their CMB."
|
|
ability_mod = (
|
|
state.combatant.abilities.dex_mod
|
|
if state.combatant.size.lower() in _TINY_OR_SMALLER_SIZES
|
|
else state.combatant.abilities.str_mod
|
|
)
|
|
return (
|
|
state.combatant.cmb
|
|
or (state.combatant.bab + ability_mod + self._size_mod(state))
|
|
)
|
|
|
|
def _maneuver_cmd(self, target: CombatantState) -> int:
|
|
if target.combatant.cmd:
|
|
return target.combatant.cmd
|
|
# PF1e (SRD Combat Maneuvers): "CMD = 10 + Base attack bonus +
|
|
# Strength modifier + Dexterity modifier + special size modifier" —
|
|
# the Tiny-or-smaller Dex-for-Str substitution is CMB-only, so the
|
|
# ability component is computed here instead of via _maneuver_cmb.
|
|
target_base = target.combatant.cmb or (
|
|
target.combatant.bab
|
|
+ target.combatant.abilities.str_mod
|
|
+ self._size_mod(target)
|
|
)
|
|
# PF1e (SRD Combat Maneuver Defense): "A creature can also add any
|
|
# circumstance, deflection, dodge, insight, luck, morale, profane,
|
|
# and sacred bonuses to AC to its CMD. [...] A flat-footed creature
|
|
# does not add its Dexterity bonus to its CMD." A Dexterity penalty
|
|
# is not a bonus, so it still applies when flat-footed.
|
|
dex_mod = target.combatant.abilities.dex_mod
|
|
if self.has_condition_flag(target, "denies_dex_to_ac"):
|
|
dex_mod = min(0, dex_mod)
|
|
# PF1e (SRD Combat Maneuvers): "Any penalties to a creature's AC also
|
|
# apply to its CMD." — negative AC modifiers ride the CMD whatever
|
|
# their bonus type; only positive ones are restricted to the eight
|
|
# SRD-listed types.
|
|
ac_mods = [
|
|
m
|
|
for m in self._stat_modifiers(target, "ac")
|
|
if m.bonus_type in _CMD_AC_BONUS_TYPES or m.value < 0
|
|
]
|
|
# A combat maneuver is a melee attack roll (SRD: "Combat maneuvers are
|
|
# attack rolls"), so the prone defender's -4 melee AC penalty applies.
|
|
if self._has_condition_named(target, "prone"):
|
|
ac_mods.append(StatModifier(target="ac", value=-4))
|
|
ac_bonuses = resolve_modifiers(ac_mods)
|
|
# defensive_ac_bonus (total defense / fight defensively) is a dodge
|
|
# bonus and aid_ac_bonus a circumstance bonus to AC — both types the
|
|
# SRD lets a creature add to its CMD.
|
|
return (
|
|
10 + target_base + dex_mod + ac_bonuses
|
|
+ target.defensive_ac_bonus + target.aid_ac_bonus
|
|
)
|
|
|
|
def _maneuver_trip(
|
|
self, state: CombatantState, target: CombatantState, m: _ManeuverRoll
|
|
) -> None:
|
|
if not m.success:
|
|
self._maneuver_fail(state, target, "trip", m)
|
|
# PF1e (SRD Trip): "If your attack fails by 10 or more, you are
|
|
# knocked prone instead."
|
|
if m.total <= m.target_cmd - _TRIP_RETALIATION_MARGIN:
|
|
state.active_conditions.append(
|
|
ActiveCondition(
|
|
condition=CONDITIONS_BY_NAME["prone"],
|
|
rounds_left=1,
|
|
source="maneuver: trip retaliation",
|
|
)
|
|
)
|
|
self._log(
|
|
f"round {self._current_round} {state.combatant.id}: "
|
|
f"trip backfire -> attacker prone"
|
|
)
|
|
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
|
|
# PF1e (SRD Bull Rush): "your target is pushed back 5 feet. For every
|
|
# 5 by which your attack exceeds your opponent's CMD you can push the
|
|
# target back an additional 5 feet." A natural-20 auto-success below
|
|
# CMD still gets only the base 5 ft, hence the margin floor of 0.
|
|
distance = 1 + max(0, m.total - m.target_cmd) // _BULL_RUSH_EXTRA_PER_MARGIN
|
|
if direction is not None:
|
|
pushed = False
|
|
for _ in range(distance):
|
|
new_pos = (target.pos[0] + direction[0], target.pos[1] + direction[1])
|
|
if not self._cells_free(self._footprint_at(new_pos, target), target):
|
|
break
|
|
target.pos = new_pos
|
|
pushed = True
|
|
else:
|
|
pushed = self._bull_rush_push(state, target, distance)
|
|
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 _nearest_anchor_adjacent_to(
|
|
self, grappler: CombatantState, target: CombatantState
|
|
) -> Pos | None:
|
|
"""Closest free anchor where ``target``'s footprint is adjacent to
|
|
``grappler``'s occupied cells (SRD "Grapple" pull-to-adjacent).
|
|
|
|
Candidates are scored by 5-10-5 distance from the target's current
|
|
anchor; ties break on (row, col) for determinism. The target's own
|
|
current cells are excluded from the occupancy check (it is the thing
|
|
being moved), walls/out-of-bounds are never candidates, and adjacency
|
|
means the two footprints' cells are exactly one square apart.
|
|
"""
|
|
grappler_cells = grappler.occupied_cells()
|
|
best: tuple[int, Pos] | None = None
|
|
for row in range(self._grid.height):
|
|
for col in range(self._grid.width):
|
|
anchor: Pos = (row, col)
|
|
footprint = self._footprint_at(anchor, target)
|
|
if not all(
|
|
self._grid.passable(cell) for cell in footprint
|
|
):
|
|
continue
|
|
blocked = self._blocked(grappler, target)
|
|
if footprint & (blocked | grappler_cells):
|
|
continue
|
|
if not any(
|
|
self._grid.distance(g, t) == 1
|
|
for g in grappler_cells
|
|
for t in footprint
|
|
):
|
|
continue
|
|
dist = self._grid.distance(anchor, target.pos)
|
|
if best is None or (dist, anchor) < (best[0], best[1]):
|
|
best = (dist, anchor)
|
|
return None if best is None else best[1]
|
|
|
|
def _maneuver_grapple(
|
|
self, state: CombatantState, target: CombatantState, m: _ManeuverRoll
|
|
) -> None:
|
|
if not m.success:
|
|
self._maneuver_fail(state, target, "grapple", m)
|
|
return
|
|
# PF1e (SRD "Grapple"): "If you successfully grapple a creature that
|
|
# is not adjacent to you, move that creature to an adjacent open
|
|
# space (if no space is available, your grapple fails)."
|
|
if self.creature_distance(state, target) > 1:
|
|
anchor = self._nearest_anchor_adjacent_to(state, target)
|
|
if anchor is None:
|
|
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} -> FAILED (no adjacent space)"
|
|
)
|
|
return
|
|
start = target.pos
|
|
target.pos = anchor
|
|
self._log(
|
|
f"round {self._current_round} {state.combatant.id}: grapple pulls "
|
|
f"{target.combatant.id} ({start[0]},{start[1]})->"
|
|
f"({anchor[0]},{anchor[1]})"
|
|
)
|
|
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)
|
|
self._overrun_fail_stop(state, target)
|
|
return
|
|
# PF1e (CRB "Overrun"): a successful maneuver moves you through the
|
|
# target's square; the target is knocked prone only if the attack
|
|
# exceeds its CMD by 5 or more.
|
|
knocked_prone = m.total - m.target_cmd >= _OVERRUN_PRONE_MARGIN
|
|
if knocked_prone:
|
|
target.active_conditions.append(
|
|
ActiveCondition(
|
|
condition=CONDITIONS_BY_NAME["prone"],
|
|
rounds_left=1,
|
|
source="maneuver: overrun",
|
|
)
|
|
)
|
|
beyond = self._beyond_cell(state.pos, target.pos)
|
|
moved_through = self._cells_free(self._footprint_at(beyond, state), state)
|
|
if moved_through:
|
|
state.pos = beyond
|
|
achieved = ", ".join(
|
|
label
|
|
for label, flag in (("prone", knocked_prone), ("moved through", moved_through))
|
|
if flag
|
|
)
|
|
outcome = f"SUCCESS ({achieved})" if achieved else "SUCCESS"
|
|
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 _overrun_fail_stop(
|
|
self, state: CombatantState, target: CombatantState
|
|
) -> None:
|
|
"""Park a failed overrun on the open approach cell in front of the target.
|
|
|
|
PF1e (SRD Overrun): "If your overrun attempt fails, you stop in the
|
|
space directly in front of the opponent, or the nearest open space
|
|
in front of the creature if there are other creatures occupying that
|
|
space." An already-adjacent attacker is in front: no move, no log.
|
|
"""
|
|
ar, ac = state.pos
|
|
tr, tc = target.pos
|
|
steps = max(abs(tr - ar), abs(tc - ac))
|
|
if steps <= 1:
|
|
return
|
|
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)
|
|
for i in range(steps - 1, 0, -1):
|
|
cell = (ar + dr * i, ac + dc * i)
|
|
if self._cells_free(self._footprint_at(cell, state), state):
|
|
start = state.pos
|
|
state.pos = cell
|
|
self._log(
|
|
f"round {self._current_round} {state.combatant.id}: "
|
|
f"overrun blocked, stopped ({start[0]},{start[1]})->"
|
|
f"({cell[0]},{cell[1]})"
|
|
)
|
|
return
|
|
|
|
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]
|
|
# PF1e (APG Dirty Trick): "This condition lasts for 1 round. For
|
|
# every 5 by which your attack exceeds your opponent's CMD, the
|
|
# penalty lasts 1 additional round."
|
|
rounds = 1 + max(0, m.total - m.target_cmd) // _DIRTY_TRICK_EXTRA_ROUND_MARGIN
|
|
target.active_conditions.append(
|
|
ActiveCondition(
|
|
condition=condition,
|
|
rounds_left=rounds,
|
|
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} {rounds}r)"
|
|
)
|
|
|
|
def _maneuver_steal(
|
|
self, state: CombatantState, target: CombatantState, m: _ManeuverRoll
|
|
) -> None:
|
|
if not m.success:
|
|
self._maneuver_fail(state, target, "steal", m)
|
|
return
|
|
# PF1e (APG Steal): "Items held in the hands (such as wielded weapons
|
|
# or wands) also cannot be taken with the steal maneuver—you must use
|
|
# the disarm combat maneuver instead." The model carries no non-held
|
|
# items, so every stealable target is held: a success takes nothing.
|
|
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} -> SUCCESS (nothing stealable — wielded "
|
|
f"weapons require disarm)"
|
|
)
|
|
|
|
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
|
|
# PF1e (APG Reposition): "you may move your target 5 feet to a new
|
|
# location. For every 5 by which your attack exceeds your opponent's
|
|
# CMD, you can move the target an additional 5 feet" — the walk stops
|
|
# at the first obstacle ("You cannot move a creature into a square
|
|
# that is occupied by a solid object or obstacle").
|
|
distance = 1 + max(0, m.total - m.target_cmd) // _REPOSITION_EXTRA_PER_MARGIN
|
|
step = direction
|
|
if step is None:
|
|
# First free neighbor (the attacker's own square is occupied, so
|
|
# it can never be picked here).
|
|
for dr, dc in _NEIGHBOR_OFFSETS:
|
|
cand = (target.pos[0] + dr, target.pos[1] + dc)
|
|
if self._cells_free(self._footprint_at(cand, target), target):
|
|
step = (dr, dc)
|
|
break
|
|
moved = False
|
|
if step is not None:
|
|
for _ in range(distance):
|
|
pos = (target.pos[0] + step[0], target.pos[1] + step[1])
|
|
if not self._cells_free(self._footprint_at(pos, target), target):
|
|
break
|
|
target.pos = pos
|
|
moved = True
|
|
outcome = f"SUCCESS (moved to {target.pos})" if moved else "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
|
|
# PF1e (APG Drag): "both you and your target are moved 5 feet back,
|
|
# with your opponent occupying your original space and you in the
|
|
# space behind that in a straight line. For every 5 by which your
|
|
# attack exceeds your opponent's CMD, you can drag the target back an
|
|
# additional 5 feet" — the line stops at the first obstacle.
|
|
distance = 1 + max(0, m.total - m.target_cmd) // _DRAG_EXTRA_PER_MARGIN
|
|
dragged = 0
|
|
for _ in range(distance):
|
|
ar, ac = state.pos
|
|
tr, tc = target.pos
|
|
adjacent = max(abs(tr - ar), abs(tc - ac)) == 1
|
|
if not adjacent:
|
|
break
|
|
if direction is not None:
|
|
beyond = (tr + direction[0], tc + direction[1])
|
|
else:
|
|
beyond = self._beyond_cell(state.pos, target.pos)
|
|
if not self._cells_free(self._footprint_at(beyond, target), target):
|
|
break
|
|
target_old = target.pos
|
|
target.pos = beyond
|
|
state.pos = target_old
|
|
dragged += 1
|
|
outcome = "SUCCESS (dragged)" if dragged else "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
|
|
# PF1e (SRD Combat Maneuvers): "Rolling a natural 20 while attempting
|
|
# a combat maneuver is always a success (except when attempting to
|
|
# escape from bonds)" — escaping a grapple is a combat maneuver, not
|
|
# bonds, so the natural 20 auto-succeeds here too.
|
|
success = roll == _NATURAL_TWENTY or (roll != _NATURAL_ONE 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). Damaged creatures get a
|
|
Will save (DC 10 + half the channeler's level + her Cha modifier)
|
|
for half damage (CRB Channel Energy).
|
|
"""
|
|
if not state.spend_resource("channel_energy", 1):
|
|
self._log(
|
|
f"round {self._current_round} {state.combatant.id}: "
|
|
f"channel energy failed (no resources)"
|
|
)
|
|
return
|
|
if target is None or not target.active:
|
|
return
|
|
|
|
# Channel energy affects all creatures within 30ft (6 cells)
|
|
radius_cells = 30 // _SQUARE_FT # 6 cells
|
|
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
|
|
|
|
# PF1e (CRB, Channel Energy): creatures damaged by the channel get a
|
|
# Will save (DC 10 + half the channeler's level + her Charisma
|
|
# modifier) for half damage. Charisma is used RAW even when the
|
|
# caster's spellcasting ability is another one (channeling is a
|
|
# Charisma class feature). Heal mode grants no save.
|
|
channel_dc = 10 + state.combatant.level // 2 + (
|
|
(state.combatant.abilities.cha_score - 10) // 2
|
|
)
|
|
|
|
for affected_state in hit:
|
|
if channel_mode == "heal":
|
|
self._channel_heal(state, affected_state, dmg)
|
|
else:
|
|
self._channel_damage(state, affected_state, dmg, channel_dc)
|
|
|
|
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)
|
|
# PF1e (CRB, Stable Characters and Recovery): "If any sort of healing
|
|
# cures the dying character of even 1 point of damage, he becomes
|
|
# stable and stops losing hit points."
|
|
if affected_state.hp < 0:
|
|
affected_state.stabilized_hp = affected_state.hp
|
|
actual = affected_state.hp - old_hp
|
|
self._stats[state.combatant.id].damage_dealt += actual
|
|
self._stop_bleeding(affected_state)
|
|
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, dc: int
|
|
) -> None:
|
|
"""Apply channel energy damage: Will save (DC from the channeler) for half."""
|
|
save = self.resolve_save(affected_state, "will", dc)
|
|
dealt = dmg // 2 if save.success else dmg
|
|
hp_before = affected_state.hp
|
|
affected_state.hp -= dealt
|
|
self._stats[state.combatant.id].damage_dealt += dealt
|
|
self._stats[affected_state.combatant.id].damage_taken += dealt
|
|
self._log(
|
|
f"round {self._current_round} {state.combatant.id}: channel energy "
|
|
f"damages {affected_state.combatant.id} {dealt} "
|
|
f"(Will DC{dc} d20={save.roll}+{save.total - save.roll}={save.total} "
|
|
f"{'SAVED half' if save.success else 'FAILED full'}) "
|
|
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=dealt,
|
|
)
|
|
)
|
|
|
|
def _execute_hex(
|
|
self, state: CombatantState, target: CombatantState | None, hex_name: str | None
|
|
) -> None:
|
|
"""Execute a witch hex (evil_eye, slumber, cackle).
|
|
|
|
APG Cackle is a free action with no hex resource cost, so it is
|
|
dispatched before the resource/target gate; evil_eye and slumber are
|
|
standard actions that each spend one use."""
|
|
if hex_name == "cackle":
|
|
self._hex_cackle(state)
|
|
return
|
|
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"):
|
|
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)
|
|
else:
|
|
self._hex_slumber(state, target, hex_dc)
|
|
|
|
_EVIL_EYE_ROUNDS = 3
|
|
_EVIL_EYE_SAVED_ROUNDS = 1
|
|
|
|
def _hex_evil_eye(
|
|
self, state: CombatantState, target: CombatantState, hex_dc: int
|
|
) -> None:
|
|
"""Evil Eye (APG): -2 on one chosen option for 3 rounds; a Will save
|
|
reduces it to 1 round (never negates). Simplification: the chosen
|
|
option is attack rolls — the engine's other hex targets are not
|
|
selectable per cast."""
|
|
save_result = self.resolve_save(target, "will", hex_dc)
|
|
rounds = self._EVIL_EYE_SAVED_ROUNDS if save_result.success else self._EVIL_EYE_ROUNDS
|
|
target.active_buffs.append(
|
|
ActiveBuff(
|
|
modifiers=(StatModifier(target="attack", value=-2),),
|
|
rounds_left=rounds,
|
|
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} -> -2 attack ({rounds}r)"
|
|
)
|
|
|
|
def _hex_slumber(
|
|
self, state: CombatantState, target: CombatantState, hex_dc: int
|
|
) -> None:
|
|
"""Slumber (APG): the target remains asleep for a number of rounds
|
|
equal to the witch's level; a successful Will save negates.
|
|
Simplification documented in README: the RAW ending of the hex when
|
|
the target takes damage is not modeled (no wake-on-damage hook)."""
|
|
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
|
|
rounds = state.combatant.level
|
|
target.active_conditions.append(
|
|
ActiveCondition(
|
|
condition=CONDITIONS_BY_NAME["asleep"],
|
|
rounds_left=rounds,
|
|
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 ({rounds}r)"
|
|
)
|
|
|
|
_CACKLE_RANGE_FT = 30
|
|
|
|
def _hex_cackle(self, state: CombatantState) -> None:
|
|
"""Cackle (APG): a free action that extends the duration of every
|
|
evil eye, fortune or misfortune hex on all creatures within 30 ft by
|
|
1 round. Fortune/misfortune are not implemented, so the extension
|
|
covers evil_eye buffs; each buff list is rebuilt in a single pass so
|
|
an extended hex is never re-extended."""
|
|
extended = 0
|
|
for other in self._states:
|
|
if not other.active:
|
|
continue
|
|
if self.creature_distance(state, other) * _SQUARE_FT > self._CACKLE_RANGE_FT:
|
|
continue
|
|
other.active_buffs[:] = [
|
|
ActiveBuff(
|
|
modifiers=buff.modifiers,
|
|
rounds_left=buff.rounds_left + 1,
|
|
source=buff.source,
|
|
)
|
|
if buff.source.startswith("hex: evil_eye")
|
|
else buff
|
|
for buff in other.active_buffs
|
|
]
|
|
extended += sum(
|
|
1 for buff in other.active_buffs if buff.source.startswith("hex: evil_eye")
|
|
)
|
|
if extended:
|
|
self._log(
|
|
f"round {self._current_round} {state.combatant.id}: cackle extends "
|
|
f"{extended} evil eye hex(es)"
|
|
)
|
|
else:
|
|
self._log(
|
|
f"round {self._current_round} {state.combatant.id}: cackle - no hex "
|
|
f"to extend"
|
|
)
|
|
|
|
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).
|
|
|
|
PF1e (CRB, Magus Spell Combat): as a full-round action the magus casts
|
|
a standard-action spell and makes all of his melee attacks at -2. The
|
|
penalty rides the full-attack chain to every swing and crit confirm,
|
|
and — "any attack roll made as part of this spell also takes this
|
|
penalty" — the spell's own melee/ranged touch attack too.
|
|
Simplification: the spell may target any resolved enemy (the RAW
|
|
spellcasting-as-move restriction and arcane pool cost are not modeled).
|
|
Spell Combat requires a casting time of 1 standard action; the policy
|
|
only proposes such spells, and a one-round spell would simply resolve
|
|
on the caster's next turn via ``PendingCast``.
|
|
"""
|
|
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 — PF1e (CRB, Magus Spell Combat): "any attack roll
|
|
# made as part of this spell also takes this penalty", so the -2 rides
|
|
# the spell's touch attack too.
|
|
self._cast_spell(
|
|
state, target, spell_name, attack_penalty=_SPELL_COMBAT_ATTACK_PENALTY
|
|
)
|
|
# Then make a full attack at -2 (Spell Combat penalty)
|
|
self._execute_attack(
|
|
state, target, "full_attack", attack_penalty=_SPELL_COMBAT_ATTACK_PENALTY
|
|
)
|
|
|
|
def _execute_inspire_courage(
|
|
self, state: CombatantState, target: CombatantState | None
|
|
) -> None:
|
|
"""Execute Inspire Courage (30ft radius, costs bardic_rounds).
|
|
|
|
PF1e (CRB, Bardic Performance — Inspire Courage): +1 competence bonus
|
|
on attack rolls and weapon damage rolls, and a +1 morale bonus on
|
|
saving throws against charm and fear effects. Simplifications: the
|
|
scaling (+1 per four bard levels) is not modeled (flat +1), and the
|
|
engine has no "charm" descriptor, so mind_affecting stands in for it
|
|
(documented over-coverage: compulsion effects also count). The save
|
|
bonus is carried by two conditional modifiers (fear /
|
|
mind_affecting); a save with both descriptors still totals +1 because
|
|
morale does not stack with itself.
|
|
"""
|
|
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 competence attack/damage and +1 morale vs charm/fear saves
|
|
for ally in affected:
|
|
ally.active_buffs.append(
|
|
ActiveBuff(
|
|
modifiers=(
|
|
StatModifier(target="attack", value=1, bonus_type="competence"),
|
|
StatModifier(target="damage", value=1, bonus_type="competence"),
|
|
StatModifier(
|
|
target="will", value=1, bonus_type="morale",
|
|
condition="fear",
|
|
),
|
|
StatModifier(
|
|
target="will", value=1, bonus_type="morale",
|
|
condition="mind_affecting",
|
|
),
|
|
),
|
|
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 competence attack/damage, +1 morale vs "
|
|
f"charm/fear saves to {len(affected)} allies"
|
|
)
|
|
def _execute_attack(
|
|
self,
|
|
state: CombatantState,
|
|
target: CombatantState | None,
|
|
kind: str,
|
|
weapon_id: str | None = None,
|
|
*,
|
|
attack_penalty: int = 0,
|
|
) -> ActionResult | None:
|
|
"""Execute an attack / full_attack (U5d: optional explicit weapon).
|
|
|
|
With ``weapon_id`` the attack is made with THAT weapon; a choice that
|
|
is unknown, broken, stolen, or unable to strike the target is logged
|
|
and rejected with ``weapon_unavailable`` (no attack is made). Without
|
|
it the ``weapon_for`` auto-selection is unchanged. ``attack_penalty``
|
|
is applied to every roll of the action (Spell Combat's -2). Returns an
|
|
explicit rejection, or None when the helper ran (or silently no-oped)
|
|
and the caller reports ok.
|
|
"""
|
|
if self.has_condition_flag(state, "cant_attack"):
|
|
self._log(f"round {self._current_round} {state.combatant.id}: wait (cannot attack)")
|
|
return None
|
|
if target is None:
|
|
return None
|
|
chosen: AttackSpec | None = None
|
|
if weapon_id is not None:
|
|
chosen = self._chosen_weapon(state, target, weapon_id)
|
|
if chosen is None:
|
|
self._log(
|
|
f"round {self._current_round} {state.combatant.id}: "
|
|
f"{kind} rejected ({_REASON_WEAPON_UNAVAILABLE})"
|
|
)
|
|
return ActionResult(
|
|
kind=kind, ok=False, reason=_REASON_WEAPON_UNAVAILABLE
|
|
)
|
|
if kind == "attack":
|
|
weapon = chosen if chosen is not None else self.weapon_for(state, target)
|
|
if weapon is None:
|
|
return None
|
|
self._single_attack(state, target, weapon)
|
|
elif kind == "full_attack":
|
|
if chosen is not None:
|
|
self._full_attack_weapon(
|
|
state, target, chosen, attack_penalty=attack_penalty
|
|
)
|
|
else:
|
|
self._full_attack(state, target, attack_penalty=attack_penalty)
|
|
return None
|
|
|
|
def _chosen_weapon(
|
|
self, attacker: CombatantState, target: CombatantState, weapon_id: str
|
|
) -> AttackSpec | None:
|
|
"""The usable weapon ``weapon_id``, if it can strike ``target`` (U5d).
|
|
|
|
None when the id is unknown or the weapon is broken/stolen (not in
|
|
``usable_attacks()``), or when it cannot reach the target / lacks
|
|
line of effect.
|
|
"""
|
|
for weapon in attacker.usable_attacks():
|
|
if weapon.id == weapon_id:
|
|
if self._weapon_usable_against(attacker, target, weapon):
|
|
return weapon
|
|
return None
|
|
return None
|
|
|
|
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 _casting_ability_mod(self, caster: CombatantState) -> int:
|
|
"""Modifier of the casting ability (the one used for bonus spells)."""
|
|
ability_name = caster.combatant.casting_ability.lower()
|
|
ability_score = getattr(caster.combatant.abilities, f"{ability_name}_score")
|
|
return (ability_score - 10) // 2
|
|
|
|
def _spell_dc(
|
|
self, caster: CombatantState, spell: SpellSpec, *, slot_level: int | None = None
|
|
) -> int:
|
|
"""Spell save DC: 10 + spell level + casting ability modifier.
|
|
|
|
PF1e (CRB, B7): a metamagic spell uses the level of the slot cast
|
|
from, so ``slot_level`` overrides the spell's base level when given.
|
|
"""
|
|
level = spell.level if slot_level is None else slot_level
|
|
return 10 + level + self._casting_ability_mod(caster)
|
|
|
|
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": "",
|
|
"half_on_save": False,
|
|
"save_negates": False,
|
|
"area": None,
|
|
"cast_time": "standard",
|
|
}
|
|
half_on_save = _spell_half_on_save(spell)
|
|
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),
|
|
"half_on_save": half_on_save,
|
|
"save_negates": spell.save != "none" and not half_on_save,
|
|
"area": _spell_area_dict(spell),
|
|
"cast_time": spell.cast_time,
|
|
}
|
|
|
|
def _cast_spell(
|
|
self,
|
|
caster: CombatantState,
|
|
target: CombatantState | None,
|
|
spell_name: str,
|
|
*,
|
|
center_pos: tuple[int, int] | None = None,
|
|
metamagic: tuple[str, ...] = (),
|
|
cast_defensively: bool = False,
|
|
attack_penalty: int = 0,
|
|
) -> ActionResult | None:
|
|
"""Cast ``spell_name``.
|
|
|
|
``center_pos`` (U7b) centers a top-level ``AreaEffect`` on that cell
|
|
instead of the target creature's position. It is honoured only for
|
|
spells carrying an area; every other effect still resolves on the
|
|
target. A cast with no resolved target requires ``center_pos`` and an
|
|
area effect — the primary save is skipped and each creature in the
|
|
area rolls its own. ``metamagic`` (B7) lists the feats applied to
|
|
THIS cast (only feats the caster knows apply, and the spell then
|
|
occupies — and must have — a slot of base level + adjustment).
|
|
``cast_defensively`` (B24) skips the casting AoO for a concentration
|
|
check. ``attack_penalty`` folds into this cast's touch attack rolls
|
|
(PF1e CRB Magus Spell Combat: "any attack roll made as part of this
|
|
spell also takes this penalty"). Returns an explicit rejection
|
|
(``no_slot``) when the required slot is unavailable; None means the
|
|
cast proceeded through the gates.
|
|
"""
|
|
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 None
|
|
# PF1e (CRB, Metamagic Feats): metamagic is chosen per cast and only
|
|
# the feats the caster actually knows can be applied (B7).
|
|
known = set(caster.combatant.metamagic_feats)
|
|
applied = tuple(f for f in metamagic if f in known)
|
|
used_level = spell.level + sum(
|
|
_METAMAGIC_SLOT_ADJUST.get(f, 0) for f in applied
|
|
)
|
|
has_area = any(isinstance(e, AreaEffect) for e in spell.effects)
|
|
if self._spell_cast_rejected(
|
|
caster, target, spell, spell_name, center_pos, has_area=has_area
|
|
):
|
|
return None
|
|
# PF1e (CRB): a caster cannot cast without an available slot of the
|
|
# spell's level (metamagic-inflated, B7); cantrips (level 0) are cast
|
|
# freely. The slot is spent up-front — a later failed concentration
|
|
# check or missed touch attack still burns it.
|
|
if used_level > 0 and not caster.spend_spell_slot(used_level):
|
|
self._log(
|
|
f"round {self._current_round} {caster.combatant.id}: "
|
|
f"cast {spell_name} -> NO SPELL SLOT (level {used_level})"
|
|
)
|
|
return ActionResult(
|
|
kind="cast_spell", ok=False, reason="no_slot",
|
|
detail=f"level {used_level}",
|
|
)
|
|
# PF1e (CRB, Concentration, B24): grappled/defensive/threatened casts
|
|
# must clear their interruption gate before the spell is delivered.
|
|
if self._spell_cast_interrupted(
|
|
caster, used_level, cast_defensively=cast_defensively
|
|
):
|
|
return None
|
|
if spell.cast_time == "one_round":
|
|
# PF1e (SRD "Casting Time"): "A spell that takes 1 round to cast
|
|
# is a full-round action. It comes into effect just before the
|
|
# beginning of your turn in the round after you began casting the
|
|
# spell." The slot is spent and the start-of-casting gates have
|
|
# run; delivery (touch attack, SR, saves, effects — "You make all
|
|
# pertinent decisions about a spell [...] when the spell comes
|
|
# into effect") is deferred to _resolve_pending_cast.
|
|
caster.pending_cast = PendingCast(
|
|
spell_name=spell_name,
|
|
target_id=target.combatant.id if target is not None else None,
|
|
center_pos=center_pos,
|
|
metamagic=applied,
|
|
slot_level=used_level,
|
|
cast_defensively=cast_defensively,
|
|
hp_at_cast=caster.hp,
|
|
)
|
|
self._log(
|
|
f"round {self._current_round} {caster.combatant.id}: "
|
|
f"begins casting {spell_name} (1 round)"
|
|
)
|
|
return None
|
|
# PF1e (CRB): hostile touch delivery needs a melee touch attack (B14)
|
|
# and spell resistance can still turn the spell away. A confirmed
|
|
# touch crit doubles the spell's damage (CRB "Range — Touch").
|
|
self._deliver_cast(
|
|
caster, target, spell, spell_name, center_pos,
|
|
metamagic=applied, slot_level=used_level,
|
|
attack_penalty=attack_penalty,
|
|
)
|
|
return None
|
|
|
|
def _deliver_cast(
|
|
self,
|
|
caster: CombatantState,
|
|
target: CombatantState | None,
|
|
spell: SpellSpec,
|
|
spell_name: str,
|
|
center_pos: tuple[int, int] | None,
|
|
*,
|
|
metamagic: tuple[str, ...],
|
|
slot_level: int,
|
|
attack_penalty: int = 0,
|
|
) -> None:
|
|
"""Post-gate delivery: touch attack, spell resistance, then effects.
|
|
|
|
Shared by the immediate cast path and the resolution of a pending
|
|
one-round cast (SRD "Casting Time").
|
|
"""
|
|
has_area = any(isinstance(e, AreaEffect) for e in spell.effects)
|
|
blocked, delivery_crit = self._spell_delivery_blocked(
|
|
caster, target, spell, has_area=has_area, attack_penalty=attack_penalty
|
|
)
|
|
if blocked:
|
|
return
|
|
self._apply_and_log_cast(
|
|
caster, target, spell, spell_name, center_pos,
|
|
metamagic=metamagic, slot_level=slot_level, spell_crit=delivery_crit,
|
|
attack_penalty=attack_penalty,
|
|
)
|
|
|
|
def _spell_cast_interrupted(
|
|
self, caster: CombatantState, spell_level: int, *, cast_defensively: bool
|
|
) -> bool:
|
|
"""Pre-delivery interruption gate (B24).
|
|
|
|
PF1e (CRB, Concentration): while grappled the caster must pass
|
|
DC 10 + grappler's CMB + spell level to cast at all; casting
|
|
defensively forgoes the provocation but requires DC 15 + 2x spell
|
|
level (Combat Casting's +4 applies to both); otherwise casting in a
|
|
threatened square provokes the AoO (with the injured-casting check).
|
|
True = the spell is lost (the slot is already spent).
|
|
"""
|
|
if caster.grappled_with is not None or cast_defensively:
|
|
if not self._check_concentration(caster, spell_level, 0):
|
|
dc = self._concentration_dc(caster, spell_level, 0)
|
|
situation = (
|
|
"while grappled" if caster.grappled_with is not None else "defensively"
|
|
)
|
|
self._log(
|
|
f"round {self._current_round} {caster.combatant.id}: "
|
|
f"casting {situation}: concentration failed (DC {dc}), spell lost"
|
|
)
|
|
return True
|
|
return False
|
|
return self._check_casting_aoo(caster, spell_level)
|
|
|
|
def _spell_delivery_blocked(
|
|
self,
|
|
caster: CombatantState,
|
|
target: CombatantState | None,
|
|
spell: SpellSpec,
|
|
*,
|
|
has_area: bool,
|
|
attack_penalty: int = 0,
|
|
) -> tuple[bool, bool]:
|
|
"""Delivery gates: a hostile touch spell must land its melee touch
|
|
attack (B14) and a hostile ranged touch spell (ray) must land its
|
|
ranged touch attack (cover, concealment and crits included), then
|
|
spell resistance must be overcome. Returns ``(blocked, crit)``:
|
|
True blocked = no effect (the slot is already spent); ``crit`` is True
|
|
when a damage-dealing touch delivery was confirmed on a natural 20
|
|
(the spell's damage dice are then doubled)."""
|
|
crit = False
|
|
deals_damage = any(isinstance(e, DamageEffect) for e in spell.effects)
|
|
# PF1e multi-projectile rays (Scorching Ray): each projectile rolls its
|
|
# own ranged touch attack, resolved per-projectile in
|
|
# _apply_damage_effect, so the single delivery attack is skipped here.
|
|
per_projectile_attack = _spell_declares_projectiles(spell)
|
|
if (
|
|
target is not None
|
|
and spell.range == "touch"
|
|
and target.side != caster.side
|
|
):
|
|
hit, crit = self._spell_touch_attack_hits(
|
|
caster, target,
|
|
attack_penalty=attack_penalty, deals_damage=deals_damage,
|
|
)
|
|
if not hit:
|
|
return True, False
|
|
if (
|
|
target is not None
|
|
and spell.requires_ranged_touch
|
|
and not has_area
|
|
and not per_projectile_attack
|
|
and target.side != caster.side
|
|
):
|
|
hit, ranged_crit = self._spell_ranged_touch_attack_hits(
|
|
caster, target,
|
|
attack_penalty=attack_penalty, deals_damage=deals_damage,
|
|
)
|
|
if not hit:
|
|
return True, False
|
|
crit = ranged_crit
|
|
if (
|
|
target is not None
|
|
and not has_area
|
|
and spell.spell_resistance
|
|
and not self._check_spell_resistance(caster, target)
|
|
):
|
|
return True, False
|
|
return False, crit
|
|
|
|
def _spell_touch_attack_hits(
|
|
self,
|
|
caster: CombatantState,
|
|
target: CombatantState,
|
|
*,
|
|
attack_penalty: int = 0,
|
|
deals_damage: bool = False,
|
|
) -> tuple[bool, bool]:
|
|
"""Melee touch attack to deliver a touch-range spell (B14).
|
|
|
|
PF1e (CRB): d20 + caster's melee attack bonus vs the target's touch
|
|
AC, PLUS the target's cover bonus (a touch attack is an attack roll —
|
|
CRB "Cover") and subject to the target's concealment miss chance (CRB
|
|
"Concealment"); a spell that deals damage threatens on a natural 20
|
|
and deals double damage on a successful confirmation (CRB "Cast a
|
|
Spell — Touch Attacks": "You can score critical hits with either type
|
|
of attack as long as the spell deals damage"; CRB "Range — Touch").
|
|
``attack_penalty`` folds into the bonus (Spell Combat: "any attack
|
|
roll made as part of this spell also takes this penalty"). Engine
|
|
simplification (documented): the bonus is BAB + Strength modifier —
|
|
Weapon Finesse and other situational touch bonuses are not modeled;
|
|
natural 20 hits and natural 1 misses per the engine's attack-roll
|
|
convention. The touch AC reuses the resolve_attack denies-Dex path
|
|
(flat-footed/running target, invisible attacker).
|
|
Returns ``(hit, crit)``.
|
|
"""
|
|
roll = self._rng.d20()
|
|
bonus = caster.combatant.bab + caster.combatant.abilities.str_mod + attack_penalty
|
|
denies_dex = (
|
|
self.has_condition_flag(target, "denies_dex_to_ac")
|
|
or target.running
|
|
or self._has_condition_named(caster, "invisible")
|
|
)
|
|
if denies_dex:
|
|
ac = target.combatant.ac.touch - max(0, target.combatant.abilities.dex_mod)
|
|
else:
|
|
ac = target.combatant.ac.touch
|
|
ac += self._attack_cover_bonus(caster, target, ranged=False)
|
|
return self._resolve_touch_delivery(
|
|
caster, target, label="touch attack",
|
|
roll=roll, bonus=bonus, ac=ac, deals_damage=deals_damage,
|
|
)
|
|
|
|
def _spell_ranged_touch_attack_hits(
|
|
self,
|
|
caster: CombatantState,
|
|
target: CombatantState,
|
|
*,
|
|
attack_penalty: int = 0,
|
|
deals_damage: bool = False,
|
|
) -> tuple[bool, bool]:
|
|
"""Ranged touch attack to deliver a ray spell.
|
|
|
|
PF1e (CRB, Magic — Ranged Touch Attacks): d20 + caster's ranged attack
|
|
bonus vs the target's touch AC, PLUS the target's ranged cover bonus
|
|
(terrain + soft cover, CRB "Cover") and subject to the concealment
|
|
miss chance (CRB "Concealment"); a ray that deals damage can score a
|
|
critical hit (CRB "Cast a Spell — Touch Attacks"). The spell's range
|
|
is a single absolute value so there is no range-increment penalty.
|
|
``attack_penalty`` folds into the bonus (Spell Combat: "any attack
|
|
roll made as part of this spell also takes this penalty"). Engine
|
|
simplification (documented): the bonus is BAB + Dexterity modifier —
|
|
size modifier, Weapon Finesse and other situational bonuses are not
|
|
modeled; natural 20 hits and natural 1 misses per the engine's
|
|
attack-roll convention. The touch AC reuses the resolve_attack
|
|
denies-Dex path (flat-footed/running target, invisible attacker).
|
|
Returns ``(hit, crit)``.
|
|
"""
|
|
roll = self._rng.d20()
|
|
bonus = caster.combatant.bab + caster.combatant.abilities.dex_mod + attack_penalty
|
|
denies_dex = (
|
|
self.has_condition_flag(target, "denies_dex_to_ac")
|
|
or target.running
|
|
or self._has_condition_named(caster, "invisible")
|
|
)
|
|
if denies_dex:
|
|
ac = target.combatant.ac.touch - max(0, target.combatant.abilities.dex_mod)
|
|
else:
|
|
ac = target.combatant.ac.touch
|
|
ac += self._attack_cover_bonus(caster, target, ranged=True)
|
|
return self._resolve_touch_delivery(
|
|
caster, target, label="ranged touch attack",
|
|
roll=roll, bonus=bonus, ac=ac, deals_damage=deals_damage,
|
|
)
|
|
|
|
def _resolve_touch_delivery(
|
|
self,
|
|
caster: CombatantState,
|
|
target: CombatantState,
|
|
*,
|
|
label: str,
|
|
roll: int,
|
|
bonus: int,
|
|
ac: int,
|
|
deals_damage: bool,
|
|
) -> tuple[bool, bool]:
|
|
"""Finish a touch-attack roll: hit check, crit threat/confirm (only
|
|
when the spell deals damage, CRB "Cast a Spell — Touch Attacks"), then
|
|
the concealment miss chance (a blinded caster faces total concealment,
|
|
same max(target, 50) convention as _resolve_hit_damage)."""
|
|
hit = roll == _NATURAL_TWENTY or (roll != _NATURAL_ONE and roll + bonus >= ac)
|
|
self._log(
|
|
f"round {self._current_round} {caster.combatant.id}: {label} vs "
|
|
f"{target.combatant.id} d20={roll}+{bonus}={roll + bonus} AC {ac} "
|
|
f"-> {'HIT' if hit else 'MISS'}"
|
|
)
|
|
if not hit:
|
|
return False, False
|
|
crit = False
|
|
if deals_damage and roll == _NATURAL_TWENTY:
|
|
confirm = self._rng.d20()
|
|
crit = confirm == _NATURAL_TWENTY or (
|
|
confirm != _NATURAL_ONE and confirm + bonus >= ac
|
|
)
|
|
self._log(
|
|
f"round {self._current_round} {caster.combatant.id}: {label} "
|
|
f"crit confirm d20={confirm}+{bonus}={confirm + bonus} AC {ac} "
|
|
f"-> {'CRIT' if crit else 'no crit'}"
|
|
)
|
|
concealment = self._concealment_chance(target)
|
|
blinded = self._has_condition_named(caster, "blinded")
|
|
miss_chance = max(concealment, _TOTAL_CONCEALMENT_PCT) if blinded else concealment
|
|
if miss_chance > 0:
|
|
pct = self._rng.d100()
|
|
missed = pct <= miss_chance
|
|
source = "attacker blinded" if blinded else "concealment"
|
|
self._log(
|
|
f"round {self._current_round} {caster.combatant.id}: "
|
|
f"{source} vs {target.combatant.id}: "
|
|
f"d100({pct}) {'<=' if missed else '>'} {miss_chance}% "
|
|
f"— {'spell lost' if missed else 'spell delivered'}"
|
|
)
|
|
if missed:
|
|
return False, False
|
|
return True, crit
|
|
|
|
def _spell_cast_rejected(
|
|
self,
|
|
caster: CombatantState,
|
|
target: CombatantState | None,
|
|
spell: SpellSpec,
|
|
spell_name: str,
|
|
center_pos: tuple[int, int] | None,
|
|
*,
|
|
has_area: bool,
|
|
) -> bool:
|
|
"""Pre-cast gates: resolvable target (or U7b point cast of an area
|
|
spell), spell range and line of effect. True = not castable."""
|
|
if target is None:
|
|
point_ok = self._point_cast_castable(
|
|
caster, spell, spell_name, center_pos, has_area=has_area
|
|
)
|
|
return not point_ok
|
|
if not target.active:
|
|
return True
|
|
dist_ft = self.creature_distance(caster, target) * _SQUARE_FT
|
|
if dist_ft > spell_range_ft(spell, caster.combatant.level):
|
|
self._log(
|
|
f"round {self._current_round} {caster.combatant.id}: "
|
|
f"cast {spell_name} -> OUT OF RANGE"
|
|
)
|
|
return True
|
|
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 True
|
|
return False
|
|
|
|
def _point_cast_castable(
|
|
self,
|
|
caster: CombatantState,
|
|
spell: SpellSpec,
|
|
spell_name: str,
|
|
center_pos: tuple[int, int] | None,
|
|
*,
|
|
has_area: bool,
|
|
) -> bool:
|
|
"""U7b point cast (no resolved target): needs an area spell centered
|
|
on ``center_pos``, within the spell's range of the caster."""
|
|
if center_pos is None or not has_area:
|
|
return False
|
|
point_dist = min(
|
|
self._grid.distance(cell, center_pos)
|
|
for cell in caster.occupied_cells()
|
|
) * _SQUARE_FT
|
|
if point_dist > spell_range_ft(spell, caster.combatant.level):
|
|
self._log(
|
|
f"round {self._current_round} {caster.combatant.id}: "
|
|
f"cast {spell_name} -> OUT OF RANGE"
|
|
)
|
|
return False
|
|
return True
|
|
|
|
def _apply_and_log_cast(
|
|
self,
|
|
caster: CombatantState,
|
|
target: CombatantState | None,
|
|
spell: SpellSpec,
|
|
spell_name: str,
|
|
center_pos: tuple[int, int] | None,
|
|
*,
|
|
metamagic: tuple[str, ...] = (),
|
|
slot_level: int | None = None,
|
|
spell_crit: bool = False,
|
|
attack_penalty: int = 0,
|
|
) -> None:
|
|
"""Resolve save, apply effects and log a spell cast (post-gates).
|
|
|
|
``metamagic`` holds the feats chosen for THIS cast (already filtered
|
|
to known ones, B7); ``slot_level`` is the spell's level in the slot
|
|
used — the save DC is computed from it (PF1e CRB: a metamagic spell
|
|
uses the DC of the higher slot level). ``spell_crit`` is a confirmed
|
|
touch-delivery critical: the spell's damage dice are doubled (CRB
|
|
"Range — Touch": "deals double damage on a successful critical hit").
|
|
``attack_penalty`` (Spell Combat) folds into each projectile's ranged
|
|
touch attack for multi-projectile ray spells.
|
|
"""
|
|
dc = self._spell_dc(caster, spell, slot_level=slot_level)
|
|
save_result = (
|
|
self.resolve_save(
|
|
target, spell.save, dc, save_descriptors=spell_save_descriptors(spell)
|
|
)
|
|
if spell.save != "none" and target is not None
|
|
else None
|
|
)
|
|
caster_live = self._stats[caster.combatant.id]
|
|
target_live = (
|
|
self._stats[target.combatant.id] if target is not None else caster_live
|
|
)
|
|
# Apply only the metamagic feats chosen for this cast (B7)
|
|
metamagic_effects = self._apply_metamagic(spell, metamagic)
|
|
# PF1e multi-projectile rays (Scorching Ray): the delivery gate skipped
|
|
# its single ranged touch attack, so each projectile rolls its own here
|
|
# — only against a hostile target (a friendly ray needs no attack).
|
|
per_projectile_attack = (
|
|
spell.requires_ranged_touch
|
|
and _spell_declares_projectiles(spell)
|
|
and target is not None
|
|
and target.side != caster.side
|
|
)
|
|
hp_before = target.hp if target is not None else caster.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,
|
|
maximize="Maximize Spell" in metamagic,
|
|
dc=dc, center_pos=center_pos, save_type=spell.save,
|
|
crit=spell_crit, ranged_touch=per_projectile_attack,
|
|
attack_penalty=attack_penalty, spell_resistance=spell.spell_resistance,
|
|
)
|
|
save_str = _save_label(save_result)
|
|
if target is None and center_pos is not None:
|
|
self._log(
|
|
f"round {self._current_round} {caster.combatant.id}: "
|
|
f"cast {spell_name} at ({center_pos[0]},{center_pos[1]}) "
|
|
f"DC{dc} {save_str} "
|
|
f"-> {'; '.join(parts)}"
|
|
)
|
|
elif target is not None:
|
|
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, save_type=spell.save
|
|
)
|
|
|
|
def _apply_spell_effects_from_list(
|
|
self,
|
|
effects: tuple[SpellEffect, ...],
|
|
target: CombatantState | None,
|
|
save_result: SaveResult | None,
|
|
caster_live: _LiveStats,
|
|
target_live: _LiveStats,
|
|
*,
|
|
caster: CombatantState,
|
|
spell_name: str,
|
|
empower: bool = False,
|
|
maximize: bool = False,
|
|
dc: int = 15,
|
|
center_pos: tuple[int, int] | None = None,
|
|
save_type: SaveType = "none",
|
|
crit: bool = False,
|
|
ranged_touch: bool = False,
|
|
attack_penalty: int = 0,
|
|
spell_resistance: bool = True,
|
|
) -> 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, center_pos=center_pos, save_type=save_type,
|
|
crit=crit, ranged_touch=ranged_touch, attack_penalty=attack_penalty,
|
|
spell_resistance=spell_resistance,
|
|
)
|
|
for effect in effects
|
|
]
|
|
|
|
def _apply_single_spell_effect(
|
|
self,
|
|
effect: SpellEffect,
|
|
target: CombatantState | None,
|
|
caster_live: _LiveStats,
|
|
target_live: _LiveStats,
|
|
caster: CombatantState,
|
|
*,
|
|
spell_name: str,
|
|
empower: bool = False,
|
|
maximize: bool = False,
|
|
saved: bool,
|
|
dc: int = 15,
|
|
center_pos: tuple[int, int] | None = None,
|
|
save_type: SaveType = "none",
|
|
crit: bool = False,
|
|
ranged_touch: bool = False,
|
|
attack_penalty: int = 0,
|
|
spell_resistance: bool = True,
|
|
) -> 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,
|
|
center_pos=center_pos, save_type=save_type,
|
|
spell_resistance=spell_resistance,
|
|
)
|
|
if isinstance(effect, SummonEffect):
|
|
return self._apply_summon_effect(effect, caster, caster_live, spell_name)
|
|
if target is None:
|
|
return ""
|
|
return self._apply_targeted_spell_effect(
|
|
effect, target, caster_live, target_live,
|
|
caster=caster, saved=saved, empower=empower, maximize=maximize,
|
|
spell_name=spell_name, crit=crit, ranged_touch=ranged_touch,
|
|
attack_penalty=attack_penalty,
|
|
)
|
|
|
|
def _apply_targeted_spell_effect(
|
|
self,
|
|
effect: DamageEffect | ConditionEffect | HealEffect | BuffEffect,
|
|
target: CombatantState,
|
|
caster_live: _LiveStats,
|
|
target_live: _LiveStats,
|
|
*,
|
|
caster: CombatantState,
|
|
saved: bool,
|
|
empower: bool,
|
|
maximize: bool,
|
|
spell_name: str,
|
|
crit: bool = False,
|
|
ranged_touch: bool = False,
|
|
attack_penalty: int = 0,
|
|
) -> str:
|
|
if isinstance(effect, DamageEffect):
|
|
return self._apply_damage_effect(
|
|
effect, target, caster_live, target_live,
|
|
saved=saved, empower=empower, maximize=maximize, caster=caster,
|
|
crit=crit, ranged_touch=ranged_touch, attack_penalty=attack_penalty,
|
|
spell_name=spell_name,
|
|
)
|
|
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,
|
|
crit: bool = False,
|
|
ranged_touch: bool = False,
|
|
attack_penalty: int = 0,
|
|
spell_name: str = "",
|
|
) -> str:
|
|
# PF1e (CRB, Magic — Caster Level): per-level spell damage is resolved
|
|
# against the caster's level at cast time; the scaled formula then
|
|
# flows through crits, energy resistance and metamagic unchanged.
|
|
formula = effect.effective_formula(caster.combatant.level)
|
|
# PF1e multi-projectile spells (SRD Magic Missile: "one missile, plus
|
|
# one additional missile per two caster levels beyond 1st [...]
|
|
# maximum of five"; Scorching Ray: "one ray, plus one additional ray
|
|
# for every four levels beyond 3rd [...] maximum of three"): each
|
|
# projectile rolls its own damage and, for a ray, its own attack.
|
|
count = (
|
|
effect.projectiles.count_for(caster.combatant.level)
|
|
if effect.projectiles is not None
|
|
else 1
|
|
)
|
|
total = 0
|
|
for _ in range(count):
|
|
dmg = self._resolve_one_projectile(
|
|
effect, formula, target, caster,
|
|
saved=saved, empower=empower, maximize=maximize,
|
|
crit=crit, ranged_touch=ranged_touch, attack_penalty=attack_penalty,
|
|
)
|
|
if dmg is None:
|
|
continue # this ray missed — no damage, no stat/reaction
|
|
caster_live.damage_dealt += dmg
|
|
target_live.damage_taken += dmg
|
|
self._check_reactions(
|
|
ReactionEvent(
|
|
kind="damage_taken", actor=caster, target=target, damage=dmg
|
|
)
|
|
)
|
|
total += dmg
|
|
# PF1e: if the target drops mid-sequence the remaining projectiles
|
|
# are lost (the engine's existing multi-attack convention).
|
|
if not target.active:
|
|
break
|
|
# PF1e recurring damage (S2c, Acid Arrow): a landed hit leaves acid
|
|
# that keeps burning for rounds_for(caster_level) rounds. Only attached
|
|
# while the target is still standing (a dead target has no turns).
|
|
if effect.recurring is not None and target.active:
|
|
self._attach_recurring_damage(effect.recurring, target, caster, spell_name)
|
|
return f"{total} damage"
|
|
|
|
def _resolve_one_projectile(
|
|
self,
|
|
effect: DamageEffect,
|
|
formula: DiceExpr,
|
|
target: CombatantState,
|
|
caster: CombatantState,
|
|
*,
|
|
saved: bool,
|
|
empower: bool,
|
|
maximize: bool,
|
|
crit: bool,
|
|
ranged_touch: bool,
|
|
attack_penalty: int,
|
|
) -> int | None:
|
|
"""Resolve one projectile's hit + damage; None when the ray misses.
|
|
|
|
Rolls the projectile's own ranged touch attack (when the spell is a
|
|
ray), then its own damage dice through maximize / crit / save /
|
|
empower / energy resistance, and applies the damage to the target.
|
|
"""
|
|
proj_crit = crit
|
|
if ranged_touch:
|
|
# Each ray rolls its own ranged touch attack (cover, concealment
|
|
# and critical threat included).
|
|
hit, proj_crit = self._spell_ranged_touch_attack_hits(
|
|
caster, target,
|
|
attack_penalty=attack_penalty, deals_damage=True,
|
|
)
|
|
if not hit:
|
|
return None
|
|
raw = formula.roll(self._rng)
|
|
if maximize:
|
|
raw = formula.max_value()
|
|
if proj_crit:
|
|
# PF1e (CRB "Range — Touch"): a touch/ranged-touch spell that
|
|
# deals damage threatens on a natural 20 and deals double
|
|
# damage on a successful critical hit.
|
|
raw *= 2
|
|
if saved and effect.half_on_save:
|
|
dmg = max(0, raw // 2)
|
|
elif saved and effect.save_negates:
|
|
dmg = 0
|
|
else:
|
|
dmg = raw
|
|
if empower:
|
|
dmg = int(dmg * 1.5)
|
|
dmg = self._apply_energy_resistance(dmg, frozenset(effect.types), target)
|
|
target.hp -= dmg
|
|
return dmg
|
|
|
|
def _attach_recurring_damage(
|
|
self,
|
|
spec: RecurringDamageSpec,
|
|
target: CombatantState,
|
|
caster: CombatantState,
|
|
spell_name: str,
|
|
) -> None:
|
|
"""Add a recurring-damage source to a target that just took the hit."""
|
|
rounds = spec.rounds_for(caster.combatant.level)
|
|
if rounds <= 0:
|
|
return
|
|
target.recurring_damage.append(
|
|
RecurringDamage(
|
|
formula=spec.formula,
|
|
types=frozenset(spec.types),
|
|
rounds_left=rounds,
|
|
source=f"acid: {spell_name}",
|
|
)
|
|
)
|
|
|
|
def _apply_condition_effect(
|
|
self,
|
|
effect: ConditionEffect,
|
|
target: CombatantState,
|
|
*,
|
|
saved: bool,
|
|
spell_name: str,
|
|
) -> str:
|
|
if saved and effect.save_negates:
|
|
# PF1e partial save (SRD spells/fear.md): a successful save does
|
|
# not always spare the target — it may impose a weaker condition
|
|
# (Fear: shaken for 1 round instead of panicked).
|
|
if effect.save_partial is not None:
|
|
name, duration = effect.save_partial
|
|
applied = self._impose_condition(target, name, duration, spell_name)
|
|
if not applied:
|
|
return ""
|
|
return f"{name} ({duration}r) (partial save)"
|
|
return f"{effect.condition_name} negated"
|
|
return self._impose_condition(
|
|
target, effect.condition_name, effect.duration_rounds, spell_name
|
|
)
|
|
|
|
def _impose_condition(
|
|
self,
|
|
target: CombatantState,
|
|
condition_name: str,
|
|
duration_rounds: int,
|
|
spell_name: str,
|
|
) -> str:
|
|
"""Append a timed condition to the target; handle the bleeding DoT."""
|
|
cond = CONDITIONS_BY_NAME.get(condition_name)
|
|
if cond is None:
|
|
return ""
|
|
target.active_conditions.append(
|
|
ActiveCondition(
|
|
condition=cond,
|
|
rounds_left=duration_rounds,
|
|
source=f"spell: {spell_name}",
|
|
)
|
|
)
|
|
if condition_name == "bleeding":
|
|
# The bleeding condition's damage-over-time is engine-driven
|
|
# (CombatantState.bleed_damage, CRB Conditions — bleeding); the
|
|
# SRD Bleed cantrip (spells/bleed.md) deals 1 bleed damage/round.
|
|
target.bleed_damage = max(target.bleed_damage, 1)
|
|
return f"{condition_name} ({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)
|
|
# PF1e (CRB, Stable Characters and Recovery): "If any sort of healing
|
|
# cures the dying character of even 1 point of damage, he becomes
|
|
# stable and stops losing hit points."
|
|
if target.hp < 0:
|
|
target.stabilized_hp = target.hp
|
|
actual = target.hp - old_hp
|
|
self._stop_bleeding(target)
|
|
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 | None,
|
|
caster_live: _LiveStats,
|
|
target_live: _LiveStats,
|
|
*,
|
|
caster: CombatantState,
|
|
spell_name: str,
|
|
saved: bool,
|
|
empower: bool = False,
|
|
maximize: bool = False,
|
|
dc: int = 15,
|
|
center_pos: tuple[int, int] | None = None,
|
|
save_type: SaveType = "none",
|
|
spell_resistance: bool = True,
|
|
) -> str:
|
|
"""Apply an area effect to all creatures within its template.
|
|
|
|
PF1e (CRB, Magic - Aiming a Spell): a burst/spread centers on
|
|
``center_pos`` when given (U7b point targeting), otherwise on the
|
|
resolved target's position; a cone or line starts at the CASTER's
|
|
square and is aimed at that same resolved cell (the cone "shoots
|
|
away from you in a quarter-circle in the direction you designate",
|
|
the line "shoots away from you in a line in the direction you
|
|
designate"). Every creature whose occupied cells intersect the
|
|
template is subject to the effect.
|
|
"""
|
|
center = _burst_center(target, center_pos)
|
|
if center is None:
|
|
return "no targets in area"
|
|
affected = self._area_creatures(effect, caster, center)
|
|
|
|
if not affected:
|
|
return "no targets in area"
|
|
|
|
total_damage = 0
|
|
total_conditions = 0
|
|
total_buffs = 0
|
|
|
|
for affected_state in affected:
|
|
if spell_resistance and not self._check_spell_resistance(caster, affected_state):
|
|
continue
|
|
creature_saved = self._area_save(
|
|
affected_state, target, effect, saved=saved, dc=dc,
|
|
save_type=save_type,
|
|
)
|
|
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 _area_creatures(
|
|
self, effect: AreaEffect, caster: CombatantState, aim: tuple[int, int]
|
|
) -> list[CombatantState]:
|
|
"""Creatures intersecting the template of an area effect.
|
|
|
|
Burst: circle of ``radius_ft`` around the resolved center ``aim``
|
|
(unchanged behaviour). Cone/line: the template originates at the
|
|
caster's anchor cell and opens toward ``aim`` (PF1e CRB "Aiming a
|
|
Spell" — see ``areas.py``); a creature is affected when ANY of its
|
|
occupied cells lies inside the template.
|
|
"""
|
|
span_cells = effect.radius_ft // _SQUARE_FT
|
|
if effect.shape == "burst":
|
|
return self._creatures_in_range(aim, span_cells)
|
|
origin = caster.pos
|
|
if effect.shape == "cone":
|
|
cells = cone_cells(self._grid, origin, aim, span_cells)
|
|
else:
|
|
cells = line_cells(self._grid, origin, aim, span_cells)
|
|
return [
|
|
state
|
|
for state in self._states
|
|
if state.active and state.occupied_cells() & cells
|
|
]
|
|
|
|
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 | None,
|
|
effect: AreaEffect,
|
|
*,
|
|
saved: bool,
|
|
dc: int = 15,
|
|
save_type: SaveType = "none",
|
|
) -> bool:
|
|
"""Save result for one creature in an area effect.
|
|
|
|
PF1e (CRB): every creature in the area rolls the save the SPELL lists
|
|
(Will / Fortitude / Reflex) — "save negates" and "save for half" both
|
|
grant it; whether a success negates or halves is decided per sub-effect
|
|
(``save_negates`` / ``half_on_save``), never by skipping the roll.
|
|
The resolved primary target re-uses its already-rolled save; every
|
|
other creature in the area — and every creature of a point-targeted
|
|
burst (no primary target, U7b) — rolls its own.
|
|
"""
|
|
if save_type == "none":
|
|
return False
|
|
if affected_state is target:
|
|
return saved
|
|
save_result = self.resolve_save(
|
|
affected_state,
|
|
save_type,
|
|
dc,
|
|
save_descriptors=save_descriptors_for_effects(effect.effects),
|
|
)
|
|
return save_result.success
|
|
|
|
def _apply_metamagic(
|
|
self, spell: SpellSpec, metamagic_feats: tuple[str, ...]
|
|
) -> tuple[SpellEffect, ...]:
|
|
"""Apply the feats chosen for THIS cast to the spell's effects (B7)."""
|
|
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
|
|
partial = effect.save_partial
|
|
if partial is not None and has_extend:
|
|
partial = (partial[0], partial[1] * 2)
|
|
return ConditionEffect(
|
|
condition_name=effect.condition_name,
|
|
duration_rounds=duration,
|
|
save_negates=effect.save_negates,
|
|
save_partial=partial,
|
|
)
|
|
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(
|
|
shape=effect.shape,
|
|
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._apply_simple_monster_death(summoned_state)
|
|
|
|
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,
|
|
attack_penalty: int = 0,
|
|
) -> bool:
|
|
"""Resolve one swing, log it, update stats. Return True if target dropped.
|
|
|
|
``attack_penalty`` is folded into the attack bonus (and thus the crit
|
|
confirmation): Spell Combat's -2 (PF1e, Magus Spell Combat) rides the
|
|
full-attack chain to every swing.
|
|
"""
|
|
live = self._stats[attacker.combatant.id]
|
|
target_live = self._stats[target.combatant.id]
|
|
hp_before = target.hp
|
|
override = bonus_override
|
|
if attack_penalty:
|
|
base = weapon.attack_bonus if override is None else override
|
|
override = base + attack_penalty
|
|
result = self.resolve_attack(attacker, target, weapon, bonus_override=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 ""
|
|
)
|
|
melee_str = f"{result.melee_penalty}(melee)" if result.melee_penalty else ""
|
|
bonus_str = (
|
|
f"{result.base_bonus}{result.penalty:+d}{flank_str}{high_ground_str}{melee_str}"
|
|
if result.penalty
|
|
else f"{result.base_bonus}{flank_str}{high_ground_str}{melee_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,
|
|
*,
|
|
attack_penalty: int = 0,
|
|
) -> 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, attack_penalty=attack_penalty
|
|
):
|
|
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,
|
|
*,
|
|
attack_penalty: int = 0,
|
|
) -> 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, attack_penalty=attack_penalty
|
|
)
|
|
return
|
|
for weapon in melee_weapons:
|
|
if not attacker.active or not target.active:
|
|
return
|
|
self._full_attack_weapon(
|
|
attacker, target, weapon, attack_penalty=attack_penalty
|
|
)
|
|
|
|
def _full_attack_weapon(
|
|
self,
|
|
attacker: CombatantState,
|
|
target: CombatantState,
|
|
weapon: AttackSpec,
|
|
*,
|
|
attack_penalty: int = 0,
|
|
) -> None:
|
|
"""Resolve one weapon's full-attack swings (count swings or BAB iteratives)."""
|
|
if weapon.count > 1:
|
|
self._attack(attacker, target, weapon, attack_penalty=attack_penalty)
|
|
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,
|
|
attack_penalty=attack_penalty,
|
|
):
|
|
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
|
|
inner_cells, outer_cells = _melee_reach_zone_cells(attacker, weapon)
|
|
return inner_cells * _SQUARE_FT <= dist_ft <= outer_cells * _SQUARE_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 _step5_to(self, state: CombatantState, destination: Pos) -> ActionResult:
|
|
"""5-foot step to an explicit adjacent cell — never provokes (U5c)."""
|
|
kind = "5foot_step"
|
|
if state.moved_this_turn:
|
|
return ActionResult(kind=kind, ok=True, reason=_REASON_OK)
|
|
if self.has_condition_flag(state, "cant_move"):
|
|
self._log(f"round {self._current_round} {state.combatant.id}: wait (cannot move)")
|
|
return ActionResult(kind=kind, ok=True, reason=_REASON_OK)
|
|
adjacent = self._grid.distance(state.pos, destination) == 1
|
|
free = self._cells_free(self._footprint_at(destination, state), state)
|
|
# PF1e (SRD Measuring Distance): "You can't move diagonally past a
|
|
# corner (even by taking a 5-foot step)."
|
|
diagonal = state.pos[0] != destination[0] and state.pos[1] != destination[1]
|
|
corner_clear = not diagonal or self._grid.diagonal_allowed(state.pos, destination)
|
|
if not adjacent or not free or not corner_clear:
|
|
self._log(
|
|
f"round {self._current_round} {state.combatant.id}: "
|
|
f"{kind} rejected ({_REASON_UNREACHABLE_DESTINATION})"
|
|
)
|
|
return ActionResult(
|
|
kind=kind, ok=False, reason=_REASON_UNREACHABLE_DESTINATION
|
|
)
|
|
start = state.pos
|
|
state.pos = destination
|
|
state.moved_this_turn = True
|
|
self._log(
|
|
f"round {self._current_round} {state.combatant.id}: "
|
|
f"5ft step ({start[0]},{start[1]})->({destination[0]},{destination[1]})"
|
|
)
|
|
return ActionResult(kind=kind, ok=True, reason=_REASON_OK)
|
|
|
|
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, friendly_passable=True)
|
|
start = state.pos
|
|
actual_path = self._walk_steps(state, path)
|
|
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_to(self, state: CombatantState, destination: Pos) -> ActionResult:
|
|
"""Move action to an explicit cell within speed (U5c).
|
|
|
|
Validated against the Dijkstra cost field from the combatant's position
|
|
under its speed budget; walked step-by-step with the same AoO
|
|
machinery as the auto-pathed move.
|
|
"""
|
|
kind = "move"
|
|
if state.moved_this_turn:
|
|
return ActionResult(kind=kind, ok=True, reason=_REASON_OK)
|
|
if self.has_condition_flag(state, "cant_move"):
|
|
self._log(f"round {self._current_round} {state.combatant.id}: wait (cannot move)")
|
|
return ActionResult(kind=kind, ok=True, reason=_REASON_OK)
|
|
if not self._destination_reachable(state, destination, friendly_passable=True):
|
|
self._log(
|
|
f"round {self._current_round} {state.combatant.id}: "
|
|
f"{kind} rejected ({_REASON_UNREACHABLE_DESTINATION})"
|
|
)
|
|
return ActionResult(
|
|
kind=kind, ok=False, reason=_REASON_UNREACHABLE_DESTINATION
|
|
)
|
|
path = self._descend_path(state, destination, friendly_passable=True)
|
|
self._walk_steps(state, path)
|
|
state.moved_this_turn = True
|
|
self._log(
|
|
f"round {self._current_round} {state.combatant.id}: "
|
|
f"move -> ({destination[0]},{destination[1]})"
|
|
)
|
|
return ActionResult(kind=kind, ok=True, reason=_REASON_OK)
|
|
|
|
def _destination_reachable(
|
|
self,
|
|
state: CombatantState,
|
|
destination: Pos,
|
|
*,
|
|
speed_factor: int = 1,
|
|
friendly_passable: bool = False,
|
|
avoid_difficult: bool = False,
|
|
) -> bool:
|
|
"""True when destination is passable, unoccupied and within this speed.
|
|
|
|
``speed_factor`` widens the budget (run: 4x speed, U5e).
|
|
``friendly_passable`` routes the cost field around allies' squares
|
|
(move/run may cross them, SRD Movement); the destination itself must
|
|
still be completely free. ``avoid_difficult`` removes difficult cells
|
|
from the cost field, mirroring the descent constraint of a run
|
|
(CRB: "You can't run or charge across difficult terrain") so the
|
|
reachability verdict and the walked path agree.
|
|
"""
|
|
speed_cells = int(
|
|
_speed_ft(state)
|
|
// _SQUARE_FT
|
|
* self._get_speed_mult(state)
|
|
* speed_factor
|
|
)
|
|
if speed_cells <= 0:
|
|
return destination == state.pos
|
|
if not self._cells_free(self._footprint_at(destination, state), state):
|
|
return False
|
|
if avoid_difficult and self._footprint_touches_difficult(
|
|
self._footprint_at(destination, state)
|
|
):
|
|
return False
|
|
blocked = self._blocked_hostile(state) if friendly_passable else self._blocked(state)
|
|
if avoid_difficult:
|
|
blocked = blocked | self._difficult_cells()
|
|
field = self._grid.reachable(state.pos, speed_cells, blocked)
|
|
return destination in field
|
|
|
|
def _difficult_cells(self) -> frozenset[Pos]:
|
|
"""Every passable cell on the grid whose terrain costs more than one."""
|
|
return frozenset(
|
|
(row, col)
|
|
for row in range(self._grid.height)
|
|
for col in range(self._grid.width)
|
|
if self._is_difficult_cell((row, col))
|
|
)
|
|
|
|
def _walk_steps(
|
|
self, state: CombatantState, path: list[Pos]
|
|
) -> list[Pos]:
|
|
"""Walk a precomputed path cell by cell, resolving AoOs and reactions."""
|
|
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
|
|
return actual_path
|
|
|
|
def _move_path(
|
|
self, state: CombatantState, target: CombatantState, *, friendly_passable: bool = False
|
|
) -> 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.
|
|
"""
|
|
return self._descend_path(state, target.pos, friendly_passable=friendly_passable)
|
|
|
|
def _descend_path(
|
|
self,
|
|
state: CombatantState,
|
|
anchor: Pos,
|
|
*,
|
|
speed_factor: int = 1,
|
|
avoid_difficult: bool = False,
|
|
friendly_passable: bool = False,
|
|
) -> list[Pos]:
|
|
"""Greedy walk of the anchor over the cost field from ``anchor``.
|
|
|
|
``speed_factor`` widens the movement budget (run: 4x speed, U5e).
|
|
``avoid_difficult`` excludes difficult-terrain cells from the walk
|
|
(PF1e: a run cannot cross difficult terrain), so the walk stops in
|
|
front of the obstacle. ``friendly_passable`` lets the walk CROSS
|
|
same-side creatures' squares (move/run, PF1e SRD Movement) while the
|
|
tail is trimmed so the movement never ENDS on an occupied cell.
|
|
"""
|
|
speed_mult = self._get_speed_mult(state)
|
|
speed_cells = int(
|
|
_speed_ft(state)
|
|
// _SQUARE_FT
|
|
* speed_mult
|
|
* speed_factor
|
|
)
|
|
if speed_cells <= 0:
|
|
return []
|
|
blocked = self._blocked_hostile(state) if friendly_passable else self._blocked(state)
|
|
to_target = self._grid.reachable(anchor, 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,
|
|
avoid_difficult=avoid_difficult, friendly_passable=friendly_passable,
|
|
)
|
|
if best is None:
|
|
break
|
|
_, step_cost, nxt = best
|
|
current = nxt
|
|
path.append(current)
|
|
budget -= step_cost
|
|
if friendly_passable:
|
|
while path and not self._cells_free(self._footprint_at(path[-1], state), state):
|
|
path.pop()
|
|
return path
|
|
|
|
def _run_line_path(self, state: CombatantState, goal: Pos) -> list[Pos]:
|
|
"""Straight-line run path toward ``goal``, truncated at the first obstacle.
|
|
|
|
PF1e (SRD "Full-Round Actions — Run"): "When you run, you can move up
|
|
to four times your speed in a straight line" — a run CANNOT turn a
|
|
corner the way a Dijkstra ``move`` does. The path is the Bresenham
|
|
line from the combatant's anchor toward ``goal``, walked under the 4x
|
|
speed budget and stopped at the first obstacle, mirroring the charge's
|
|
straight-line clear-path logic (CRB "Charge"): impassable terrain, a
|
|
difficult-terrain cell (CRB "Difficult Terrain": "You can't run or
|
|
charge across difficult terrain"), or a blocking creature. A hostile
|
|
creature (including the target itself) halts the walk one cell short,
|
|
so an auto-directed run ends adjacent to the target. Friendly squares
|
|
are still crossed (SRD Movement, conformance L5) but never ended on —
|
|
the tail is trimmed like ``_descend_path``.
|
|
"""
|
|
speed_mult = self._get_speed_mult(state)
|
|
budget = int(
|
|
_speed_ft(state) // _SQUARE_FT * speed_mult * _RUN_SPEED_FACTOR
|
|
)
|
|
if budget <= 0:
|
|
return []
|
|
path: list[Pos] = []
|
|
current = state.pos
|
|
for nxt in _bresenham_line(state.pos, goal):
|
|
footprint = self._footprint_at(nxt, state)
|
|
if self._footprint_touches_difficult(footprint):
|
|
break
|
|
if not self._cells_walkable(footprint, state):
|
|
break
|
|
step = self._grid.step_cost(current, nxt, 0)
|
|
if step > budget:
|
|
break
|
|
path.append(nxt)
|
|
current = nxt
|
|
budget -= step
|
|
while path and not self._cells_free(self._footprint_at(path[-1], state), state):
|
|
path.pop()
|
|
return path
|
|
|
|
def _best_move_step(
|
|
self,
|
|
state: CombatantState,
|
|
current: Pos,
|
|
to_target: dict[Pos, int],
|
|
budget: int,
|
|
*,
|
|
avoid_difficult: bool = False,
|
|
friendly_passable: bool = False,
|
|
) -> 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
|
|
fits = self._cells_walkable if friendly_passable else self._cells_free
|
|
for d_row, d_col in _STEP_DELTAS:
|
|
nxt = (row + d_row, col + d_col)
|
|
# PF1e (CRB, Measuring Distance): "You can't move diagonally past
|
|
# a corner (even by taking a 5-foot step)" — same corner rule the
|
|
# explicit step (``_step5_to``) and flee (``_best_flee_step``)
|
|
# paths already enforce.
|
|
if not self._grid.diagonal_allowed(current, nxt):
|
|
continue
|
|
footprint = self._footprint_at(nxt, state)
|
|
if not fits(footprint, state):
|
|
continue
|
|
if avoid_difficult and self._footprint_touches_difficult(footprint):
|
|
continue
|
|
nxt_cost = self._footprint_cost(footprint, to_target)
|
|
if nxt_cost is None or nxt_cost >= current_cost:
|
|
continue
|
|
# Friendly square: enter it only to cross it — a free cell must
|
|
# descend from it, so the walk never dead-ends on an ally
|
|
# (movement may not END on an occupied cell).
|
|
if (
|
|
friendly_passable
|
|
and not self._cells_free(footprint, state)
|
|
and not self._has_free_descent(state, nxt, to_target, nxt_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 _has_free_descent(
|
|
self,
|
|
state: CombatantState,
|
|
pos: Pos,
|
|
to_target: dict[Pos, int],
|
|
pos_cost: int,
|
|
) -> bool:
|
|
"""True if some adjacent anchor is fully free and descends the field."""
|
|
row, col = pos
|
|
for d_row, d_col in _STEP_DELTAS:
|
|
footprint = self._footprint_at((row + d_row, col + d_col), state)
|
|
if not self._cells_free(footprint, state):
|
|
continue
|
|
cost = self._footprint_cost(footprint, to_target)
|
|
if cost is not None and cost < pos_cost:
|
|
return True
|
|
return False
|
|
|
|
def _footprint_touches_difficult(self, footprint: frozenset[Pos]) -> bool:
|
|
"""True if any footprint cell is difficult terrain (move cost > 1)."""
|
|
return any(self._is_difficult_cell(cell) for cell in footprint)
|
|
|
|
def _is_difficult_cell(self, pos: Pos) -> bool:
|
|
"""True for a passable cell whose terrain costs more than one square."""
|
|
if not self._grid.in_bounds(pos):
|
|
return False
|
|
terrain = self._grid.terrain_at(pos)
|
|
return terrain.move_cost is not None and terrain.move_cost > 1
|
|
|
|
def _flee_path(self, state: CombatantState, threat: CombatantState) -> list[Pos]:
|
|
"""Movement path away from ``threat``, up to double speed.
|
|
|
|
A staggered/disabled creature taking Restricted Withdraw (SRD "Full-Round
|
|
Actions": "you can withdraw as a standard action. In this case, you may
|
|
move up to your speed") gets a single-speed budget instead.
|
|
|
|
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)
|
|
withdraw_mult = 1 if self._is_staggered_limited(state) else 2
|
|
max_cells = int(
|
|
withdraw_mult * (_speed_ft(state) // _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.
|
|
|
|
Restricted Withdraw (SRD "Full-Round Actions"): a staggered/disabled
|
|
creature takes this as a standard action and moves up to its speed
|
|
(single, not double — see ``_flee_path``).
|
|
|
|
The starting square is not threatened — no AoO when leaving it.
|
|
Subsequent squares provoke AoOs normally (step-by-step, as for move).
|
|
Like every other movement it is mutually exclusive with movement
|
|
already spent this turn (``moved_this_turn``).
|
|
"""
|
|
if state.moved_this_turn:
|
|
return
|
|
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, distance: int
|
|
) -> bool:
|
|
"""Push target up to ``distance`` cells away from attacker.
|
|
|
|
The push stops at the first obstacle; returns True if the target
|
|
moved at least one cell.
|
|
"""
|
|
# 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
|
|
moved = False
|
|
for _ in range(distance):
|
|
new_pos = (target.pos[0] + dr, target.pos[1] + dc)
|
|
if not self._cells_free(self._footprint_at(new_pos, target), target):
|
|
break
|
|
target.pos = new_pos
|
|
moved = True
|
|
return moved
|
|
|
|
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 _is_castable_this_turn(engine: CombatEngine, spell_name: str) -> bool:
|
|
"""False only for long casting times (more than 1 round): the budget filter
|
|
would drop the cast, so the policy must never propose it."""
|
|
spell = engine.spell_registry.get(spell_name)
|
|
return spell is None or spell.cast_time != "long"
|
|
|
|
|
|
def _choose_spell_action(
|
|
engine: CombatEngine, state: CombatantState, target: CombatantState
|
|
) -> tuple[Action, ...] | None:
|
|
"""Select and return a spell action if appropriate, else None.
|
|
|
|
Long-casting-time spells are never selected (they cannot be scheduled
|
|
within a turn); the heal -> offensive -> buff order is unchanged.
|
|
"""
|
|
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 _is_castable_this_turn(engine, 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 _is_castable_this_turn(engine, 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 _is_castable_this_turn(engine, 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
|
|
|
|
|
|
_SWIFT_BONUS_KINDS: frozenset[str] = frozenset({"deed", "inspire_courage", "swift"})
|
|
# PF1e (SRD Conditions, nauseated): "The only action such a character can
|
|
# take is a single move action per turn" — genuine move actions only: the
|
|
# 5-foot step and dropping prone are free actions, not move actions, and
|
|
# run/charge/withdraw are full-round.
|
|
_NAUSEA_MOVE_KINDS: frozenset[str] = frozenset(
|
|
{"move", "stand_up", "retrieve_weapon", "repair_weapon"}
|
|
)
|
|
|
|
# Casting-time plumbing for the budget filters: a cast_spell action consumes
|
|
# the slot its spell's CastTime maps to. The pure filters take a resolver so
|
|
# tests can drive them without a registry; the engine binds its own.
|
|
CastTimeResolver = Callable[[Action], CastTime]
|
|
|
|
|
|
def _default_cast_time(_action: Action) -> CastTime:
|
|
return "standard"
|
|
|
|
|
|
def _cast_time_slot(cast_time: CastTime) -> _ActionEconomySlot:
|
|
"""Budget slot consumed by a cast_spell of the given casting time.
|
|
|
|
PF1e (CRB, Magic — Casting Time): a standard/swift casting time takes
|
|
that kind of action; an immediate action is off-turn and consumes the
|
|
next swift (handled via immediate_used, so it is budget-free here); a
|
|
free-action cast is free. one_round occupies the full-round slot (it IS
|
|
a full-round action, SRD "Casting Time") and additionally defers the
|
|
spell's effect to the start of the caster's next turn via
|
|
``PendingCast``. A long casting
|
|
time (more than 1 round: 3 rounds, 10 minutes, 1 hour, ...) cannot be
|
|
scheduled by the turn budget at all — the
|
|
engine drops such casts before the filter runs, so reaching this
|
|
function with "long" is a programming error.
|
|
"""
|
|
if cast_time == "standard":
|
|
return "standard"
|
|
if cast_time == "swift":
|
|
return "swift"
|
|
if cast_time in ("immediate", "free"):
|
|
return "free"
|
|
if cast_time in ("full_round", "one_round"):
|
|
return "full_round"
|
|
msg = f"long casting time cannot be scheduled by the budget filter: {cast_time!r}"
|
|
raise ValueError(msg)
|
|
|
|
|
|
def _effective_slot(
|
|
action: Action, cast_time: CastTimeResolver, *, free_draw: bool = False
|
|
) -> _ActionEconomySlot:
|
|
"""The budget slot an action consumes, resolving cast_spell by cast time
|
|
and the cackle hex by its APG free-action cost.
|
|
|
|
``free_draw`` (SRD "Move Actions": "If you have a base attack bonus of +1
|
|
or higher, you may draw a weapon as a free action combined with a regular
|
|
move") makes retrieve_weapon occupy the free slot instead of the move
|
|
slot — the caller sets it only when the turn's sequence carries a move
|
|
action and the actor's BAB is at least +1.
|
|
"""
|
|
if action.kind == "cast_spell":
|
|
return _cast_time_slot(cast_time(action))
|
|
if action.kind == "hex" and action.hex_name == "cackle":
|
|
return "free"
|
|
if action.kind == "retrieve_weapon" and free_draw:
|
|
return "free"
|
|
return _action_economy_slot(action.kind)
|
|
|
|
|
|
def _without_swift_bonus(
|
|
actions: tuple[Action, ...],
|
|
*,
|
|
cast_time: CastTimeResolver = _default_cast_time,
|
|
) -> 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 —
|
|
including a swift-action cast_spell, which occupies the same slot.
|
|
"""
|
|
return tuple(
|
|
a
|
|
for a in actions
|
|
if a.kind not in _SWIFT_BONUS_KINDS
|
|
and not (a.kind == "cast_spell" and _effective_slot(a, cast_time) == "swift")
|
|
)
|
|
|
|
|
|
def _dropped_kinds(
|
|
before: tuple[Action, ...], after: tuple[Action, ...]
|
|
) -> tuple[str, ...]:
|
|
"""Kinds present in ``before`` but absent from the filtered ``after``."""
|
|
kept = {a.kind for a in after}
|
|
return tuple(a.kind for a in before if a.kind not in kept)
|
|
|
|
|
|
def _staggered_filter(
|
|
actions: tuple[Action, ...],
|
|
*,
|
|
cast_time: CastTimeResolver = _default_cast_time,
|
|
free_draw: bool = False,
|
|
) -> tuple[Action, ...]:
|
|
"""Staggered: one move OR one standard action, plus every swift and free.
|
|
|
|
PF1e (CRB, Conditions — Staggered): "A staggered creature may take a
|
|
single move action or standard action each round (but not both, nor can
|
|
he take full-round actions). A staggered creature can still take free,
|
|
swift, and immediate actions." So the filter walks the sequence in order
|
|
and keeps every swift and free action, the FIRST move-or-standard action,
|
|
and drops full-round actions plus any SECOND move-or-standard. A leading
|
|
``full_attack`` (already in range) is downgraded to a single standard
|
|
``attack`` that consumes the one move-or-standard slot; a further
|
|
full-round action (charge, run, a full-round/one-round cast,
|
|
spell_combat) is dropped. Exception (SRD "Full-Round Actions — Restricted
|
|
Withdraw"): "If you are limited to taking only a standard action each
|
|
round you can withdraw as a standard action. In this case, you may move
|
|
up to your speed" — a ``withdraw`` is kept as the one standard action and
|
|
``_flee_path`` halves its budget to single speed.
|
|
"""
|
|
kept: list[Action] = []
|
|
slot_used = False # the single move-OR-standard slot is spent
|
|
for action in actions:
|
|
if action.kind == "full_attack":
|
|
# A full attack is a full-round action; staggered it collapses to
|
|
# a single standard attack (the kept move-or-standard action).
|
|
if not slot_used:
|
|
kept.append(Action(kind="attack", target_id=action.target_id))
|
|
slot_used = True
|
|
continue
|
|
slot = _effective_slot(action, cast_time, free_draw=free_draw)
|
|
if slot == "full_round":
|
|
if action.kind == "withdraw" and not slot_used:
|
|
# Restricted Withdraw: standard action, move up to speed.
|
|
kept.append(action)
|
|
slot_used = True
|
|
continue
|
|
if slot in ("standard", "move"):
|
|
if slot_used:
|
|
continue
|
|
kept.append(action)
|
|
slot_used = True
|
|
else: # swift / free — always allowed while staggered (CRB)
|
|
kept.append(action)
|
|
return tuple(kept)
|
|
|
|
|
|
def _surprise_filter(
|
|
actions: tuple[Action, ...],
|
|
*,
|
|
cast_time: CastTimeResolver = _default_cast_time,
|
|
free_draw: bool = False,
|
|
) -> tuple[Action, ...]:
|
|
"""Surprise round: one standard OR move action, plus every free action.
|
|
|
|
PF1e (SRD "Surprise"): "combatants who started the battle aware of their
|
|
opponents each take a standard or move action during the surprise round.
|
|
You can also take free actions during the surprise round." The rule text
|
|
grants neither a swift action nor a full-round action, so swift and
|
|
full-round kinds are dropped — with the same conventions as
|
|
``_staggered_filter``: a ``full_attack`` collapses to a single standard
|
|
``attack``, and ``withdraw`` survives as the standard-action Restricted
|
|
Withdraw (SRD "Full-Round Actions": legal "if you are limited to taking
|
|
only a standard action each round").
|
|
"""
|
|
kept: list[Action] = []
|
|
slot_used = False # the single standard-or-move slot is spent
|
|
for action in actions:
|
|
if action.kind == "full_attack":
|
|
if not slot_used:
|
|
kept.append(Action(kind="attack", target_id=action.target_id))
|
|
slot_used = True
|
|
continue
|
|
slot = _effective_slot(action, cast_time, free_draw=free_draw)
|
|
if slot == "full_round":
|
|
if action.kind == "withdraw" and not slot_used:
|
|
kept.append(action)
|
|
slot_used = True
|
|
continue
|
|
if slot in ("standard", "move"):
|
|
if slot_used:
|
|
continue
|
|
kept.append(action)
|
|
slot_used = True
|
|
elif slot == "free":
|
|
kept.append(action)
|
|
return tuple(kept)
|
|
|
|
|
|
def _nauseated_filter(actions: tuple[Action, ...]) -> tuple[Action, ...]:
|
|
"""PF1e (CRB) nauseated: a single move action per turn, nothing else.
|
|
|
|
The sequence collapses to its first move-like action; standard actions
|
|
(attack, cast_spell, maneuver, ...) and full-round actions are dropped.
|
|
"""
|
|
for action in actions:
|
|
if action.kind in _NAUSEA_MOVE_KINDS:
|
|
return (action,)
|
|
return ()
|
|
|
|
|
|
# X4 general per-turn action economy (CRB, "Actions in Combat"): one standard
|
|
# action + one move action (or one full-round action in place of both), plus
|
|
# one swift action; free and conditional actions never consume the budget.
|
|
# A move action may also be taken INSTEAD of the standard one (conversion:
|
|
# when the move slot is spent and the standard is free, a second move
|
|
# consumes the standard). Every Action kind is classified into exactly one
|
|
# slot (exhaustive over the Action.kind Literal, locked by
|
|
# tests/test_conformance_action_economy.py):
|
|
# - standard: attack, maneuver, channel_energy, hex, delay, ready,
|
|
# total_defense, aid_another; cast_spell occupies the slot its spell's
|
|
# CastTime maps to (_cast_time_slot) — standard by default
|
|
# - move: move, stand_up, retrieve_weapon (drawing a weapon is a move action),
|
|
# repair_weapon
|
|
# - swift: swift, deed, inspire_courage (the engine's swift bonus kinds)
|
|
# - full-round: full_attack, charge, withdraw, full_round, spell_combat
|
|
# (spell + full attack in one round), run (4x-speed movement, PF1e
|
|
# full-round); consumes the standard AND the move slot
|
|
# - free/conditional (everything else, no budget): wait, free, immediate
|
|
# (off-turn it consumes the NEXT swift via immediate_used; on your own turn
|
|
# it consumes THIS turn's swift via swift_used_this_turn — SRD Immediate
|
|
# Actions), 5foot_step (free step, mutually exclusive with other movement via
|
|
# moved_this_turn), drop_prone, activate_power_attack and fight_defensively
|
|
# (mode declarations applied to the attack that follows them, like the
|
|
# Power Attack toggle), summon is not a kind (summons arrive via cast_spell).
|
|
_ACTION_ECONOMY_STANDARD_KINDS: frozenset[str] = frozenset({
|
|
"attack", "cast_spell", "maneuver", "channel_energy", "hex",
|
|
"delay", "ready", "total_defense", "aid_another",
|
|
})
|
|
_ACTION_ECONOMY_MOVE_KINDS: frozenset[str] = frozenset({
|
|
"move", "stand_up", "retrieve_weapon", "repair_weapon",
|
|
})
|
|
_ACTION_ECONOMY_SWIFT_KINDS: frozenset[str] = frozenset(
|
|
{"swift", "deed", "inspire_courage"}
|
|
)
|
|
_ACTION_ECONOMY_FULL_ROUND_KINDS: frozenset[str] = frozenset({
|
|
"full_attack", "charge", "withdraw", "full_round", "spell_combat", "run",
|
|
})
|
|
_ACTION_ECONOMY_FREE_KINDS: frozenset[str] = frozenset({
|
|
"wait", "free", "immediate", "5foot_step", "drop_prone",
|
|
"activate_power_attack", "fight_defensively",
|
|
})
|
|
|
|
_ActionEconomySlot = Literal["standard", "move", "swift", "full_round", "free"]
|
|
|
|
|
|
def _action_economy_slot(kind: str) -> _ActionEconomySlot:
|
|
"""The budget slot one action kind consumes (free if none applies)."""
|
|
if kind in _ACTION_ECONOMY_STANDARD_KINDS:
|
|
return "standard"
|
|
if kind in _ACTION_ECONOMY_MOVE_KINDS:
|
|
return "move"
|
|
if kind in _ACTION_ECONOMY_SWIFT_KINDS:
|
|
return "swift"
|
|
if kind in _ACTION_ECONOMY_FULL_ROUND_KINDS:
|
|
return "full_round"
|
|
return "free"
|
|
|
|
|
|
def _action_economy_filter(
|
|
actions: tuple[Action, ...],
|
|
*,
|
|
cast_time: CastTimeResolver = _default_cast_time,
|
|
free_draw: bool = False,
|
|
) -> tuple[Action, ...]:
|
|
"""Degrade a policy sequence to the legal PF1e action economy.
|
|
|
|
Greedy in-order walk: each action is kept while its budget slot is free
|
|
(one standard, one move, one swift; a full-round action is only legal
|
|
while BOTH the standard and the move slot are still free and consumes
|
|
them all). A cast_spell's slot is its spell's casting time resolved by
|
|
``cast_time`` (long casts are dropped by the engine BEFORE this filter —
|
|
_cast_time_slot raises for them). PF1e (CRB, "Actions in Combat"): a
|
|
move action can be taken "instead of" the standard one — when the move
|
|
slot is already spent and the standard slot is still free, a further
|
|
move action converts the standard slot (so (move, stand_up) and
|
|
(move, move) are legal). The kept sequence is therefore the earliest
|
|
order-stable legal prefix-subsequence; every skipped action is
|
|
deterministic. ``free_draw`` makes retrieve_weapon a free action (SRD
|
|
"Move Actions", BAB +1 combined with a move — see _effective_slot).
|
|
"""
|
|
kept: list[Action] = []
|
|
spent: dict[_ActionEconomySlot, bool] = {
|
|
"standard": False,
|
|
"move": False,
|
|
"swift": False,
|
|
"full_round": False,
|
|
"free": False,
|
|
}
|
|
for action in actions:
|
|
slot = _effective_slot(action, cast_time, free_draw=free_draw)
|
|
if slot == "free":
|
|
kept.append(action)
|
|
continue
|
|
if slot == "full_round":
|
|
if spent["standard"] or spent["move"]:
|
|
continue
|
|
spent["standard"] = True
|
|
spent["move"] = True
|
|
kept.append(action)
|
|
continue
|
|
if slot == "move" and spent["move"]:
|
|
slot = "standard" # conversion: a move action consumes the standard slot
|
|
if spent[slot]:
|
|
continue
|
|
spent[slot] = True
|
|
kept.append(action)
|
|
return tuple(kept)
|
|
|
|
|
|
def _dropped_kinds_multiset(
|
|
before: tuple[Action, ...], after: tuple[Action, ...]
|
|
) -> tuple[str, ...]:
|
|
"""One dropped-kind entry per dropped action instance (in order).
|
|
|
|
Unlike ``_dropped_kinds`` (set membership, which cannot report dropping
|
|
the second of two same-kind actions), this consumes the kept instances
|
|
first and reports every surplus — the budget filter regularly drops a
|
|
duplicate standard/move/swift action.
|
|
"""
|
|
remaining: dict[str, int] = {}
|
|
for action in after:
|
|
remaining[action.kind] = remaining.get(action.kind, 0) + 1
|
|
dropped: list[str] = []
|
|
for action in before:
|
|
count = remaining.get(action.kind, 0)
|
|
if count > 0:
|
|
remaining[action.kind] = count - 1
|
|
else:
|
|
dropped.append(action.kind)
|
|
return tuple(dropped)
|
|
|
|
|
|
def _ready_payload(action: Action) -> Action:
|
|
"""Derive the concrete payload of a ready action from its carrier fields.
|
|
|
|
Mirrors ``_execute_immediate``: ``spell_name`` casts, ``maneuver``
|
|
performs the maneuver (carrying direction/trick_condition), anything
|
|
else attacks ``target_id``. Class-ability payloads are out of scope for U4.
|
|
"""
|
|
if action.spell_name is not None:
|
|
return Action(
|
|
kind="cast_spell",
|
|
target_id=action.target_id,
|
|
spell_name=action.spell_name,
|
|
target_pos=action.target_pos,
|
|
metamagic=action.metamagic,
|
|
cast_defensively=action.cast_defensively,
|
|
)
|
|
if action.maneuver is not None:
|
|
return Action(
|
|
kind="maneuver",
|
|
target_id=action.target_id,
|
|
maneuver=action.maneuver,
|
|
direction=action.direction,
|
|
trick_condition=action.trick_condition,
|
|
)
|
|
return Action(kind="attack", target_id=action.target_id)
|
|
|
|
|
|
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),
|
|
)
|