068b49794e
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 a successful steal now takes nothing and logs "nothing stealable — wielded weapons require disarm". README updated; the retrieve_weapon tests keep locking the restoration mechanism directly. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
6916 lines
293 KiB
Python
6916 lines
293 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 total damage (dice + flat bonus) by crit_mult.
|
|
- 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.conditions import CONDITIONS_BY_NAME, Condition
|
|
from pf1e_simulator.dice import dice_to_str
|
|
from pf1e_simulator.effects import ActiveBuff, ActiveCondition, StatModifier, resolve_modifiers
|
|
from pf1e_simulator.loaders.combatant import CombatantLoadError, load_combatant
|
|
from pf1e_simulator.models import PREDEFINED_SKILLS
|
|
from pf1e_simulator.spells import (
|
|
AreaEffect,
|
|
BuffEffect,
|
|
CastTime,
|
|
ConditionEffect,
|
|
DamageEffect,
|
|
HealEffect,
|
|
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", "nauseated"]
|
|
|
|
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_REDUCED_PENALTY = -2 # PF1e: -2 when the melee is 10+ ft away
|
|
_FIRING_MELEE_SAFE_FT = 10 # PF1e: 2 cells (10 ft) from attacker = reduced penalty
|
|
_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
|
|
_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). Unknown sizes fall back to 5 ft (the historical behavior).
|
|
_SIZE_NATURAL_REACH_FT: dict[str, int] = {
|
|
"Small": 5,
|
|
"Medium": 5,
|
|
"Large": 10,
|
|
"Huge": 15,
|
|
"Gargantuan": 20,
|
|
"Colossal": 30,
|
|
}
|
|
|
|
# Number of grid cells per side of a creature's footprint (square footprint).
|
|
# Unknown sizes fall back to 1 (the historical single-cell behavior).
|
|
SIZE_CELLS: dict[str, int] = {
|
|
"Small": 1,
|
|
"Medium": 1,
|
|
"Large": 2,
|
|
"Huge": 3,
|
|
"Gargantuan": 4,
|
|
"Colossal": 6,
|
|
}
|
|
|
|
# Row-major scan order of the 8 adjacent cells, used by reposition.
|
|
_NEIGHBOR_OFFSETS: tuple[tuple[int, int], ...] = (
|
|
(-1, -1), (-1, 0), (-1, 1), (0, -1), (0, 1), (1, -1), (1, 0), (1, 1),
|
|
)
|
|
|
|
|
|
# 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_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
|
|
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
|
|
|
|
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.
|
|
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",
|
|
"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_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] = []
|
|
|
|
@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,
|
|
"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,
|
|
"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
|
|
),
|
|
}
|
|
|
|
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 _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._decrement_durations(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
|
|
kept = _action_economy_filter(actions, cast_time=self._cast_time_of)
|
|
results.extend(
|
|
ActionResult(
|
|
kind=kind, ok=False, reason=_REASON_DOWNGRADED_ACTION_ECONOMY
|
|
)
|
|
for kind in _dropped_kinds_multiset(actions, kept)
|
|
)
|
|
actions = 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_disabled(state) or self._has_condition_named(state, "staggered"):
|
|
kept = _staggered_filter(actions, cast_time=self._cast_time_of)
|
|
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_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."""
|
|
state.active_buffs[:] = [
|
|
ActiveBuff(modifiers=b.modifiers, rounds_left=b.rounds_left - 1, source=b.source)
|
|
for b in state.active_buffs
|
|
if b.rounds_left > 1
|
|
]
|
|
state.active_conditions[:] = [
|
|
ActiveCondition(condition=c.condition, rounds_left=c.rounds_left - 1, source=c.source)
|
|
for c in state.active_conditions
|
|
if c.rounds_left > 1
|
|
]
|
|
|
|
def _turn_gate(self, state: CombatantState) -> bool | None:
|
|
"""Start-of-turn effects: bleed tick, 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)
|
|
if not state.active:
|
|
# Bleeding 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_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()
|
|
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 _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._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]:
|
|
"""Union of occupied cells of all active states except those in ``exclude``."""
|
|
return frozenset(
|
|
cell
|
|
for s in self._states
|
|
if s.active and all(s is not e for e in exclude)
|
|
for cell in s.occupied_cells()
|
|
)
|
|
|
|
def _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."
|
|
"""
|
|
return frozenset(
|
|
cell
|
|
for s in self._states
|
|
if s.active and s is not mover and s.side != mover.side
|
|
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
|
|
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()
|
|
)
|
|
ac += cover_bonus_cells(
|
|
self._grid,
|
|
attacker.occupied_cells(),
|
|
target.occupied_cells(),
|
|
ranged=self._is_ranged_attack(weapon),
|
|
occupied=occupied,
|
|
)
|
|
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]
|
|
damage = sum(rolls) + 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
|
|
# 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.
|
|
mult = weapon.crit_mult if crit else 1
|
|
energy_share = sum(
|
|
roll * mult
|
|
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, crit=crit, target=target
|
|
)
|
|
|
|
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"). 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).
|
|
"""
|
|
if (
|
|
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, reduced to -2 when the target or every threatening
|
|
opponent stands 10+ ft from the attacker. Precise Shot removes it.
|
|
|
|
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 _FIRING_MELEE_REDUCED_PENALTY
|
|
for threat in threats:
|
|
dist = self.creature_distance(attacker, threat) * _SQUARE_FT
|
|
if dist >= _FIRING_MELEE_SAFE_FT:
|
|
return _FIRING_MELEE_REDUCED_PENALTY
|
|
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],
|
|
*,
|
|
crit: bool,
|
|
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 times
|
|
the crit multiplier (engine convention: every damage die is
|
|
multiplied on a crit) and its reduction is capped at that share.
|
|
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.
|
|
"""
|
|
mult = weapon.crit_mult if crit else 1
|
|
for comp, roll in zip(weapon.damage, rolls, strict=True):
|
|
damage -= self._energy_reduction(roll * mult, 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%). Melee line: terrain cover
|
|
only — creatures grant no melee cover.
|
|
"""
|
|
if self._concealment_chance(target) >= _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.
|
|
|
|
Reuses the move machinery: the Dijkstra descent toward an explicit
|
|
destination (U5c) or the resolved target, walked step-by-step with
|
|
the usual AoO provocation, under a 4x speed budget. PF1e forbids
|
|
running across difficult terrain (CRB "Movement, Position and
|
|
Distance — Difficult Terrain": "You can't run or charge across
|
|
difficult terrain"), so the descent refuses every step into a
|
|
difficult cell: the run moves up to the obstacle and stops there
|
|
(deterministic; the same stop-short tolerance the greedy descent
|
|
already applies to any other blockage). 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 cannot be
|
|
honoured within the 4x budget 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:
|
|
reachable = self._destination_reachable(
|
|
state, action.destination, speed_factor=_RUN_SPEED_FACTOR,
|
|
friendly_passable=True,
|
|
)
|
|
if not reachable:
|
|
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,
|
|
action.destination,
|
|
speed_factor=_RUN_SPEED_FACTOR,
|
|
avoid_difficult=True,
|
|
friendly_passable=True,
|
|
)
|
|
self._walk_steps(state, path)
|
|
state.moved_this_turn = True
|
|
elif target is not None:
|
|
path = self._descend_path(
|
|
state, target.pos, speed_factor=_RUN_SPEED_FACTOR, avoid_difficult=True,
|
|
friendly_passable=True,
|
|
)
|
|
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.
|
|
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:
|
|
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 _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})"
|
|
)
|
|
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_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)
|
|
ac_bonuses = resolve_modifiers(
|
|
m
|
|
for m in self._stat_modifiers(target, "ac")
|
|
if m.bonus_type in _CMD_AC_BONUS_TYPES
|
|
)
|
|
# 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 _maneuver_grapple(
|
|
self, state: CombatantState, target: CombatantState, m: _ManeuverRoll
|
|
) -> None:
|
|
if not m.success:
|
|
self._maneuver_fail(state, target, "grapple", m)
|
|
return
|
|
target.active_conditions.append(
|
|
ActiveCondition(
|
|
condition=CONDITIONS_BY_NAME["grappled"],
|
|
rounds_left=10,
|
|
source=f"maneuver: grapple by {state.combatant.id}",
|
|
)
|
|
)
|
|
target.grappled_with = state.combatant.id
|
|
state.grappled_with = target.combatant.id
|
|
self._log(
|
|
f"round {self._current_round} {state.combatant.id}: grapple vs "
|
|
f"{target.combatant.id} d20={m.roll}+{m.attacker_cmb}={m.total} "
|
|
f"CMD {m.target_cmd} -> SUCCESS (grappled)"
|
|
)
|
|
|
|
def _maneuver_fail(
|
|
self, state: CombatantState, target: CombatantState, maneuver: str,
|
|
m: _ManeuverRoll,
|
|
) -> None:
|
|
self._log(
|
|
f"round {self._current_round} {state.combatant.id}: {maneuver} vs "
|
|
f"{target.combatant.id} d20={m.roll}+{m.attacker_cmb}={m.total} "
|
|
f"CMD {m.target_cmd} -> FAILED"
|
|
)
|
|
|
|
def _maneuver_overrun(
|
|
self, state: CombatantState, target: CombatantState, m: _ManeuverRoll
|
|
) -> None:
|
|
if not m.success:
|
|
self._maneuver_fail(state, target, "overrun", m)
|
|
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]
|
|
target.active_conditions.append(
|
|
ActiveCondition(
|
|
condition=condition,
|
|
rounds_left=1,
|
|
source="maneuver: dirty trick",
|
|
)
|
|
)
|
|
self._log(
|
|
f"round {self._current_round} {state.combatant.id}: dirty trick vs "
|
|
f"{target.combatant.id} d20={m.roll}+{m.attacker_cmb}={m.total} "
|
|
f"CMD {m.target_cmd} -> SUCCESS ({condition_name})"
|
|
)
|
|
|
|
def _maneuver_steal(
|
|
self, state: CombatantState, target: CombatantState, m: _ManeuverRoll
|
|
) -> None:
|
|
if not m.success:
|
|
self._maneuver_fail(state, target, "steal", m)
|
|
return
|
|
# 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
|
|
new_pos: Pos | None = None
|
|
if direction is not None:
|
|
pos = (target.pos[0] + direction[0], target.pos[1] + direction[1])
|
|
if (
|
|
0 <= pos[0] < self._grid.height
|
|
and 0 <= pos[1] < self._grid.width
|
|
and self._cells_free(self._footprint_at(pos, target), target)
|
|
):
|
|
new_pos = pos
|
|
else:
|
|
for dr, dc in _NEIGHBOR_OFFSETS:
|
|
pos = (target.pos[0] + dr, target.pos[1] + dc)
|
|
if pos == state.pos:
|
|
continue
|
|
if not (0 <= pos[0] < self._grid.height and 0 <= pos[1] < self._grid.width):
|
|
continue
|
|
if self._cells_free(self._footprint_at(pos, target), target):
|
|
new_pos = pos
|
|
break
|
|
if new_pos is not None:
|
|
target.pos = new_pos
|
|
outcome = f"SUCCESS (moved to {new_pos})"
|
|
else:
|
|
outcome = "SUCCESS (no space)"
|
|
self._log(
|
|
f"round {self._current_round} {state.combatant.id}: reposition vs "
|
|
f"{target.combatant.id} d20={m.roll}+{m.attacker_cmb}={m.total} "
|
|
f"CMD {m.target_cmd} -> {outcome}"
|
|
)
|
|
|
|
def _maneuver_drag(
|
|
self,
|
|
state: CombatantState,
|
|
target: CombatantState,
|
|
m: _ManeuverRoll,
|
|
*,
|
|
direction: tuple[int, int] | None = None,
|
|
) -> None:
|
|
if not m.success:
|
|
self._maneuver_fail(state, target, "drag", m)
|
|
return
|
|
ar, ac = state.pos
|
|
tr, tc = target.pos
|
|
adjacent = max(abs(tr - ar), abs(tc - ac)) == 1
|
|
if direction is not None:
|
|
beyond = (tr + direction[0], tc + direction[1])
|
|
else:
|
|
beyond = self._beyond_cell(state.pos, target.pos)
|
|
if adjacent and self._cells_free(self._footprint_at(beyond, target), target):
|
|
target_old = target.pos
|
|
target.pos = beyond
|
|
state.pos = target_old
|
|
outcome = "SUCCESS (dragged)"
|
|
else:
|
|
outcome = "SUCCESS (no space)"
|
|
self._log(
|
|
f"round {self._current_round} {state.combatant.id}: drag vs "
|
|
f"{target.combatant.id} d20={m.roll}+{m.attacker_cmb}={m.total} "
|
|
f"CMD {m.target_cmd} -> {outcome}"
|
|
)
|
|
|
|
def _maneuver_escape(
|
|
self, state: CombatantState, target: CombatantState
|
|
) -> None:
|
|
escape_cmb = self._maneuver_cmb(state)
|
|
escape_skill = (
|
|
self._skill_static_bonus(state, "escape_artist")
|
|
if hasattr(state.combatant, "skills") and "escape_artist" in state.combatant.skills
|
|
else 0
|
|
)
|
|
escape_bonus = max(escape_cmb, escape_skill)
|
|
target_cmd = self._maneuver_cmd(target)
|
|
roll = self._rng.d20()
|
|
total = roll + escape_bonus
|
|
# 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)."""
|
|
if not state.spend_resource("hexes", 1):
|
|
self._log(
|
|
f"round {self._current_round} {state.combatant.id}: hex failed (no resources)"
|
|
)
|
|
return
|
|
if target is None or not target.active:
|
|
return
|
|
if hex_name not in ("evil_eye", "slumber", "cackle"):
|
|
self._log(
|
|
f"round {self._current_round} {state.combatant.id}: unknown hex {hex_name}"
|
|
)
|
|
return
|
|
|
|
hex_dc = 10 + state.combatant.level // 2 + (state.combatant.abilities.int_score - 10) // 2
|
|
|
|
if hex_name == "evil_eye":
|
|
self._hex_evil_eye(state, target, hex_dc)
|
|
elif hex_name == "slumber":
|
|
self._hex_slumber(state, target, hex_dc)
|
|
elif hex_name == "cackle":
|
|
self._hex_cackle(state, target)
|
|
|
|
def _hex_evil_eye(
|
|
self, state: CombatantState, target: CombatantState, hex_dc: int
|
|
) -> None:
|
|
"""Evil Eye: shaken (-2 AC or saves), 1 round, Will negates."""
|
|
save_result = self.resolve_save(target, "will", hex_dc)
|
|
if save_result.success:
|
|
self._log(
|
|
f"round {self._current_round} {state.combatant.id}: evil eye on "
|
|
f"{target.combatant.id} -> SAVED"
|
|
)
|
|
return
|
|
target.active_conditions.append(
|
|
ActiveCondition(
|
|
condition=CONDITIONS_BY_NAME["shaken"],
|
|
rounds_left=1,
|
|
source=f"hex: evil_eye by {state.combatant.id}",
|
|
)
|
|
)
|
|
self._log(
|
|
f"round {self._current_round} {state.combatant.id}: evil eye on "
|
|
f"{target.combatant.id} -> shaken (1r)"
|
|
)
|
|
|
|
def _hex_slumber(
|
|
self, state: CombatantState, target: CombatantState, hex_dc: int
|
|
) -> None:
|
|
"""Slumber: asleep/disabled if Will fails."""
|
|
save_result = self.resolve_save(target, "will", hex_dc)
|
|
if save_result.success:
|
|
self._log(
|
|
f"round {self._current_round} {state.combatant.id}: slumber on "
|
|
f"{target.combatant.id} -> SAVED"
|
|
)
|
|
return
|
|
target.active_conditions.append(
|
|
ActiveCondition(
|
|
condition=CONDITIONS_BY_NAME["asleep"],
|
|
rounds_left=1,
|
|
source=f"hex: slumber by {state.combatant.id}",
|
|
)
|
|
)
|
|
self._log(
|
|
f"round {self._current_round} {state.combatant.id}: slumber on "
|
|
f"{target.combatant.id} -> asleep (1r)"
|
|
)
|
|
|
|
def _hex_cackle(self, state: CombatantState, target: CombatantState) -> None:
|
|
"""Cackle: prolong one hex by 1 round (extends all hex conditions)."""
|
|
extended = False
|
|
for ac in list(target.active_conditions):
|
|
if ac.source.startswith("hex:"):
|
|
target.active_conditions.remove(ac)
|
|
target.active_conditions.append(
|
|
ActiveCondition(
|
|
condition=ac.condition,
|
|
rounds_left=ac.rounds_left + 1,
|
|
source=ac.source,
|
|
)
|
|
)
|
|
extended = True
|
|
if extended:
|
|
self._log(
|
|
f"round {self._current_round} {state.combatant.id}: cackle extends "
|
|
f"hex on {target.combatant.id}"
|
|
)
|
|
else:
|
|
self._log(
|
|
f"round {self._current_round} {state.combatant.id}: cackle - no hex "
|
|
f"to extend on {target.combatant.id}"
|
|
)
|
|
|
|
def _execute_deed(
|
|
self, state: CombatantState, target: CombatantState | None, deed_name: str | None
|
|
) -> None:
|
|
"""Execute a gunslinger deed (up_close_and_deadly, quick_clear)."""
|
|
if deed_name not in ("up_close_and_deadly", "quick_clear"):
|
|
return
|
|
if deed_name == "up_close_and_deadly":
|
|
# Up Close and Deadly: +1d6 damage within 30ft
|
|
if target is None or not target.active:
|
|
return
|
|
dist_ft = self.creature_distance(state, target) * _SQUARE_FT
|
|
if dist_ft > _UP_CLOSE_RANGE_FT:
|
|
self._log(
|
|
f"round {self._current_round} {state.combatant.id}: deed Up Close "
|
|
f"and Deadly - target out of range"
|
|
)
|
|
return
|
|
if not state.spend_resource("grit", 1):
|
|
self._log(
|
|
f"round {self._current_round} {state.combatant.id}: deed failed (no grit)"
|
|
)
|
|
return
|
|
# Apply +1d6 damage to next attack this round
|
|
state.deed_up_close_and_deadly = True
|
|
self._log(
|
|
f"round {self._current_round} {state.combatant.id}: "
|
|
f"deed Up Close and Deadly activated (+1d6 dmg within 30ft)"
|
|
)
|
|
elif deed_name == "quick_clear":
|
|
# Quick Clear: swift action, clears misfire
|
|
if not state.spend_resource("grit", 1):
|
|
self._log(
|
|
f"round {self._current_round} {state.combatant.id}: deed failed (no grit)"
|
|
)
|
|
return
|
|
self._log(
|
|
f"round {self._current_round} {state.combatant.id}: "
|
|
f"deed Quick Clear - firearm cleared"
|
|
)
|
|
# In a full implementation, this would clear the misfire condition
|
|
|
|
def _execute_spell_combat(
|
|
self, state: CombatantState, target: CombatantState | None, spell_name: str | None
|
|
) -> None:
|
|
"""Execute Spell Combat (cast spell + full attack in same round).
|
|
|
|
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.
|
|
Simplification: the spell may target any resolved enemy (the RAW
|
|
spellcasting-as-move restriction and arcane pool cost are not modeled).
|
|
"""
|
|
if target is None or not target.active or spell_name is None:
|
|
return
|
|
if not state.spend_resource("arcane_pool", 1):
|
|
# Arcane pool not tracked yet, allow for now
|
|
pass
|
|
# Cast the spell
|
|
self._cast_spell(state, target, spell_name)
|
|
# Then make a full attack 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,
|
|
) -> 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. 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
|
|
# PF1e (CRB): hostile touch delivery needs a melee touch attack (B14)
|
|
# and spell resistance can still turn the spell away.
|
|
if self._spell_delivery_blocked(caster, target, spell, has_area=has_area):
|
|
return None
|
|
self._apply_and_log_cast(
|
|
caster, target, spell, spell_name, center_pos,
|
|
metamagic=applied, slot_level=used_level,
|
|
)
|
|
return None
|
|
|
|
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,
|
|
) -> 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, then spell resistance must be overcome. True =
|
|
no effect (the slot is already spent)."""
|
|
if (
|
|
target is not None
|
|
and spell.range == "touch"
|
|
and target.side != caster.side
|
|
and not self._spell_touch_attack_hits(caster, target)
|
|
):
|
|
return True
|
|
if (
|
|
target is not None
|
|
and spell.requires_ranged_touch
|
|
and not has_area
|
|
and target.side != caster.side
|
|
and not self._spell_ranged_touch_attack_hits(caster, target)
|
|
):
|
|
return True
|
|
return (
|
|
target is not None
|
|
and not has_area
|
|
and not self._check_spell_resistance(caster, target)
|
|
)
|
|
|
|
def _spell_touch_attack_hits(
|
|
self, caster: CombatantState, target: CombatantState
|
|
) -> 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. 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).
|
|
"""
|
|
roll = self._rng.d20()
|
|
bonus = caster.combatant.bab + caster.combatant.abilities.str_mod
|
|
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
|
|
hit = roll == _NATURAL_TWENTY or (roll != _NATURAL_ONE and roll + bonus >= ac)
|
|
self._log(
|
|
f"round {self._current_round} {caster.combatant.id}: touch attack vs "
|
|
f"{target.combatant.id} d20={roll}+{bonus}={roll + bonus} AC {ac} "
|
|
f"-> {'HIT' if hit else 'MISS'}"
|
|
)
|
|
return hit
|
|
|
|
def _spell_ranged_touch_attack_hits(
|
|
self, caster: CombatantState, target: CombatantState
|
|
) -> 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; the spell's range is a single absolute
|
|
value so there is no range-increment 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).
|
|
"""
|
|
roll = self._rng.d20()
|
|
bonus = caster.combatant.bab + caster.combatant.abilities.dex_mod
|
|
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
|
|
hit = roll == _NATURAL_TWENTY or (roll != _NATURAL_ONE and roll + bonus >= ac)
|
|
self._log(
|
|
f"round {self._current_round} {caster.combatant.id}: ranged touch attack vs "
|
|
f"{target.combatant.id} d20={roll}+{bonus}={roll + bonus} AC {ac} "
|
|
f"-> {'HIT' if hit else 'MISS'}"
|
|
)
|
|
return hit
|
|
|
|
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,
|
|
) -> 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).
|
|
"""
|
|
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)
|
|
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,
|
|
)
|
|
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",
|
|
) -> 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,
|
|
)
|
|
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",
|
|
) -> 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,
|
|
)
|
|
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,
|
|
)
|
|
|
|
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,
|
|
) -> str:
|
|
if isinstance(effect, DamageEffect):
|
|
return self._apply_damage_effect(
|
|
effect, target, caster_live, target_live,
|
|
saved=saved, empower=empower, maximize=maximize, caster=caster,
|
|
)
|
|
if isinstance(effect, ConditionEffect):
|
|
return self._apply_condition_effect(effect, target, saved=saved, spell_name=spell_name)
|
|
if isinstance(effect, HealEffect):
|
|
return self._apply_heal_effect(effect, target, empower=empower, maximize=maximize)
|
|
return self._apply_buff_effect(effect, target, spell_name=spell_name)
|
|
|
|
def _apply_damage_effect(
|
|
self,
|
|
effect: DamageEffect,
|
|
target: CombatantState,
|
|
caster_live: _LiveStats,
|
|
target_live: _LiveStats,
|
|
*,
|
|
saved: bool,
|
|
empower: bool,
|
|
maximize: bool,
|
|
caster: CombatantState,
|
|
) -> str:
|
|
raw = effect.formula.roll(self._rng)
|
|
if maximize:
|
|
raw = effect.formula.max_value()
|
|
if saved and effect.half_on_save:
|
|
dmg = max(0, raw // 2)
|
|
elif saved:
|
|
dmg = 0
|
|
else:
|
|
dmg = raw
|
|
if empower:
|
|
dmg = int(dmg * 1.5)
|
|
dmg = self._apply_energy_resistance(dmg, frozenset(effect.types), target)
|
|
target.hp -= dmg
|
|
caster_live.damage_dealt += dmg
|
|
target_live.damage_taken += dmg
|
|
self._check_reactions(
|
|
ReactionEvent(
|
|
kind="damage_taken", actor=caster, target=target, damage=dmg
|
|
)
|
|
)
|
|
return f"{dmg} damage"
|
|
|
|
def _apply_condition_effect(
|
|
self,
|
|
effect: ConditionEffect,
|
|
target: CombatantState,
|
|
*,
|
|
saved: bool,
|
|
spell_name: str,
|
|
) -> str:
|
|
if saved and effect.save_negates:
|
|
return f"{effect.condition_name} negated"
|
|
cond = CONDITIONS_BY_NAME.get(effect.condition_name)
|
|
if cond is None:
|
|
return ""
|
|
target.active_conditions.append(
|
|
ActiveCondition(
|
|
condition=cond,
|
|
rounds_left=effect.duration_rounds,
|
|
source=f"spell: {spell_name}",
|
|
)
|
|
)
|
|
return f"{effect.condition_name} ({effect.duration_rounds}r)"
|
|
|
|
def _apply_heal_effect(
|
|
self,
|
|
effect: HealEffect,
|
|
target: CombatantState,
|
|
*,
|
|
empower: bool,
|
|
maximize: bool,
|
|
) -> str:
|
|
heal = effect.formula.roll(self._rng)
|
|
if maximize:
|
|
heal = effect.formula.max_value()
|
|
if empower:
|
|
heal = int(heal * 1.5)
|
|
old_hp = target.hp
|
|
target.hp = min(target.hp + heal, target.combatant.hp_max)
|
|
# 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",
|
|
) -> str:
|
|
"""Apply an area effect to all creatures within radius.
|
|
|
|
The burst is centered on ``center_pos`` when given (U7b point
|
|
targeting), otherwise on the resolved target's position.
|
|
"""
|
|
radius_cells = effect.radius_ft // _SQUARE_FT
|
|
center = _burst_center(target, center_pos)
|
|
if center is None:
|
|
return "no targets in area"
|
|
affected = self._creatures_in_range(center, radius_cells)
|
|
|
|
if not affected:
|
|
return "no targets in area"
|
|
|
|
total_damage = 0
|
|
total_conditions = 0
|
|
total_buffs = 0
|
|
|
|
for affected_state in affected:
|
|
if 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 _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
|
|
return ConditionEffect(
|
|
condition_name=effect.condition_name,
|
|
duration_rounds=duration,
|
|
save_negates=effect.save_negates,
|
|
)
|
|
if isinstance(effect, BuffEffect):
|
|
duration = effect.duration_rounds * 2 if has_extend else effect.duration_rounds
|
|
return BuffEffect(modifiers=effect.modifiers, duration_rounds=duration)
|
|
if isinstance(effect, AreaEffect):
|
|
return AreaEffect(
|
|
radius_ft=effect.radius_ft,
|
|
effects=tuple(
|
|
self._metamagic_transform_sub(sub, has_extend=has_extend)
|
|
for sub in effect.effects
|
|
),
|
|
save_for_half=effect.save_for_half,
|
|
)
|
|
return effect
|
|
|
|
|
|
def _apply_summon_effect(
|
|
self,
|
|
effect: SummonEffect,
|
|
state: CombatantState,
|
|
_caster_live: _LiveStats,
|
|
_spell_name: str,
|
|
) -> str:
|
|
"""Apply a summoning effect - create a summoned creature."""
|
|
# Load the summoned creature
|
|
try:
|
|
summoned_combatant = load_combatant(Path(effect.creature))
|
|
except CombatantLoadError:
|
|
return f"summon failed: could not load {effect.creature}"
|
|
|
|
# Find a valid adjacent position for the summoned creature
|
|
center = state.pos
|
|
spawn_pos = None
|
|
for dr in (-1, 0, 1):
|
|
for dc in (-1, 0, 1):
|
|
if dr == 0 and dc == 0:
|
|
continue
|
|
pos = (center[0] + dr, center[1] + dc)
|
|
if self._cells_free(self._footprint_at(pos, state), state):
|
|
spawn_pos = pos
|
|
break
|
|
if spawn_pos:
|
|
break
|
|
|
|
if not spawn_pos:
|
|
return "summon failed: no space"
|
|
|
|
# Create summoned creature state
|
|
summoned_state = CombatantState(
|
|
combatant=summoned_combatant,
|
|
side=state.side,
|
|
pos=spawn_pos,
|
|
hp=summoned_combatant.hp_max,
|
|
)
|
|
|
|
# Insert into initiative order right after caster
|
|
caster_idx = next(i for i, s in enumerate(self._states) if s is state)
|
|
self._states.insert(caster_idx + 1, summoned_state)
|
|
|
|
# Set expiration
|
|
summoned_state.summon_duration = effect.duration_rounds
|
|
summoned_state.summon_caster_id = state.combatant.id
|
|
self._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,
|
|
) -> 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.
|
|
"""
|
|
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
|
|
blocked = self._blocked_hostile(state) if friendly_passable else self._blocked(state)
|
|
field = self._grid.reachable(state.pos, speed_cells, blocked)
|
|
return destination in field
|
|
|
|
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 _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.
|
|
|
|
Uses the Dijkstra cost field from the threat and greedily picks the
|
|
step that maximizes distance (cost) from the threat each cell.
|
|
"""
|
|
speed_mult = self._get_speed_mult(state)
|
|
max_cells = int(2 * (_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.
|
|
|
|
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 is CLAMPED to the full-round slot:
|
|
the engine resolves it within the current turn instead of tracking a
|
|
pending cast across turns (documented approximation). 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) -> _ActionEconomySlot:
|
|
"""The budget slot an action consumes, resolving cast_spell by cast time."""
|
|
if action.kind == "cast_spell":
|
|
return _cast_time_slot(cast_time(action))
|
|
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,
|
|
) -> 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, withdraw, run, a full-round/one-round cast,
|
|
spell_combat) is dropped.
|
|
"""
|
|
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)
|
|
if slot == "full_round":
|
|
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 _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,
|
|
) -> 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.
|
|
"""
|
|
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)
|
|
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),
|
|
)
|