"""Deterministic combat engine: attacks, crits, DR, hp states, initiative, rounds. Phase 0 documented deviations from PF1e (conventions): - Action economy: a normal turn grants one standard action + one move action (or one full-round action), plus swift/free/immediate. The policy returns an ordered sequence of `Action` per turn; the engine enforces the budget on it (X4, `_action_economy_filter`): a cast_spell occupies the slot of its spell's casting time (long casts are dropped before the budget with `downgraded:cast_time`), surplus standards/moves/swifts and a full-round action conflicting with an already-spent standard/move are dropped in order (earliest legal prefix-subsequence kept) and reported as `downgraded:action_economy`; a move action may convert a still-free standard slot; the condition-specific filters then apply as before. The engine executes the remaining actions in order, stopping early if the actor drops or one side is wiped. `default_policy` returns `(full_attack,)` when already in range, `(charge,)` when a straight-line charge path exists, `(5foot_step, full_attack)` when a single step brings the target into range, or `(move, attack)` otherwise (move at full speed along the Dijkstra path, then single attack). Immediate actions (off-turn, consume next swift) are modeled via an opt-in ``reaction_policy`` hook: each resolved attack, movement step, spell cast, and damage application emits a ``ReactionEvent``; the first combatant whose policy returns an ``Action(kind="immediate")`` executes it immediately, and its next turn's swift action is consumed. A readied action (``Action(kind="ready")``, standard) stores a carrier-field payload that fires off-turn through the same ``ReactionEvent`` machinery when its ``ready_trigger`` matches an enemy event, then is consumed. Flanking, full attack, charge, withdraw, and 5-foot step are modeled; attacks of opportunity are resolved inline (see below). - A weapon with `count` > 1 ("2x Talons") resolves `count` independent attacks in one attack action; remaining swings are lost once the target drops (down or dead) mid-routine. BAB iterative attacks (full-round action ``full_attack``): at BAB +6/+11/+16, additional attacks at -5/-10/-15 from the weapon's attack bonus; iteratives are only computed for weapons with ``count == 1`` (natural multi-attacks do not gain iteratives). Multiple natural weapons in a single full attack are not modeled. - Natural 1 always misses; natural 20 always hits and threatens a crit. - A confirmed crit multiplies the base damage (physical dice + flat bonus) by crit_mult; energy weapon-special-ability dice (flaming etc.) and precision dice are NOT multiplied (CRB Critical Hits exception). - DR applies once, after crit multiplication, to the PHYSICAL portion of the damage only (bludgeoning/piercing/slashing dice, flat bonuses and precision dice) — energy damage dealt along with an attack is never reduced by DR (CRB "Damage Reduction"); a single bypass type defeats DR; a multi-component "and" bypass (e.g. "bludgeoning and magic") requires EVERY component (PF1e "DR X/Y and Z" notation). - Death when negative hp reaches the Constitution score (hp <= -CON); PF1e has no -10 rule. At exactly 0 hp a living creature is disabled: active, staggered (one move OR standard action per round) at half speed, and a non-movement action costs 1 hp at its end (hp -> -1, dying). Optional rule (``monsters_die_at_zero``, CLI ``--simple-monster-death``): monster combatants die at hp <= 0 instead — player characters are never affected and the default (flag off) is the PF1e rule above, bit-identical. - Initiative ties: higher initiative_mod first, then list order (no re-roll). - Movement: a move action travels up to the combatant's speed in cells along the true shortest path (Dijkstra cost field from the target, 5-10-5 diagonals), stopping adjacent to the nearest enemy; ties keep delta order. The speed budget is the best of land and fly speed (V4); flying over obstacles or difficult terrain is NOT modeled (grid costs still apply). - Line of effect gates all attacks: a target fully behind blocking terrain cannot be attacked, and the policy moves to gain sight instead. - Cover (corner rule) grants +4 AC on hit and crit-confirm rolls; melee and ranged reuse the same corner rule. Soft cover: active creatures between attacker and target also grant +4 AC on ranged attacks (CRB soft cover); melee attacks ignore creatures. - Flanking: +2 on melee attack rolls (melee touch included) when an active ally THREATENS the target from the opposite border or corner — the ally must actually threaten the target's squares with a usable melee weapon (a reach weapon's 5-ft dead zone does not threaten) and must not be unable to act (stunned/paralyzed allies do not flank). Ranged attacks do not benefit from flanking; allies without melee weapons do not threaten. - Attacks of opportunity: a combatant threatens the 8 adjacent squares if active and wielding a melee weapon. Two triggers are modeled: (1) moving out of a threatened square — movement is resolved step-by-step, and each enemy that threatens the current square gets one AoO before the mover leaves it; (2) making a ranged attack while in a threatened square — each threatening enemy gets one AoO before each ranged swing. One AoO per combatant per round by default (tracked in ``_aoo_used``, cleared at round start); the Combat Reflexes feat raises the allowance to ``max(1, 1 + DEX mod)`` per round. Moving out of more than one square threatened by the same opponent in the same round counts as ONE opportunity for that opponent (tracked in ``_move_aoo_provoked``, cleared at round start); the ranged, casting, stand-up and maneuver triggers are separate and each still provokes. AoOs always strike (PF1e allows declining, the simulator does not) — except that an opponent with cover or total concealment relative to the attacker cannot be struck at all (the opportunity is suppressed, not spent; CRB "Cover"/"Concealment"). A 5-foot step never provokes AoOs (it is not a move action); the withdraw action protects only the starting square (see below). A 5-foot step and any other movement (move, charge, withdraw) are mutually exclusive within the same turn (``moved_this_turn`` flag, cleared in ``_take_turn``). - Charge: a full-round action that moves in a straight line (Bresenham) up to 2x speed toward the closest square from which the charger can melee the target, then makes a single melee attack at +2. The charger takes -2 AC until the start of their next turn (tracked as a StatModifier in ``effects``, cleared in ``_take_turn``). The straight line must be clear of difficult terrain, obstacles, and creatures; minimum 2 cells (10 ft). ``default_policy`` chooses a charge when the enemy is out of reach but a valid charge path exists. - Withdraw: a full-round action that moves up to 2x speed away from the nearest enemy. The starting square is not threatened — no AoO when leaving it. Subsequent squares provoke AoOs normally (resolved step-by-step, as for move). The path is a greedy ascent of the Dijkstra cost field from the threat. ``default_policy`` does not choose withdraw (it is only available via a custom policy). - 5-foot step: a free action that moves one square toward the nearest enemy without provoking AoOs. Mutually exclusive with any other movement (move, charge, withdraw) in the same turn — tracked via ``moved_this_turn``, cleared at the start of each turn in ``_take_turn``. ``default_policy`` uses ``(5foot_step, full_attack)`` when a single step brings the target into weapon range. - Ranged: cumulative -2 per full range increment beyond the first, up to 10 range increments; the penalty applies to attack and crit-confirm rolls. Thrown weapons (5 increments max) are not distinguished. """ from __future__ import annotations from collections.abc import Callable, Iterable from dataclasses import asdict, dataclass, field, replace from itertools import pairwise from pathlib import Path from typing import TYPE_CHECKING, Literal from pf1e_simulator.areas import cone_cells, line_cells from pf1e_simulator.conditions import CONDITIONS_BY_NAME, Condition from pf1e_simulator.dice import DiceExpr, dice_to_str from pf1e_simulator.effects import ( ActiveBuff, ActiveCondition, ActiveOneShot, OneShotTrack, StatModifier, StatTarget, is_stat_target, resolve_modifiers, ) from pf1e_simulator.loaders.combatant import CombatantLoadError, load_combatant from pf1e_simulator.models import PREDEFINED_SKILLS from pf1e_simulator.spells import ( AlignmentGate, AreaEffect, AreaShape, BuffEffect, CastTime, ConditionEffect, DamageEffect, HealEffect, OneShotEffect, PenaltyEffect, RecurringDamageSpec, SaveType, SpellEffect, StabilizeEffect, SummonEffect, TempHpEffect, close_range_ft, 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, SplashSpec, ) from pf1e_simulator.rng import Rng from pf1e_simulator.spells import SpellSpec TrickCondition = Literal[ "dazzled", "blinded", "deafened", "entangled", "shaken", "sickened" ] 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) _BROKEN_WEAPON_PENALTY = -2 # PF1e (CRB "Broken"): -2 attack and damage rolls _BROKEN_CRIT_MULT = 2 # PF1e (CRB "Broken"): broken weapons deal only x2 on a crit _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 _GRAPPLED_CMB_PENALTY = 2 # PF1e: grappled/pinned -2 on non-grapple/escape CMB _MINIMUM_HIT_DAMAGE = 1 # PF1e (CRB "Damage" - Minimum Damage): a hit deals >= 1 _DISARM_RETALIATION_MARGIN = 10 # PF1e SRD: disarm fails by 10 -> attacker drops # PF1e: Point-Blank Shot grants +1/+1 within 30 ft with ranged weapons _POINT_BLANK_RANGE_FT = 30 _FIRING_MELEE_PENALTY = -4 # PF1e: ranged attack into melee _FIRING_MELEE_SAFE_FT = 10 # PF1e: 2 cells (10 ft) = the -4 penalty is avoided _SNEAK_ATTACK_FEATURE = "Sneak Attack" # PF1e: rogue class feature (precision dice) _PRECISE_SHOT_FEATURE = "Precise Shot" # PF1e: feat negating firing-into-melee penalty # PF1e: feat letting a channeler exclude up to her Cha modifier targets from # her own channel energy (SRD feats/selective-channeling.md). _SELECTIVE_CHANNELING_FEAT = "Selective Channeling" _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 (SRD Cast a Spell - Components): "A spellcaster who has been deafened # has a 20% chance to spoil any spell he tries to cast if that spell has a # verbal component" (rules/prpg-core-rulebook/cast-a-spell.md). _DEAFENED_VERBAL_FAILURE_PCT = 20 # 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 (SRD "Throw Splash Weapon", KB rules/prpg-core-rulebook/ # throw-splash-weapon.md:13): "If you miss the target [...] roll 1d8. This # determines the misdirection of the throw, with 1 falling short (off-target # in a straight line toward the thrower), and 2 through 8 rotating around the # target creature or grid intersection in a clockwise direction. Then, count a # number of squares in the indicated direction equal to the range increment of # the throw [...] it deals splash damage to all creatures in that square and # in all adjacent squares." _SPLASH_D8_SIDES = 8 # The eight unit vectors in clockwise compass order starting at north (row 0 # is the TOP row of the displayed ASCII map, so displayed-clockwise is # N, NE, E, SE, S, SW, W, NW). _SPLASH_COMPASS_CW: tuple[tuple[int, int], ...] = ( (-1, 0), (-1, 1), (0, 1), (1, 1), (1, 0), (1, -1), (0, -1), (-1, -1), ) # PF1e (SRD undead/construct traits, KB rules/advanced-race-guide/ # undead-16-rp.md:15 / construct-20-rp.md:16): "not subject to ... nonlethal # damage" - the same living-creature split death_threshold already draws. _NONLETHAL_IMMUNE_TYPES: frozenset[str] = frozenset({"undead", "construct"}) # PF1e (SRD "Attack - Unarmed Attacks", KB rules/prpg-core-rulebook/ # attack.md:23): "All damage from unarmed strikes is nonlethal damage." The # data model has no damage-mode field, so the authored weapon name is the # marker: a melee attack named exactly "Unarmed Strike" (case-insensitive, # as transcribed in data/pcs/*.json) routes its damage to the nonlethal # track. Natural weapons (bite/claw) and manufactured weapons deal lethal # damage and are NOT matched; the monk/brawler lethal-by-default exception # and the declared -4 conversions (lethal weapon at nonlethal, unarmed # strike at lethal) are out of scope (documented in README). _UNARMED_STRIKE_NAME = "unarmed strike" # 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. # Heighten Spell's RAW adjustment is variable (the caster picks the # heightened level); the engine's per-cast API carries feat names only, so # it is modeled at its minimum +1 (documented simplification). _METAMAGIC_SLOT_ADJUST: dict[str, int] = { "Empower Spell": 2, "Maximize Spell": 3, "Extend Spell": 1, "Heighten 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 # PF1e (SRD "Full-Round Actions — Run"): after running a number of rounds equal # to your Constitution score, each further round of running requires a DC 10 # Constitution check whose DC rises by 1 per check made; the Run feat grants # +4 on this check (and lets the runner keep its Dexterity bonus to AC). A # failed check stops the run and forces a 1-minute (10-round) rest. _RUN_FEAT = "Run" _RUN_CHECK_BASE_DC = 10 _RUN_FEAT_CHECK_BONUS = 4 _RUN_REST_ROUNDS = 10 _OVERRUN_PRONE_MARGIN = 5 # PF1e: overrun knocks prone only on a 5+ CMB margin _BULL_RUSH_EXTRA_PER_MARGIN = 5 # PF1e: +5 ft push per 5 points above CMD _DIRTY_TRICK_EXTRA_ROUND_MARGIN = 5 # PF1e APG: +1 round per 5 points above CMD # PF1e (APG "Dirty Trick"): "The penalty is limited to one of the following # conditions: blinded, dazzled, deafened, entangled, shaken, or sickened." _DIRTY_TRICK_CONDITIONS: frozenset[str] = frozenset( {"blinded", "dazzled", "deafened", "entangled", "shaken", "sickened"} ) _REPOSITION_EXTRA_PER_MARGIN = 5 # PF1e APG: +5 ft move per 5 points above CMD _DRAG_EXTRA_PER_MARGIN = 5 # PF1e APG: +5 ft drag per 5 points above CMD _TRIP_RETALIATION_MARGIN = 10 # PF1e: trip failing by 10+ knocks the attacker prone _MANEUVER_MAX_SIZE_DIFF = 2 # PF1e: 2+ size categories larger = unmaneuverable _SAVE_DESCRIPTOR_CONDITIONS = frozenset({ "fear", "cold", "electricity", "fey", "divination", "poison", "disease", "mind_affecting", }) # SRD "Alignment" axis predicates for the attacker-alignment modifier # conditions (protection from evil/law/good/chaos: "+2 deflection bonus to AC # and a +2 resistance bonus on saves against attacks or effects created by # evil creatures"). Evaluated against the optional ``attacker`` context; # without a source in scope the condition is False. _ATTACKER_ALIGNMENT_CONDITIONS: dict[str, Callable[[Combatant], bool]] = { "attacker_is_evil": lambda c: c.is_evil, "attacker_is_lawful": lambda c: c.is_lawful, "attacker_is_good": lambda c: c.is_good, "attacker_is_chaotic": lambda c: c.is_chaotic, } _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") def _weapon_filter_matches(weapon_filter: str, weapon_name: str | None) -> bool: """True when a feat's weapon_filter names the weapon being used. PF1e Weapon Focus is taken by weapon GROUP ("Pistol"), while sheets name individual weapons ("Braises d'Automne (pistolet ?)"), so the match is a case-insensitive substring test of the filter against the weapon name. A None weapon name (no weapon context) never matches a filtered modifier. """ if weapon_name is None: return False return weapon_filter.lower() in weapon_name.lower() # PF1e natural reach for tall creatures (simplification: no long/quadruped # distinction). Tiny, Diminutive and Fine have a natural reach of 0 ft (SRD # "Big and Little Creatures in Combat"): "Creatures that take up less than 1 # square of space typically have a natural reach of 0 feet, meaning they can't # reach into adjacent squares." Unknown sizes fall back to 5 ft (the historical # behavior). _SIZE_NATURAL_REACH_FT: dict[str, int] = { "Fine": 0, "Diminutive": 0, "Tiny": 0, "Small": 5, "Medium": 5, "Large": 10, "Huge": 15, "Gargantuan": 20, "Colossal": 30, } # PF1e co-occupancy (SRD "Big and Little Creatures in Combat"): "Very small # creatures take up less than 1 square of space. This means that more than one # such creature can fit into a single square" and "They must enter an # opponent's square to attack in melee" — a Tiny-or-smaller creature shares a # square with a strictly larger one (and the larger one may move through and # end in the smaller's square). Small and larger creatures occupy a full # square and never share. The rank ceiling for "takes up less than 1 square". _TINY_OR_SMALLER_MAX_RANK: int = _size_rank("Tiny") # SRD "Moving Through a Square": "Any creature can move through a square # occupied by a creature three size categories larger than itself." The # minimum size-rank gap that opens the pass-through gate. _PASS_THROUGH_MIN_SIZE_GAP: int = 3 def _can_cooccupy(size_a: str, size_b: str) -> bool: """True when two sizes may share a single grid square (PF1e co-occupancy). Only a Tiny-or-smaller creature ("less than 1 square") and a STRICTLY larger creature share; two same-size creatures never do. """ rank_a = _size_rank(size_a) rank_b = _size_rank(size_b) if rank_a == rank_b: return False return min(rank_a, rank_b) <= _TINY_OR_SMALLER_MAX_RANK def _can_pass_through(size_a: str, size_b: str) -> bool: """True when a creature of ``size_a`` may move THROUGH a square occupied by ``size_b``. PF1e (SRD "Movement, Position, and Distance" / "Moving Through a Square"): "You can move through a square occupied by a creature three size categories larger than you" and "A big creature can move through a square occupied by a creature three size categories smaller than it" — the size ranks must differ by at least three categories. The pass-through may cross the occupied square but never END on it (``_blocked`` keeps the co-occupancy-only end gate; SRD: "You can't end your movement in the same square as another creature unless it is helpless"). """ return abs(_size_rank(size_a) - _size_rank(size_b)) >= _PASS_THROUGH_MIN_SIZE_GAP # 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 _damage_effect_summary(effect: DamageEffect | PenaltyEffect, tags: list[str]) -> str: parts = [dice_to_str(effect.formula), *tags] if effect.half_on_save: parts.append("half on save") return " ".join(parts) def _simple_effect_summary( effect: OneShotEffect | TempHpEffect | StabilizeEffect, ) -> str: if isinstance(effect, OneShotEffect): return ( f"one-shot +{effect.value} {effect.bonus_type} on {effect.track}" ) if isinstance(effect, TempHpEffect): return f"temp hp +{effect.amount} ({effect.duration_rounds}r)" return "stabilize" def _non_damage_effect_summary( effect: ( ConditionEffect | HealEffect | BuffEffect | AreaEffect | SummonEffect | OneShotEffect | TempHpEffect | StabilizeEffect ) ) -> str: 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, AreaEffect): return f"area {effect.radius_ft}ft" if isinstance(effect, SummonEffect): return f"summon {effect.creature}" if isinstance(effect, (OneShotEffect, TempHpEffect, StabilizeEffect)): return _simple_effect_summary(effect) return f"buff {effect.duration_rounds}r" def _spell_effect_summary(effect: SpellEffect) -> str: """Short serializable summary of one spell effect (never the description).""" if isinstance(effect, (DamageEffect, PenaltyEffect)): tags: list[str] = ( list(effect.types) if isinstance(effect, DamageEffect) else list(effect.targets) ) return _damage_effect_summary(effect, tags) return _non_damage_effect_summary(effect) 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_centered_on_caster(spell: SpellSpec) -> bool: """True when the spell's WHOLE delivery is a caster-centered area. SRD bless.md ("The caster and all allies within a 50-ft. burst, centered on the caster") / bane.md ("50-ft.-radius burst, centered on you"): the chosen target is irrelevant to the geometry, so there is no primary target to save against - every creature in the area rolls its own (like the U7b point cast). """ return bool(spell.effects) and all( isinstance(effect, AreaEffect) and effect.centered_on_caster for effect in spell.effects ) 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, PenaltyEffect) and effect.half_on_save: return True if isinstance(effect, AreaEffect) and effect.save_for_half: return True return False def _spell_declares_projectiles(spell: SpellSpec) -> bool: """True when a top-level damage effect carries a projectile-scaling spec. Such spells (Magic Missile, Scorching Ray) resolve EVERY projectile's attack roll inside ``_apply_damage_effect`` — even a single-ray Scorching Ray — so the one-shot delivery-gate ranged touch attack is skipped for them. Rays without a projectile spec (Acid Arrow, Ray of Frost) keep the single delivery attack unchanged. """ return any( isinstance(e, DamageEffect) and e.projectiles is not None for e in spell.effects ) def _spell_area_dict(spell: SpellSpec) -> dict[str, object] | None: """Structured area template of a spell, or None if it has no top-level AreaEffect. Returns ``{"shape": str, "radius_ft": int, "centered_on_caster": bool}`` 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, "centered_on_caster": effect.centered_on_caster, } 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 _deals_nonlethal_by_default(weapon: AttackSpec) -> bool: """True when the weapon's damage goes to the nonlethal track with no declaration (SRD "Unarmed Attacks": "All damage from unarmed strikes is nonlethal damage", KB attack.md:23). Detection is the authored name heuristic documented at _UNARMED_STRIKE_NAME.""" return weapon.kind == "melee" and weapon.name.strip().lower() == ( _UNARMED_STRIKE_NAME ) def _split_landed_damage(weapon: AttackSpec, final: int) -> tuple[int, int]: """Split a landed hit's final damage into ``(lethal, nonlethal)``. PF1e (SRD "Unarmed Attacks"): an unarmed strike's damage is entirely nonlethal. PF1e (CRB "Damage" - Minimum Damage): "If penalties reduce the damage result to less than 1, a hit still deals 1 point of nonlethal damage" - a lethal hit cut below 1 by DR/resistance becomes 1 nonlethal point, and a nonlethal hit floors at 1 nonlethal point too. """ if _deals_nonlethal_by_default(weapon): return 0, max(_MINIMUM_HIT_DAMAGE, final) if final < _MINIMUM_HIT_DAMAGE: return 0, _MINIMUM_HIT_DAMAGE return final, 0 def _hit_damage_segment(result: AttackResult, hp_before: int, hp_after: int) -> str: """Damage part of a HIT transcript line: lethal, nonlethal, or both. SRD "Nonlethal Damage": the nonlethal total is logged beside the lethal one because it never shows up in the hp delta. """ parts: list[str] = [] if result.damage: parts.append(f"{result.damage} damage") if result.nonlethal: parts.append(f"{result.nonlethal} nonlethal") if not parts: parts.append("0 damage") return f" {' + '.join(parts)} ({hp_before}->{hp_after})" 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. ``healing_done`` is hit points *restored* by the combatant (channel heal and spell HealEffect, capped at the target's hp_max); healing is never counted in ``damage_dealt``/``damage_taken`` (those stay damage-only). """ hits: int = 0 crits: int = 0 damage_dealt: int = 0 damage_taken: int = 0 healing_done: int = 0 _PC_SOURCE_PREFIX = "fiches_personnages/" def _is_pc_combatant(combatant: Combatant) -> bool: """True for player characters, False for monsters (X5 classification). The unified loader records each combatant's origin in ``source``: every PC JSON (``data/pcs/*.json``) points at its Foundry sheet under ``fiches_personnages/`` (README convention), while monster JSONs carry bestiary/summon sources. ``side`` is a user-chosen faction name and ``cr``/``xp`` are absent from some monster files, so ``source`` is the only robust per-combatant marker that survives loading without a schema change. """ return combatant.source.startswith(_PC_SOURCE_PREFIX) @dataclass(frozen=True) class RecurringDamage: """One active damage-over-time source on a combatant (S2c, Acid Arrow). - ``formula``: dice rolled each recurring round (2d4) - ``types``: energy types, reduced by resistance/immunity (never DR) - ``rounds_left``: rounds this source still deals damage (decremented and removed at the start of the owner's turn once it ticks) - ``source``: human-readable origin for the transcript """ formula: DiceExpr types: frozenset[str] rounds_left: int source: str @dataclass(frozen=True) class PendingCast: """A one-round cast begun this turn, to be resolved next turn. PF1e (SRD "Casting Time"): "A spell that takes 1 round to cast is a full-round action. It comes into effect just before the beginning of your turn in the round after you began casting the spell. You then act normally after the spell is completed." Everything the delivery still needs is frozen at the start of the casting (the slot is already spent): ``slot_level`` carries the metamagic-inflated level (B7) that drives the concentration checks and the Heighten Spell DC (other metamagic does not raise the DC - see _spell_dc), ``hp_at_cast`` is the caster's hp when the casting began — the net hp lost before resolution is the interruption damage that forces the concentration check (CRB "Concentration": injured while casting, DC 10 + damage + spell level). """ spell_name: str target_id: str | None center_pos: tuple[int, int] | None metamagic: tuple[str, ...] slot_level: int cast_defensively: bool hp_at_cast: int @dataclass(frozen=True) class HeldCharge: """A touch spell whose melee touch attack missed - the charge is held. PF1e (SRD "Cast a Spell - Touch Attacks", Holding the Charge): "If you don't discharge the spell in the round when you cast the spell, you can hold the charge indefinitely. You can continue to make touch attacks round after round." A missed melee touch therefore does NOT expend the spell (the slot was spent at the cast; the spell itself survives to be re-attempted). Casting another spell dissipates it ("If you cast another spell, the touch spell dissipates"). Ranged touch attacks are NOT held: SRD "Ranged Touch Spells in Combat" - "ranged touch attacks cannot be held until a later turn". """ spell_name: str target_id: str metamagic: tuple[str, ...] slot_level: int @dataclass class CombatantState: """Mutable runtime state of one combatant on the grid.""" combatant: Combatant side: str pos: Pos hp: int # Old fields for backwards compatibility with tests effects: list[StatModifier] = field(default_factory=list) conditions: list[Condition] = field(default_factory=list) # New duration-tracked fields active_buffs: list[ActiveBuff] = field(default_factory=list) active_conditions: list[ActiveCondition] = field(default_factory=list) # One-shot typed bonuses (SRD spells/guidance.md) discharged by the first # roll of their track and expiring with their duration. one_shot_bonuses: list[ActiveOneShot] = field(default_factory=list) # Temporary hit points (SRD spells/virtue.md): absorb damage before real # hp (CRB "Temporary Hit Points") and expire when the granting effect's # duration ends (temp_hp_rounds_left, decremented like buffs). temp_hp: int = 0 temp_hp_rounds_left: int = 0 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): damage dealt # PAST the object's hardness per weapon id (CRB "Breaking and Entering": # "Only damage in excess of its hardness is deducted from the object's # hit points"), the set of weapon ids with the broken condition (usable # at -2 with restricted crits — CRB "Broken"), and the set of weapon ids # destroyed by a sunder (hp <= 0: "When an object's hit points reach 0, # it's ruined" — unusable). weapon_damage: dict[str, int] = field(default_factory=dict) weapon_broken: set[str] = field(default_factory=set) weapon_destroyed: 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) # Runtime carried items (APG "Steal"): mutable copy of # ``combatant.carried_items``; a successful steal transfers one item from # the victim's list to the thief's, and an unarmed successful disarm's # automatic pickup appends the dropped weapon there. carried_items: list[str] = field(default_factory=list) # Runtime disarmed weapons (not in the frozen data model): weapon ids the # combatant DROPPED on a failed disarm maneuver (SRD Disarm: "your # opponent drops the weapon"). A retrieve_weapon move action picks the # weapon back up. weapon_dropped: 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. The total_defense flag additionally marks # that the combatant is using total defense — PF1e (SRD "Total Defense"): # "You can't make attacks of opportunity while using total defense." defensive_ac_bonus: int = 0 fight_defensively: bool = False total_defense: 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. PF1e (SRD "Aid Another"): the +2 lasts "as long as that # attack comes before the beginning of your next turn" — YOUR = the AIDER, # so each grant is tracked on the aider (target id, mode) and expires at # the start of the aider's next turn. The running flag denies the Dex # bonus to AC until the runner's next turn. aid_attack_bonus: int = 0 aid_ac_bonus: int = 0 aid_grants: list[tuple[str, str]] = field(default_factory=list) running: bool = False # Run endurance (SRD "Full-Round Actions — Run", not in the frozen data # model): run_rounds counts the CONSECUTIVE rounds this combatant has run; # once it reaches the combatant's Constitution score, each further round # requires a Constitution check (DC 10 + run_checks, +4 with the Run feat); # a failure stops the run and sets run_rest_rounds (10-round rest, no # running). Undead/constructs have no Constitution score: exempt. run_rounds: int = 0 run_checks: int = 0 run_rest_rounds: int = 0 # Bleeding (V5b, not in the frozen data model): damage taken at the start # of each turn; any effect that restores hit points clears it. bleed_damage: int = 0 # PF1e nonlethal damage (SRD "Nonlethal Damage", not in the frozen data # model): a running total that is NEVER deducted from hp ("Do not deduct # the nonlethal damage number from your current hit points. It is not # 'real' damage"). The staggered/unconscious thresholds are DERIVED from # this total against current hp (``nonlethal_staggered`` / # ``nonlethal_unconscious``) rather than stored as condition objects - # the SRD state is a pure function of the two numbers ("You cease being # staggered when your current hit points once again exceed your nonlethal # damage"), so a derived flag can never desync, double up with an # injected condition, or fight the duration-tick machinery. nonlethal: int = 0 # PF1e recurring spell damage (S2c, not in the frozen data model): each # entry is one damage-over-time source (Acid Arrow's lingering acid) that # deals its dice at the start of this combatant's turn for `rounds_left` # rounds, reduced by energy resistance/immunity (never DR). recurring_damage: list[RecurringDamage] = field(default_factory=list) # PF1e one-round casting (SRD "Casting Time", not in the frozen data # model): the spell begun on this combatant's turn, resolved just before # its NEXT turn begins; None when no casting is in progress. pending_cast: PendingCast | None = None # PF1e held touch charge (SRD "Cast a Spell - Touch Attacks", Holding the # Charge, not in the frozen data model): a touch spell whose melee touch # attack missed; re-attempted at the start of each later turn of this # combatant while the target lives and stands in reach, dissipated when # this combatant casts another spell. None when no charge is held. held_charge: HeldCharge | None = None def usable_attacks(self) -> list[AttackSpec]: # PF1e (CRB "Breaking Items" / "Conditions - Broken"): a broken # weapon is still USABLE — it just suffers -2 on attack and damage # rolls with crits restricted to natural 20 at x2 (applied in # resolve_attack). Destroyed weapons (hp <= 0) are unusable. # FIREARM EXCEPTION: a broken firearm (misfire, Ultimate Combat # "Firearm Rules - Misfires") is kept unusable until Quick Clear or # a repair — the SRD would let it fire at -2 with its misfire value # raised by 4, but the misfire escalation and the exploding-early- # firearm rules are not modeled, so re-firing a broken gun is # represented as not possible (documented simplification). return [ w for w in self.combatant.attacks if w.id not in self.weapon_destroyed and w.id not in self.weapon_stolen and w.id not in self.weapon_dropped and not (w.id in self.weapon_broken and w.firearm) ] 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) if not self.carried_items: self.carried_items = list(self.combatant.carried_items) 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 def take_nonlethal(self, amount: int) -> int: """Accumulate nonlethal damage; return the portion treated as LETHAL. PF1e (SRD "Nonlethal Damage"): nonlethal damage is a running total never deducted from hp, so the track simply grows - EXCEPT the max-hp overflow rule: "If a creature's nonlethal damage is equal to his total maximum hit points (not his current hit points), all further nonlethal damage is treated as lethal damage" (the regeneration exception is not modeled: no combatant has regeneration). Undead and constructs are NOT subject to nonlethal damage (SRD undead/construct traits, KB rules/advanced-race-guide/undead-16-rp.md:15, construct-20-rp.md:16) - the damage is discarded. Returns the amount the caller must apply as real hp damage (0 in every other case). """ if amount <= 0: return 0 if self.combatant.creature_type in _NONLETHAL_IMMUNE_TYPES: return 0 if self.nonlethal >= self.combatant.hp_max: return amount self.nonlethal += amount return 0 @property def nonlethal_unconscious(self) -> bool: """PF1e (SRD "Nonlethal Damage"): "when it [nonlethal damage] exceeds your current hit points, you fall unconscious" - and "While unconscious, you are helpless". Derived, so healing hp back above the nonlethal total (or removing nonlethal) restores consciousness with no bookkeeping; below 0 hp the dying rules already govern the creature.""" return self.hp >= 0 and self.nonlethal > self.hp and self.nonlethal > 0 @property def nonlethal_staggered(self) -> bool: """PF1e (SRD "Nonlethal Damage"): "when your nonlethal damage equals your current hit points, you're staggered ... You cease being staggered when your current hit points once again exceed your nonlethal damage." Strictly equal: exceeding the total is the unconscious case (``nonlethal_unconscious``), which subsumes the staggered restriction.""" return self.hp >= 0 and self.nonlethal > 0 and self.nonlethal == self.hp @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. A creature knocked unconscious by nonlethal damage (SRD "Nonlethal Damage") is inactive exactly like a dying one: it cannot act, threatens no square, and counts as downed for the winner check - but its hp is untouched.""" return self.hp >= 0 and not self.dead and not self.nonlethal_unconscious @property def dead(self) -> bool: return self.hp <= self.death_threshold @property def occupies_space(self) -> bool: """True while the creature's squares count as creature-occupied. A dying/unconscious (inactive but not dead) creature still occupies its squares: its square grants soft cover on a ranged line (SRD cover.md: cover exists when a line passes "through a square occupied by a creature"), and an area effect catches it ("A burst spell affects whatever it catches in its area, including creatures that you can't see", SRD aiming-a-spell.md). Fully dead creatures — corpses — are EXCLUDED (documented choice): the SRD occupancy rule speaks of creatures; a corpse is an object, and the engine models no corpse obstacles. """ return not self.dead @property def death_threshold(self) -> int: """PF1e (CRB): death when negative hp reaches the CON score (no -10 rule). Undead and constructs have no Constitution score and are "immediately destroyed when reduced to 0 hit points or fewer" (SRD Undead/Construct traits). The optional simple monster-death rule raises the threshold to 0.""" if self.dies_at_zero: return 0 if self.combatant.creature_type in ("undead", "construct"): 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 # SRD "Nonlethal Damage": damage this attack routed to the target's # separate nonlethal track (NOT included in ``damage``; ``damage`` only # grows by the max-hp overflow the track could not hold). nonlethal: int = 0 # SRD "Throw Splash Weapon": total splash damage this attack dealt to the # creatures around the point of impact (0 for a non-splash weapon). The # splash is flat energy damage - never multiplied on a critical, never # cut by DR, and NOT included in ``damage`` (which is target-only). splash: 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 copies (tuples/dicts), never read back by the engine, so a policy may mutate them freely without touching combat; the snapshot is JSON-serializable (the top level is a frozen dataclass; the inner dicts are plain mutable copies, not deep-frozen). ``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 SkillCheckResult: """Outcome of one d20 skill check: the raw die and the full total. No success/failure verdict lives here - a skill check only succeeds against a DC (``CombatEngine.resolve_skill``) or against an opposing check (CRB "Skill Checks": "the attempt is successful if your check result exceeds the result of the target"). """ roll: int total: 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 (unless it has the Run feat). Running beyond a number of consecutive rounds equal to the runner's Constitution score requires a DC 10 Constitution check each round (Run feat: +4). ``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 # PF1e (SRD Feats > Selective Channeling): "When you channel energy, you # can choose a number of targets in the area up to your Charisma # modifier. These targets are not affected by your channeled energy." # Ids the channeler's policy excludes from THIS channel; honored only # with the feat, capped at the Charisma modifier (excess entries drop). channel_exclude: tuple[str, ...] = () direction: tuple[int, int] | None = None # steering for directional maneuvers trick_condition: TrickCondition | None = None # dirty trick condition choice # weapon id for attack / full_attack / retrieve_weapon / repair_weapon weapon_id: str | None = None ready_trigger: ReadyTrigger | None = None # ready action trigger event destination: tuple[int, int] | None = None # explicit (row, col) cell for move / 5foot_step target_pos: tuple[int, int] | None = None # explicit (row, col) AoE center for cast_spell aid_mode: Literal["attack", "ac"] | None = None # aid another: bonus to grant (default attack) # B7: metamagic feats to apply to THIS cast (only known ones apply) metamagic: tuple[str, ...] = () # B24: cast defensively — no AoO, but a concentration check or the spell fizzles cast_defensively: bool = False # Normalized action-result reason vocabulary. U1 emits ok, unsupported_kind, # missing_target and the downgraded:* reasons; no_weapon, out_of_range, # no_line_of_effect are reused by U6b to explain, per pair, why an attack or # a charge is unavailable. no_slot and insufficient_resource are declared # here for the deeper execution-feedback package. U3 adds delayed / # already_delayed for the delay action. U4 adds ready for the ready action. # U5b adds not_prone / already_prone for the prone actions. U5c adds # unreachable_destination for move / 5foot_step with an explicit destination. # U5d adds weapon_unavailable for attack / full_attack with an explicit # weapon_id that cannot be honoured. U7b adds invalid_destination for # cast_spell with an explicit target_pos that cannot be honoured. U5e reuses # no_weapon (aid another without a usable melee weapon) and # unreachable_destination (run to an unhonourable destination). X4 adds # downgraded:action_economy for actions the general per-turn budget filter # drops from the policy sequence (second standard, second move when no # standard is left to convert, second swift, full-round conflicting with # standard+move). The cast-time package adds downgraded:cast_time for # cast_spell actions whose spell has a long casting time (more than 1 round): the # engine cannot schedule them within a turn, so they are dropped before the # budget filter runs. S3a adds downgraded:surprise for actions the surprise # round allowance (SRD "Surprise": one standard OR move plus free) drops from # an aware combatant's sequence during the surprise round. ACTION_RESULT_REASONS: frozenset[str] = frozenset({ "ok", "unsupported_kind", "missing_target", "no_weapon", "out_of_range", "no_line_of_effect", "no_slot", "insufficient_resource", "downgraded:staggered", "downgraded:nauseated", "downgraded:action_economy", "downgraded:cast_time", "downgraded:surprise", "downgraded:immediate_swift", "delayed", "already_delayed", "ready", "not_prone", "already_prone", "unreachable_destination", "weapon_unavailable", "invalid_destination", "cannot_combine", }) _REASON_OK = "ok" _REASON_UNSUPPORTED_KIND = "unsupported_kind" _REASON_MISSING_TARGET = "missing_target" _REASON_DOWNGRADED_STAGGERED = "downgraded:staggered" _REASON_DOWNGRADED_NAUSEATED = "downgraded:nauseated" _REASON_DOWNGRADED_IMMEDIATE_SWIFT = "downgraded:immediate_swift" _REASON_DOWNGRADED_ACTION_ECONOMY = "downgraded:action_economy" _REASON_DOWNGRADED_CAST_TIME = "downgraded:cast_time" _REASON_DOWNGRADED_SURPRISE = "downgraded:surprise" _REASON_DELAYED = "delayed" _REASON_ALREADY_DELAYED = "already_delayed" _REASON_READY = "ready" _REASON_NOT_PRONE = "not_prone" _REASON_ALREADY_PRONE = "already_prone" _REASON_UNREACHABLE_DESTINATION = "unreachable_destination" _REASON_WEAPON_UNAVAILABLE = "weapon_unavailable" _REASON_INVALID_DESTINATION = "invalid_destination" _REASON_NO_WEAPON = "no_weapon" _REASON_OUT_OF_RANGE = "out_of_range" _REASON_NO_LINE_OF_EFFECT = "no_line_of_effect" _REASON_CANNOT_COMBINE = "cannot_combine" # 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 healing_done: 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] = [] # Occurrence counts per combatant id, seeded from the deployed roster: # summons disambiguate duplicate creature ids exactly the way # build_states does (first keeps the bare id, later ones get -2, -3...), # so each summoned creature owns a distinct stats slot. self._id_counts: dict[str, int] = {} for st in states: base = st.combatant.id self._id_counts[base] = self._id_counts.get(base, 0) + 1 # True while the surprise round runs (SRD "Surprise"): the turn filter # then degrades every aware combatant's sequence to a single standard # OR move action plus free actions. self._surprise_round = False @property def states(self) -> list[CombatantState]: """The live list of combatant states in initiative order.""" return self._states @property def grid(self) -> Grid: """The combat grid.""" return self._grid @property def spell_registry(self) -> dict[str, SpellSpec]: """The spell registry used for spell lookups.""" return self._spell_registry def _apply_simple_monster_death(self, state: CombatantState) -> None: """Opt-in X5 rule: with ``monsters_die_at_zero``, monster combatants die at hp <= 0 (no dying/disabled band); PCs are never marked.""" if self._monsters_die_at_zero and not _is_pc_combatant(state.combatant): state.dies_at_zero = True def _llm_adapter(self, llm_policy: LLMPolicy) -> Policy: """Wrap an ``LLMPolicy`` into a ``Policy``. At the point of invocation (``actions = self._policy(self, state)`` in ``_take_turn``), the Observation is built for the current state and delegated to ``llm_policy(observation, state)``. The adapter consumes no RNG and mutates no state. """ def adapter(_engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]: observation = self._build_observation(state) return llm_policy(observation, state) return adapter def _build_observation(self, state: CombatantState) -> Observation: """Build a frozen, serializable snapshot of the current combat state. Pure read: never mutates state nor consumes RNG. ``state`` is the acting combatant (the one whose turn it is). """ acting_index = self._states.index(state) combatants = tuple(self._combatant_snapshot(s) for s in self._states) pairs = tuple( self._pair_snapshot(state, target) for target in self._states if target is not state ) terrain = tuple( tuple(self._grid.terrain_at((r, c)).type for c in range(self._grid.width)) for r in range(self._grid.height) ) has_elevation = getattr(self._grid, "_elevation", None) is not None if has_elevation: elevation = tuple( tuple( self._grid.elevation_at((r, c)) for c in range(self._grid.width) ) for r in range(self._grid.height) ) else: elevation = () threatened = self._threat_grid(state) ranged_threatened = self._ranged_threat_grid(state) terrain_semantics = self._terrain_semantics() condition_definitions = tuple( (name, _condition_definition_dict(cond)) for name, cond in sorted(CONDITIONS_BY_NAME.items()) ) initiative_order = tuple( s.combatant.id for s in self._build_round_order() ) return Observation( round=self._current_round, acting_combatant=acting_index, combatants=combatants, pairs=pairs, terrain=terrain, elevation=elevation, threatened=threatened, ranged_threatened=ranged_threatened, terrain_semantics=terrain_semantics, condition_definitions=condition_definitions, initiative_order=initiative_order, ) def _threat_grid(self, state: CombatantState) -> tuple[tuple[bool, ...], ...]: """Build the height x width bool grid of cells threatened by enemies. Union of ``threatened_squares(e)`` for every enemy ``e`` of ``state`` (active, opposite side, with a usable melee weapon). The union is bounded to the grid: cells produced by the public helper that lie outside the map are dropped (helper output is in 5-ft-square coords and may overflow on small maps). """ enemies = ( s for s in self._states if s is not state and s.active and s.side != state.side and any(w.kind == "melee" for w in s.usable_attacks()) ) threatened: set[Pos] = set() for enemy in enemies: threatened |= self.threatened_squares(enemy) return tuple( tuple((r, c) in threatened for c in range(self._grid.width)) for r in range(self._grid.height) ) def _ranged_threat_grid(self, state: CombatantState) -> tuple[tuple[bool, ...], ...]: """Build the height x width bool grid of cells under enemy ranged fire. Sibling of ``_threat_grid`` for RANGED weapons: the union over every active enemy ``e`` of ``state`` (opposite side) of the cells each of e's usable ranged-style weapons can reach (see ``_cell_under_ranged_fire``). Bounded to the grid. Read-only, no RNG. PF1e ranged weapons grant no attacks of opportunity — this is a tactical field-of-fire map, not an AoO threat; the melee ``threatened`` grid is unchanged by it. """ covered = self._ranged_covered_cells(state) return tuple( tuple((r, c) in covered for c in range(self._grid.width)) for r in range(self._grid.height) ) def _ranged_covered_cells(self, state: CombatantState) -> set[Pos]: """Cells covered by any usable ranged-style weapon of state's enemies.""" covered: set[Pos] = set() for enemy in self._states: if enemy is state or not enemy.active or enemy.side == state.side: continue weapons = [w for w in enemy.usable_attacks() if self._is_ranged_attack(w)] if not weapons: continue occupied = enemy.occupied_cells() for r in range(self._grid.height): for c in range(self._grid.width): cell = (r, c) if cell not in covered and self._cell_under_ranged_fire( occupied, cell, weapons ): covered.add(cell) return covered def _cell_under_ranged_fire( self, occupied: frozenset[Pos], cell: Pos, weapons: list[AttackSpec], ) -> bool: """True if some ranged weapon in ``weapons`` reaches ``cell`` with LoE. Reach uses the same maximum-range rule as ``_weapon_usable_against``: ``range_increment_ft`` x 10 increments (x 5 for touch), measured from the shooter's closest occupied cell under the 5-10-5 rule; the line of effect runs from the shooter's footprint to the bare cell. """ dist_ft = ( min(self._grid.distance(from_cell, cell) for from_cell in occupied) * _SQUARE_FT ) for weapon in weapons: increment = weapon.range_increment_ft if increment is None: continue max_increments = ( _MAX_TOUCH_RANGE_INCREMENTS if weapon.kind == "touch" else _MAX_RANGE_INCREMENTS ) if dist_ft > increment * max_increments: continue if has_line_of_effect_cells(self._grid, occupied, frozenset({cell})): return True return False def _terrain_semantics(self) -> tuple[dict[str, object], ...]: """One dict per distinct ``TerrainType`` on the map (sorted by type). Derived by deduplicating ``grid.terrain_at`` over the entire map: the Grid stores resolved ``TerrainType`` objects in ``_cells`` but does not expose ``MapSpec.legend`` directly, so iterating the cells is the safest source. The Grid is guaranteed to hold at least one cell (constructor enforces non-empty ``cells``). """ seen: dict[str, TerrainType] = {} for r in range(self._grid.height): for c in range(self._grid.width): cell = self._grid.terrain_at((r, c)) seen.setdefault(cell.type, cell) return tuple( { "type": cell.type, "move_cost": cell.move_cost, "blocks_los": cell.blocks_los, "cover": cell.cover, "cover_bonus": cell.cover_bonus, } for cell in (seen[name] for name in sorted(seen)) ) def _combatant_snapshot(self, state: CombatantState) -> dict[str, object]: """One per-combatant dict for the Observation (frozen copies).""" conditions = tuple( (c.condition.name, c.rounds_left) for c in state.active_conditions ) + tuple((c.name, 0) for c in state.conditions) resources = dict(state.resources_left) for level, count in state.spell_slots_left.items(): resources[f"spell_slot_{level}"] = count dr = None if state.combatant.dr is not None: dr = { "amount": state.combatant.dr.amount, "bypass": ",".join(sorted(state.combatant.dr.bypass)), } return { "id": state.combatant.id, "side": state.side, "pos": state.pos, "occupied_cells": tuple(sorted(state.occupied_cells())), "hp": state.hp, "hp_max": state.combatant.hp_max, # SRD "Nonlethal Damage": separate running total; staggered when # it equals hp, unconscious (helpless) when it exceeds hp - the # policy derives the thresholds from (nonlethal, hp) itself. "nonlethal": state.nonlethal, "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_destroyed": tuple(sorted(state.weapon_destroyed)), "weapon_stolen": tuple(sorted(state.weapon_stolen)), "weapon_dropped": tuple(sorted(state.weapon_dropped)), "carried_items": tuple(state.carried_items), "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, "run_rounds": state.run_rounds, "run_rest_rounds": state.run_rest_rounds, "bleed_damage": state.bleed_damage, "recurring_damage": [ { "formula": dice_to_str(entry.formula), "types": tuple(sorted(entry.types)), "rounds_left": entry.rounds_left, } for entry in state.recurring_damage ], "aoo_remaining": max( 0, self._aoo_allowance(state) - self._aoo_used.get(state.combatant.id, 0), ), "name": state.combatant.name, "level": state.combatant.level, "size": state.combatant.size, "natural_reach_ft": _SIZE_NATURAL_REACH_FT.get(state.combatant.size, 5), "aware": state.combatant.aware, "alignment": state.combatant.alignment, "armor_spell_failure": state.combatant.armor_spell_failure, "arcane_caster": state.combatant.arcane_caster, "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 ), "one_shot_bonuses": tuple( { "track": shot.track, "value": shot.value, "bonus_type": shot.bonus_type, "rounds_left": shot.rounds_left, "source": shot.source, } for shot in state.one_shot_bonuses ), "temp_hp": state.temp_hp, "temp_hp_rounds_left": state.temp_hp_rounds_left, "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, "splash": ( {"damage": attack.splash.damage, "type": attack.splash.type} if attack.splash is not None else None ), "hardness": attack.object_stats()[0], "hit_points": attack.object_stats()[1], } for attack in state.combatant.attacks ), "features": tuple(sorted(f.name for f in state.combatant.features)), "feature_details": tuple( { "name": feature.name, "category": feature.category, "source_detail": feature.source_detail, "effects": tuple( _stat_modifier_dict(mod) for mod in feature.effects ), } for feature in sorted( state.combatant.features, key=lambda f: f.name ) ), "spells": tuple( self._spell_snapshot(state, name) for name in state.combatant.spells ), "spell_resistance": state.combatant.spell_resistance, "resistances": dict(state.combatant.resistances), "immunities": tuple(state.combatant.immunities), "sneak_dice": ( (state.combatant.level + 1) // 2 if self._has_feat(state, _SNEAK_ATTACK_FEATURE) else 0 ), "engaged_in_melee": self._engaged_in_melee(state), "last_turn_results": tuple( {"kind": r.kind, "ok": r.ok, "reason": r.reason} for r in state.last_action_results ), "readied": ( {"kind": state.readied.kind, "trigger": state.readied_trigger} if state.readied is not None else None ), "pending_cast": ( None if state.pending_cast is None else { "spell_name": state.pending_cast.spell_name, "target_id": state.pending_cast.target_id, "slot_level": state.pending_cast.slot_level, } ), } def _pair_snapshot( self, state: CombatantState, target: CombatantState ) -> dict[str, object]: """One per-pair dict (acting combatant x target) for the Observation.""" distance_ft = self.creature_distance(state, target) * _SQUARE_FT weapon = self.weapon_for(state, target) weapon_name = weapon.name if weapon is not None else None range_penalty = ( self.range_penalty(weapon, distance_ft) if weapon is not None else 0 ) charge = self.can_charge(state, target) can_charge = charge is not None and charge.weapon is not None occupied = state.occupied_cells() line_of_effect = has_line_of_effect_cells( self._grid, occupied, target.occupied_cells() ) weapon_reason = self._weapon_failure_reason( state, weapon, line_of_effect=line_of_effect ) charge_reason: str | None = None if not can_charge: # can_charge() returns None only when no melee weapon is usable. charge_reason = charge.reason if charge is not None else _REASON_NO_WEAPON target_threatens_me = bool( target.active and any(w.kind == "melee" for w in target.usable_attacks()) and self.threatened_squares(target) & occupied ) threatening_enemies: set[str] = set() for cell in occupied: for enemy in self.enemies_threatening(state, cell): threatening_enemies.add(enemy.combatant.id) cover_bonus = 0 if weapon is not None: others = frozenset( cell for s in self._states if s.occupies_space and s is not state and s is not target for cell in s.occupied_cells() ) cover_bonus = cover_bonus_cells( self._grid, state.occupied_cells(), target.occupied_cells(), ranged=self._is_ranged_attack(weapon), occupied=others, ) return { "target_index": self._states.index(target), "distance_ft": distance_ft, "weapon_for": weapon_name, "weapon_reason": weapon_reason, "range_penalty": range_penalty, "can_charge": can_charge, "charge_reason": charge_reason, "can_step5_to_attack": self.can_step5_to_attack(state, target), "has_line_of_effect": line_of_effect, "flanking": self._flanking_bonus(state, target) > 0, "movement_cost_ft": self.movement_cost_ft(state, target), "target_threatens_me": target_threatens_me, "enemies_threatening_me": len(threatening_enemies), "cover_bonus": cover_bonus, } def _weapon_failure_reason( self, attacker: CombatantState, weapon: AttackSpec | None, *, line_of_effect: bool, ) -> str | None: """Why ``weapon_for`` found no weapon against a target, or None if it did. U6b: the cause is drawn from the ``ACTION_RESULT_REASONS`` vocabulary — no usable weapon at all, no line of effect, or every usable weapon out of range — so a policy learns which obstacle to remove. """ if weapon is not None: return None if not attacker.usable_attacks(): return _REASON_NO_WEAPON if not line_of_effect: return _REASON_NO_LINE_OF_EFFECT return _REASON_OUT_OF_RANGE def _resolve_pending_cast(self, caster: CombatantState) -> None: """PF1e (SRD "Casting Time"): a one-round cast "comes into effect just before the beginning of your turn in the round after you began casting the spell. You then act normally after the spell is completed." The caster is busy casting through the intervening round: net hp lost between the start of the casting and this moment forces the injured-while-casting concentration check (CRB "Concentration": DC 10 + damage + spell level, no Combat Casting bonus) or the spell is lost. Bounded model (documented): interruption is measured as NET hp loss at resolution instead of a hook at every damage site, so healing in between can mask damage taken. """ pending = caster.pending_cast if pending is None: return caster.pending_cast = None spell = self._spell_registry.get(pending.spell_name) if spell is None: return damage = max(0, pending.hp_at_cast - caster.hp) if damage > 0 and not self._check_concentration( caster, pending.slot_level, damage ): dc = self._concentration_dc(caster, pending.slot_level, damage) self._log( f"round {self._current_round} {caster.combatant.id}: " f"one-round cast of {pending.spell_name} interrupted: " f"concentration check failed (DC {dc}), spell fizzles" ) return self._log( f"round {self._current_round} {caster.combatant.id}: " f"finishes casting {pending.spell_name}" ) target = ( next( (s for s in self._states if s.combatant.id == pending.target_id), None, ) if pending.target_id is not None else None ) self._deliver_cast( caster, target, spell, pending.spell_name, pending.center_pos, metamagic=pending.metamagic, slot_level=pending.slot_level, ) def _resolve_held_charge(self, caster: CombatantState) -> None: """PF1e (SRD "Cast a Spell - Touch Attacks", Holding the Charge): a missed melee touch attack leaves the charge held - "You can continue to make touch attacks round after round." Re-attempt it at the start of each of the caster's later turns while the held target is alive and within the spell's touch range; a miss re-sets the charge, a hit delivers through the normal gates, and an SR suppression consumes it (the spell discharges into the resistance). Documented simplifications: the re-attempt is engine-driven at turn start instead of costing a standard action; discharge-on-any-touch and unarmed-attack interactions while holding are not modeled; the charge is not exposed in the Observation.""" held = caster.held_charge if held is None: return spell = self._spell_registry.get(held.spell_name) if spell is None: caster.held_charge = None return target = next( (s for s in self._states if s.combatant.id == held.target_id), None ) if target is None or not target.active: caster.held_charge = None self._log( f"round {self._current_round} {caster.combatant.id}: " f"held charge of {held.spell_name} dissipates (target gone)" ) return dist_ft = self.creature_distance(caster, target) * _SQUARE_FT if dist_ft > spell_range_ft(spell, caster.combatant.level): return # out of reach: still holding, re-attempt a later turn caster.held_charge = None # consumed by this attempt; a miss re-sets self._log( f"round {self._current_round} {caster.combatant.id}: " f"re-attempts held charge {held.spell_name}" ) self._deliver_cast( caster, target, spell, held.spell_name, None, metamagic=held.metamagic, slot_level=held.slot_level, ) def _forfeit_pending_cast(self, caster: CombatantState) -> None: """PF1e (SRD "Casting Time"): "you must continue the concentration from the current round to just before your turn in the next round [...] If you lose concentration before the casting is complete, you lose the spell." A caster that reaches its initiative slot unable to act (dead or dying) never gets to resolve the casting.""" pending = caster.pending_cast if pending is None: return caster.pending_cast = None self._log( f"round {self._current_round} {caster.combatant.id}: " f"one-round cast of {pending.spell_name} lost (cannot act)" ) def _take_turn(self, state: CombatantState) -> bool: """Execute one combatant's full turn; return True if the battle is over.""" # A freshly-delayed combatant is re-inserted into the round order and # ``_take_turn`` runs a second time in the SAME round. The start-of-turn # block below (duration decrement, the bleed/confusion/cant_act gate, # the fear aura and the per-turn flag resets) must run only ONCE per # round, so it is skipped when ``delayed_this_round`` is already set at # entry (it was cleared at round start and only a delay action sets it). # The delayed turn still gets its policy call, budget and execution. if not state.delayed_this_round: self._expire_readied_action(state) self._decrement_durations(state) # SRD "Casting Time": the one-round cast "comes into effect just # before the beginning of your turn" — resolved here, before the # start-of-turn gate, then the turn proceeds normally. self._resolve_pending_cast(state) gate = self._turn_gate(state) if gate is not None: # A start-of-turn effect (bleed, confusion, cant_act) forfeited # the turn; the value is the battle-over flag. state.last_action_results = [] return gate # SRD "Holding the Charge": a held touch spell is re-attempted at # the start of a later turn (before the caster acts). self._resolve_held_charge(state) self._apply_fear_aura(state) state.moved_this_turn = False state.power_attack = False state.defensive_ac_bonus = 0 state.fight_defensively = False state.total_defense = False self._expire_aid_grants(state) self._tick_run_endurance(state) 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. self._lose_hp(state, 1) self._log( f"round {self._current_round} {state.combatant.id}: " f"disabled: strenuous action costs 1 hp (hp 0 -> -1, dying)" ) if state.delayed_this_round and not was_delayed: # A fresh delay forfeits the rest of the turn. break if self._winner_side() is not None: break finally: self._turn_actor = None if state.active and len(self._transcript) == before: self._log(f"round {self._current_round} {state.combatant.id}: wait") # Assigned at the END of the turn (never reset at the start): the # Observation is built inside the policy call at the START of the next # turn, so it must still see THIS turn's results at that moment. state.last_action_results = results return self._winner_side() is not None def _cast_time_of(self, action: Action) -> CastTime: """Resolve a cast_spell action's casting time from the registry. Unknown spell names (and non-cast actions) default to "standard". Future hook (out of scope here): for a spontaneous caster this closure would read state.combatant.spontaneous_caster and action.metamagic — a spontaneous cast with a non-empty metamagic feat and no Quickened Spell becomes full_round (unless the spell is already one_round or long). The Callable[[Action], CastTime] resolver signature already accommodates the state-aware variant. """ spell = self._spell_registry.get(action.spell_name or "") return spell.cast_time if spell is not None else "standard" def _filter_turn_actions( self, state: CombatantState, actions: tuple[Action, ...], results: list[ActionResult], ) -> tuple[Action, ...]: """Downgrade the policy's action sequence to the legal action economy. Composition order: a cast-time pre-pass first drops every cast_spell whose spell has a long casting time (more than 1 round — the engine cannot schedule it within a turn; reported as downgraded:cast_time). Then the general per-turn budget (CRB, "Actions in Combat": one standard + one move, or one full-round, plus one swift; a move action may be taken instead of the standard) is enforced on every sequence, with each cast_spell occupying the slot of its resolved casting time. The condition-specific specializations then run on the budget-legal remainder (their output is a single action, so the budget never re-drops it). Swift consumed by an immediate action; PF1e (CRB, Disabled): a creature at exactly 0 hp is staggered — one move OR one standard action per round, no full-round actions (including full-round and one-round casts and spell_combat); nauseated: single move only. Dropped kinds are reported into ``results``. """ schedulable = tuple( a for a in actions if not (a.kind == "cast_spell" and self._cast_time_of(a) == "long") ) results.extend( ActionResult(kind="cast_spell", ok=False, reason=_REASON_DOWNGRADED_CAST_TIME) for _ in range(len(actions) - len(schedulable)) ) actions = schedulable # PF1e (SRD "Move Actions"): "If you have a base attack bonus of +1 or # higher, you may draw a weapon as a free action combined with a # regular move." The draw rides the turn's move action instead of # consuming the move slot. free_draw = state.combatant.bab >= 1 and any( a.kind == "move" for a in actions ) kept = _action_economy_filter( actions, cast_time=self._cast_time_of, free_draw=free_draw ) results.extend( ActionResult( kind=kind, ok=False, reason=_REASON_DOWNGRADED_ACTION_ECONOMY ) for kind in _dropped_kinds_multiset(actions, kept) ) actions = kept if self._surprise_round: # PF1e (SRD "Surprise"): the surprise round allowance replaces the # normal budget — one standard OR move action plus free actions. kept = _surprise_filter( actions, cast_time=self._cast_time_of, free_draw=free_draw ) results.extend( ActionResult(kind=kind, ok=False, reason=_REASON_DOWNGRADED_SURPRISE) for kind in _dropped_kinds(actions, kept) ) return kept if state.immediate_used: kept = _without_swift_bonus(actions, cast_time=self._cast_time_of) results.extend( ActionResult(kind=kind, ok=False, reason=_REASON_DOWNGRADED_IMMEDIATE_SWIFT) for kind in _dropped_kinds(actions, kept) ) actions = kept state.immediate_used = False if self._is_staggered_limited(state): kept = _staggered_filter( actions, cast_time=self._cast_time_of, free_draw=free_draw ) results.extend( ActionResult(kind=kind, ok=False, reason=_REASON_DOWNGRADED_STAGGERED) for kind in _dropped_kinds(actions, kept) ) return kept if self.has_condition_flag(state, "cant_attack"): kept = _nauseated_filter(actions) results.extend( ActionResult(kind=kind, ok=False, reason=_REASON_DOWNGRADED_NAUSEATED) for kind in _dropped_kinds(actions, kept) ) return kept return actions def has_condition_flag(self, state: CombatantState, flag: str) -> bool: """Check if any active condition has the given behavioral flag set to True.""" for ac in state.active_conditions: if getattr(ac.condition, flag, False): return True # Also check old conditions field for backwards compatibility return any(getattr(cond, flag, False) for cond in state.conditions) def _has_condition_named(self, state: CombatantState, name: str) -> bool: """Check if the state has an active or old condition with the given name.""" return ( any(ac.condition.name == name for ac in state.active_conditions) or any(cond.name == name for cond in state.conditions) ) def _is_disabled(self, state: CombatantState) -> bool: """PF1e (CRB, Effects of Death): a living creature at exactly 0 hp is disabled — staggered, half speed, and a strenuous action costs 1 hp.""" return state.hp == 0 def _is_staggered_limited(self, state: CombatantState) -> bool: """True when the creature is limited to one move OR one standard action per round (CRB "Conditions — Staggered"; a disabled creature at 0 hp is staggered; nonlethal damage equal to current hp staggers, SRD "Nonlethal Damage"). Such a creature cannot take full-round actions — except Restricted Withdraw (SRD "Full-Round Actions"), which it may take as a standard action moving up to its speed.""" return ( self._is_disabled(state) or self._has_condition_named(state, "staggered") or state.nonlethal_staggered ) def _is_helpless(self, state: CombatantState) -> bool: """PF1e (CRB): helpless = paralyzed, held, bound, sleeping, unconscious. Stunned is NOT helpless. A helpless creature has effective Dexterity 0 and melee attackers gain +4 (resolve_attack). A creature whose nonlethal damage EXCEEDS its current hp is unconscious, hence helpless (SRD "Nonlethal Damage": "While unconscious, you are helpless").""" return state.nonlethal_unconscious or 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(), attacker: CombatantState | None = None, ) -> 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"), save-descriptor names (fear, cold, ...) that are present in the save's descriptor set, and the attacker-alignment names "attacker_is_evil" / "attacker_is_lawful" / "attacker_is_good" / "attacker_is_chaotic" (SRD protection from evil/ law/good/chaos: "+2 deflection bonus to AC and a +2 resistance bonus on saves against attacks or effects created by evil creatures" - the bonus checks the ALIGNMENT of the attacker/effect source, passed as ``attacker``; without a source in scope the condition is False). """ 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 alignment_holds = _ATTACKER_ALIGNMENT_CONDITIONS.get(condition_name) if alignment_holds is not None: return attacker is not None and alignment_holds(attacker.combatant) return False def _get_speed_mult(self, state: CombatantState) -> float: """Get the minimum speed multiplier from all active conditions. PF1e (CRB, Disabled): a disabled creature (exactly 0 hp) moves at half speed. """ speed_mult = 0.5 if state.hp == 0 else 1.0 for ac in state.active_conditions: if ac.condition.speed_mult != 1.0: speed_mult = min(speed_mult, ac.condition.speed_mult) for cond in state.conditions: if cond.speed_mult != 1.0: speed_mult = min(speed_mult, cond.speed_mult) return speed_mult def _decrement_durations(self, state: CombatantState) -> None: """Decrement duration rounds for buffs and conditions; remove expired ones. A bleeding condition's last round ends with its bleed tick: the DoT lives on ``bleed_damage`` (CRB Conditions — bleeding), so the final damage is applied as the last condition instance expires and the field is cleared (mirrors ``_stop_bleeding`` on healing). """ state.active_buffs[:] = [ ActiveBuff(modifiers=b.modifiers, rounds_left=b.rounds_left - 1, source=b.source) for b in state.active_buffs if b.rounds_left > 1 ] kept_conditions = [ ActiveCondition(condition=c.condition, rounds_left=c.rounds_left - 1, source=c.source) for c in state.active_conditions if c.rounds_left > 1 ] state.one_shot_bonuses[:] = [ ActiveOneShot( track=o.track, value=o.value, bonus_type=o.bonus_type, rounds_left=o.rounds_left - 1, source=o.source, ) for o in state.one_shot_bonuses if o.rounds_left > 1 ] if state.temp_hp_rounds_left > 0: state.temp_hp_rounds_left -= 1 if state.temp_hp_rounds_left == 0: state.temp_hp = 0 bleeding_expired = ( any(ac.condition.name == "bleeding" for ac in state.active_conditions) and not any(ac.condition.name == "bleeding" for ac in kept_conditions) ) state.active_conditions[:] = kept_conditions if bleeding_expired and state.bleed_damage > 0: self._apply_bleed_damage(state) state.bleed_damage = 0 def _turn_gate(self, state: CombatantState) -> bool | None: """Start-of-turn effects: bleed tick, recurring spell damage, confused d% roll, cant_act flag. Returns None when the turn proceeds to the policy; otherwise the turn is forfeited and the value is the battle-over flag. """ self._apply_bleed_damage(state) self._apply_recurring_damage(state) if not state.active: # Bleeding / acid knocked the combatant out (dying or dead); the # winner check covers the death case. return self._winner_side() is not None confused_over = self._apply_confusion(state) if confused_over is not None: return confused_over if self.has_condition_flag(state, "cant_act"): self._log(f"round {self._current_round} {state.combatant.id}: wait (cannot act)") return self._winner_side() is not None return None def _lose_hp(self, state: CombatantState, amount: int) -> None: """Apply damage: temp HP absorbs first (CRB "Temporary Hit Points"), then real hp. Callers keep booking the full amount to stats.""" absorbed = min(state.temp_hp, amount) state.temp_hp -= absorbed state.hp -= amount - absorbed def _apply_nonlethal(self, state: CombatantState, amount: int) -> tuple[int, int]: """Route ``amount`` of nonlethal damage through the state's track (SRD "Nonlethal Damage"); returns ``(lethal_part, tracked_nonlethal)``. The lethal part (max-hp overflow) is already applied to hp and booked to the victim's damage_taken; the nonlethal part is NOT real damage and never touches the hp/damage counters - the attrition stats stay lethal-only. Immune targets (undead/construct) return (0, 0): the damage is discarded. Logs threshold crossings (staggered / unconscious).""" if amount <= 0: return 0, 0 if state.combatant.creature_type in _NONLETHAL_IMMUNE_TYPES: self._log( f"round {self._current_round} {state.combatant.id}: nonlethal " f"{amount} ignored (immune)" ) return 0, 0 was_staggered = state.nonlethal_staggered was_unconscious = state.nonlethal_unconscious lethal_part = state.take_nonlethal(amount) if lethal_part: old_hp = state.hp self._lose_hp(state, lethal_part) self._stats[state.combatant.id].damage_taken += lethal_part self._log( f"round {self._current_round} {state.combatant.id}: nonlethal " f"overflow at max hp - {lethal_part} treated as lethal " f"(hp {old_hp}->{state.hp})" ) else: self._log( f"round {self._current_round} {state.combatant.id}: " f"nonlethal {amount} (nl {state.nonlethal - amount}->{state.nonlethal}, " f"hp {state.hp})" ) if not was_unconscious and state.nonlethal_unconscious: self._log( f"round {self._current_round} {state.combatant.id}: " f"unconscious (nonlethal {state.nonlethal} > hp {state.hp})" ) elif not was_staggered and state.nonlethal_staggered: self._log( f"round {self._current_round} {state.combatant.id}: " f"staggered (nonlethal {state.nonlethal} = hp {state.hp})" ) return lethal_part, amount - lethal_part 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 self._lose_hp(state, state.bleed_damage) self._stats[state.combatant.id].damage_taken += state.bleed_damage self._log( f"round {self._current_round} {state.combatant.id}: " f"bleed {state.bleed_damage} (hp {old_hp}->{state.hp})" ) def _apply_recurring_damage(self, state: CombatantState) -> None: """PF1e recurring spell damage (S2c, Acid Arrow): at the start of the owner's turn each active source rolls its dice, is reduced by energy resistance/immunity (never DR — it is spell damage), deals that damage, then loses one round; sources that reach 0 rounds are cleared.""" if not state.recurring_damage: return remaining: list[RecurringDamage] = [] live = self._stats[state.combatant.id] for entry in state.recurring_damage: raw = entry.formula.roll(self._rng) dmg = self._apply_energy_resistance(raw, entry.types, state) old_hp = state.hp self._lose_hp(state, dmg) live.damage_taken += dmg self._log( f"round {self._current_round} {state.combatant.id}: " f"{entry.source} {dmg} (hp {old_hp}->{state.hp})" ) if entry.rounds_left > 1: remaining.append( RecurringDamage( formula=entry.formula, types=entry.types, rounds_left=entry.rounds_left - 1, source=entry.source, ) ) state.recurring_damage[:] = remaining def _apply_confusion(self, state: CombatantState) -> bool | None: """PF1e confused: roll d% at the start of the turn to act randomly (CRB). Returns None when the subject acts normally (01-25, the turn proceeds); otherwise the turn is consumed and the value is the battle-over flag. Simplification: the "if attacked, attack the attacker" clause is reduced to attacking the nearest creature. """ if not self._has_condition_named(state, "confused"): return None pct = self._rng.d100() if pct <= _CONFUSED_ACT_NORMAL_MAX: return None if pct <= _CONFUSED_BABBLE_MAX: self._log( f"round {self._current_round} {state.combatant.id}: confused: babbles" ) elif pct <= _CONFUSED_SELF_DAMAGE_MAX: self._confused_self_damage(state) else: self._confused_attack_nearest(state) return self._winner_side() is not None def _confused_self_damage(self, state: CombatantState) -> None: """Confused 51-75: 1d8 + Str modifier damage to self with item in hand.""" dmg = self._rng.roll_die(8) + state.combatant.abilities.str_mod old_hp = state.hp self._lose_hp(state, dmg) self._log( f"round {self._current_round} {state.combatant.id}: " f"confused: hits self for {dmg} (hp {old_hp}->{state.hp})" ) def _confused_attack_nearest(self, state: CombatantState) -> None: """Confused 76-100: attack the nearest creature (friend OR foe) once.""" target = self._nearest_creature(state) weapon = None if target is not None: weapon = next( ( w for w in state.usable_attacks() if w.kind == "melee" and self._melee_weapon_usable(state, target, w) ), None, ) if target is None or weapon is None: self._log( f"round {self._current_round} {state.combatant.id}: " f"confused: attacks nearest but nothing in reach" ) return self._log( f"round {self._current_round} {state.combatant.id}: " f"confused: attacks {target.combatant.id}" ) self._single_attack(state, target, weapon) def _nearest_creature(self, state: CombatantState) -> CombatantState | None: """Closest active creature — friend OR foe — by grid distance.""" others = [s for s in self._states if s is not state and s.active] if not others: return None return min( others, key=lambda s: (self.creature_distance(state, s), self._states.index(s)), ) def _stop_bleeding(self, state: CombatantState) -> None: """PF1e: applying a spell that cures hit point damage stops bleeding (CRB). Simplification: the DC 15 Heal check alternative is not modeled (the engine has no Heal skill action). """ state.bleed_damage = 0 state.active_conditions[:] = [ ac for ac in state.active_conditions if ac.condition.name != "bleeding" ] state.conditions[:] = [ cond for cond in state.conditions if cond.name != "bleeding" ] def run(self) -> CombatResult: """Run the battle and return the final result and transcript.""" self._roll_initiative() if self._surprise_round_applies(): self._run_surprise_round() round_no = 1 while round_no <= self._round_cap: self._current_round = round_no self._aoo_used.clear() self._move_aoo_provoked.clear() self._expire_summons() if self._run_round(): break round_no += 1 winner = self._winner_side() if winner is not None: self._log(f"battle over: {winner} win in {round_no} rounds") else: self._log(f"battle over: draw after {self._round_cap} rounds") stats = {cid: CombatantStats(**asdict(live)) for cid, live in self._stats.items()} return CombatResult( winner=winner, rounds=round_no if winner is not None else self._round_cap, transcript=tuple(self._transcript), stats=stats, ) def _surprise_round_applies(self) -> bool: """PF1e (SRD "Surprise"): "If some but not all of the combatants are aware of their opponents, a surprise round happens before regular rounds begin"; if no one or everyone is surprised, none occurs.""" active = [s for s in self._states if s.active] return any(not s.combatant.aware for s in active) and any( s.combatant.aware for s in active ) def _run_surprise_round(self) -> None: """PF1e (SRD "Surprise"): one partial round before round 1. In initiative order (highest to lowest), aware combatants each take a standard OR move action plus free actions (``_surprise_filter``); "Combatants who are unaware at the start of battle don't get to act in the surprise round." Simplification: unaware combatants are not flagged flat-footed during the surprise round — the engine never injects the flat-footed condition itself (see README limitations). """ self._current_round = 0 self._aoo_used.clear() self._move_aoo_provoked.clear() self._log("surprise round") self._surprise_round = True try: for state in self._build_round_order(): if not state.active: continue if not state.combatant.aware: self._log( f"round 0 {state.combatant.id}: unaware — " "no action in surprise round" ) continue if self._take_turn(state): return finally: self._surprise_round = False def _run_round(self) -> bool: """Execute one round; return True if the battle ended. The round order is mutable: a combatant that took a fresh delay action is re-inserted right before its ``delay_before`` target when that target is still ahead, otherwise at the end of the round. """ for state in self._states: state.delayed_this_round = False order = self._build_round_order() i = 0 while i < len(order): state = order[i] if not state.active: self._forfeit_pending_cast(state) self._process_dying(state) i += 1 continue was_delayed = state.delayed_this_round if self._take_turn(state): return True if self._drive_pending_summons(): return True i += 1 if ( state.delayed_this_round and not was_delayed and state not in order[i:] ): self._reinsert_delayed(order, i, state) self._pending_summons.clear() return self._winner_side() is not None def _reinsert_delayed( self, order: list[CombatantState], start: int, state: CombatantState ) -> None: """Put a freshly-delayed combatant back into the round order. Inserted immediately before its ``delay_before`` target if that target still has to act (search from ``start``); appended at the end otherwise. """ insert_at = len(order) if state.delay_before is not None: for j in range(start, len(order)): if order[j].combatant.id == state.delay_before: insert_at = j break order.insert(insert_at, state) def _drive_pending_summons(self) -> bool: """Give summoned creatures their turn immediately; return True if battle ended.""" while self._pending_summons: summoned = self._pending_summons.pop(0) if ( summoned in self._states and summoned.active and self._take_turn(summoned) ): return True return False def _process_dying(self, state: CombatantState) -> None: """Advance the dying condition on the creature's own initiative slot. PF1e dying: a combatant below 0 hp but not dead is unconscious and, on its turn each round, makes a DC 10 Constitution check (d20 + Con modifier - negative hp total); a natural 20 auto-succeeds. Failure bleeds 1 more hp; success stabilizes (no further loss while stable). New damage below the stabilizing hp resumes dying; healing to hp >= 0 resets stabilization for any future downing. A creature taking continuous damage (bleed) instead ticks the bleed and AUTOMATICALLY FAILS the check (CRB, Stable Characters and Recovery). """ if state.dead: return if state.hp >= 0: state.stabilized_hp = None return if state.stabilized_hp is not None: if state.hp >= state.stabilized_hp: return state.stabilized_hp = None # PF1e (CRB, Stable Characters and Recovery): "Characters taking # continuous damage, such as from an acid arrow or a bleed effect, # automatically fail all Constitution checks made to stabilize. Such # characters lose 1 hit point per round in addition to the continuous # damage." This is the inactive creature's once-per-round slot (the # active path already ticked bleed in _turn_gate), so the bleed damage # is applied here and the d20 check is skipped (auto-fail). if state.bleed_damage > 0: self._apply_bleed_damage(state) hp_before = state.hp state.hp -= 1 self._log( f"round {self._current_round} {state.combatant.id}: dying — continuous " f"damage auto-fails the Constitution check, loses 1 hp " f"(hp {hp_before} -> {state.hp})" ) return con_mod = (state.combatant.abilities.con_score - 10) // 2 penalty = -state.hp roll = self._rng.d20() total = roll + con_mod - penalty check = f"d20({roll}) + CON({con_mod}) - {penalty} = {total} vs DC {_DYING_DC}" if roll == _NATURAL_TWENTY or total >= _DYING_DC: state.stabilized_hp = state.hp nat20 = " — natural 20" if roll == _NATURAL_TWENTY else "" self._log( f"round {self._current_round} {state.combatant.id}: " f"stabilized ({check}{nat20})" ) else: hp_before = state.hp state.hp -= 1 self._log( f"round {self._current_round} {state.combatant.id}: dying — Constitution " f"check {check} failed, loses 1 hp (hp {hp_before} -> {state.hp})" ) def _blocked(self, *exclude: CombatantState) -> frozenset[Pos]: """Occupied cells that block the mover (the first excluded state). Union of the active creatures' cells except those in ``exclude``. PF1e co-occupancy (SRD "Big and Little Creatures in Combat") lets a Tiny-or-smaller creature share a square with a strictly larger one, so a co-occupiable creature's cells do NOT block the mover (it may move through and end on them); two same-size creatures (or two Small-or- larger) never share, so their cells still block. """ mover = exclude[0] if exclude else None blocked: set[Pos] = set() for s in self._states: if not s.active or any(s is e for e in exclude): continue if mover is not None and _can_cooccupy( mover.combatant.size, s.combatant.size ): continue blocked |= set(s.occupied_cells()) return frozenset(blocked) def _blocked_hostile(self, mover: CombatantState) -> frozenset[Pos]: """Hostile-occupied cells that block the mover's path. A hostile creature's square blocks movement (SRD "Movement" / "Moving Through a Square"), except when the mover may co-occupy it (``_can_cooccupy``, SRD "Big Creatures and Smaller Creatures") or may pass through it (``_can_pass_through``, SRD "Moving Through a Square" : "You can move through a square occupied by a creature three size categories larger than you"). The distinction between ``_blocked_hostile`` (through-gate) and ``_blocked`` (end-gate) ensures pass-through candidates are walkable but not valid endpoints. """ return frozenset( cell for s in self._states if s.active and s is not mover and s.side != mover.side and not _can_cooccupy(mover.combatant.size, s.combatant.size) and not _can_pass_through(mover.combatant.size, s.combatant.size) for cell in s.occupied_cells() ) def creature_distance(self, a: CombatantState, b: CombatantState) -> int: """Minimum grid distance between any occupied cell of ``a`` and ``b``.""" cells_b = b.occupied_cells() return min( self._grid.distance(cell_a, cell_b) for cell_a in a.occupied_cells() for cell_b in cells_b ) def _elevation(self, state: CombatantState) -> int: """Height of the creature's anchor cell (0 when no elevation layer).""" return self._grid.elevation_at(state.pos) def _cells_free(self, cells: Iterable[Pos], mover: CombatantState) -> bool: """True if every cell is in bounds, passable, and unoccupied by others.""" blocked = self._blocked(mover) return all( self._grid.passable(cell) and cell not in blocked for cell in cells ) def _cells_walkable(self, cells: Iterable[Pos], mover: CombatantState) -> bool: """True if a move/run path may enter these cells (allies allowed). Inbounds, passable, and not occupied by a hostile creature; a friendly creature's square may be crossed through (SRD Movement), though the movement may not END there (``_cells_free`` gates that). """ hostile = self._blocked_hostile(mover) return all( self._grid.passable(cell) and cell not in hostile for cell in cells ) def _footprint_at(self, anchor: Pos, state: CombatantState) -> frozenset[Pos]: """The cells ``state`` would occupy if its anchor were at ``anchor``.""" n = SIZE_CELLS.get(state.combatant.size, 1) r, c = anchor return frozenset( (r + dr, c + dc) for dr in range(n) for dc in range(n) ) def _footprint_cost( self, footprint: frozenset[Pos], cost_field: dict[Pos, int] ) -> int | None: """Minimum cost over footprint cells present in the cost field.""" costs = [cost_field[cell] for cell in footprint if cell in cost_field] return min(costs) if costs else None def nearest_enemy(self, state: CombatantState) -> CombatantState | None: """Closest active enemy by grid distance; ties keep list order.""" enemies = [s for s in self._states if s.side != state.side and s.active] if not enemies: return None return min( enemies, key=lambda s: (self.creature_distance(state, s), self._states.index(s)), ) def weapon_for(self, attacker: CombatantState, target: CombatantState) -> AttackSpec | None: """First weapon usable against the target at this range and with clear LoE.""" for weapon in attacker.usable_attacks(): if self._weapon_usable_against(attacker, target, weapon): return weapon return None def _weapon_usable_against( self, attacker: CombatantState, target: CombatantState, weapon: AttackSpec ) -> bool: """True if ``weapon`` can strike ``target`` at this range with clear LoE. The per-weapon half of ``weapon_for``'s auto-selection, exposed for the explicit weapon choice of ``attack`` / ``full_attack`` (U5d): melee/touch reach (with the reach dead zone) or ranged/touch range within the max increments, behind a line-of-effect gate. """ if not has_line_of_effect_cells( self._grid, attacker.occupied_cells(), target.occupied_cells() ): return False dist_ft = self.creature_distance(attacker, target) * _SQUARE_FT if weapon.kind in ("melee", "touch"): inner_cells, outer_cells = _melee_reach_zone_cells(attacker, weapon) if inner_cells * _SQUARE_FT <= dist_ft <= outer_cells * _SQUARE_FT: return True max_increments = ( _MAX_TOUCH_RANGE_INCREMENTS if weapon.kind == "touch" else _MAX_RANGE_INCREMENTS ) return ( weapon.kind in ("ranged", "touch") and weapon.range_increment_ft is not None and dist_ft <= weapon.range_increment_ft * max_increments ) def _is_ranged_attack(self, weapon: AttackSpec) -> bool: """Ranged-style attack: projectile, or touch thrown/fired from a distance. Melee touch attacks (no range increment) are not ranged-style. """ return weapon.kind == "ranged" or ( weapon.kind == "touch" and weapon.range_increment_ft is not None ) def _is_melee_attack(self, weapon: AttackSpec) -> bool: """PF1e: a melee touch attack (kind "touch" with no range increment, e.g. the lich's Paralyzing Touch) is a melee attack — it gains the melee benefits (flanking, high ground, vs-helpless +4, sneak attack). """ return weapon.kind == "melee" or ( weapon.kind == "touch" and weapon.range_increment_ft is None ) def range_penalty(self, weapon: AttackSpec, dist_ft: int) -> int: """PF1e: -2 per full range increment beyond the first, zero within it.""" if weapon.kind not in ("ranged", "touch") or weapon.range_increment_ft is None: return 0 increments = dist_ft // weapon.range_increment_ft if dist_ft % weapon.range_increment_ft != 0: increments += 1 return -_RANGE_INCREMENT_PENALTY * max(0, increments - 1) def _flanking_bonus(self, attacker: CombatantState, target: CombatantState) -> int: """+2 if an active ally threatens the target from the opposite side (CRB flanking). The opposite side is the mirror of every attacker cell across every target cell; an ally occupying any mirrored cell flanks. PF1e (CRB): the ally must actually THREATEN the target — a usable melee weapon whose reach covers a target square (a reach weapon's 5-ft dead zone does not), and no condition that stops it from acting (stunned, paralyzed, ...). """ opposite = { (2 * tr - ar, 2 * tc - ac) for ar, ac in attacker.occupied_cells() for tr, tc in target.occupied_cells() } for ally in self._states: if ally is attacker or ally is target: continue if not ally.active or ally.side != attacker.side: continue if self.has_condition_flag(ally, "cant_act"): continue if not (ally.occupied_cells() & opposite): continue if self.threatened_squares(ally) & target.occupied_cells(): return _FLANK_BONUS return 0 def _stat_modifiers( self, state: CombatantState, target: str, *, weapon_name: str | None = None, weapon: AttackSpec | None = None, dist_ft: int | None = None, save_descriptors: frozenset[str] = frozenset(), attacker: CombatantState | None = None, ) -> list[StatModifier]: """Collect all StatModifiers for ``target`` from all sources. ``weapon_name`` filters out modifiers with a ``weapon_filter`` that doesn't match (e.g. Weapon Focus (Pistol) only applies to weapons whose name contains "Pistol", case-insensitively — see ``_weapon_filter_matches``). 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). ``attacker`` is the source of the attack/effect being resolved (the alignment context for the "attacker_is_*" conditions); None when no source is in scope. 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 _weapon_filter_matches(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, attacker=attacker, ) ] 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 _discharge_one_shot( self, state: CombatantState, track: OneShotTrack ) -> ActiveOneShot | None: """Remove and return the best pending one-shot for ``track``. PF1e (SRD spells/guidance.md): the bonus applies to a SINGLE roll and "must [be] chosen ... before making the roll" - the engine has no declaration channel, so the first roll of the encoded track spends it. The caller folds the returned bonus into its resolve_modifiers pool so the bonus-type stacking rules apply; other pending charges of the same track survive (each casting is its own charge). """ best: ActiveOneShot | None = None for entry in state.one_shot_bonuses: if entry.track == track and (best is None or entry.value > best.value): best = entry if best is None: return None state.one_shot_bonuses.remove(best) return best def _one_shot_modifier( self, state: CombatantState, track: OneShotTrack, target: StatTarget ) -> StatModifier | None: """Discharge this track's one-shot as a modifier on ``target``, if any.""" shot = self._discharge_one_shot(state, track) if shot is None: return None return StatModifier(target=target, value=shot.value, bonus_type=shot.bonus_type) def _attack_roll_modifiers( self, attacker: CombatantState, atk_mods: list[StatModifier] ) -> tuple[int, int]: """Return (pool_mod, attack_roll_mod) for an attack roll. ``pool_mod`` is the resolve_modifiers total of the standing pool (it also rides the crit-confirm roll); ``attack_roll_mod`` additionally folds in a discharged one-shot (SRD spells/guidance.md). The one-shot rides the attack roll only, not the confirmation (the SRD grants it to a SINGLE attack roll, so unlike aid another the confirm is untouched); it goes through resolve_modifiers so its bonus type stacks normally. """ pool_mod = resolve_modifiers(atk_mods) one_shot = self._one_shot_modifier(attacker, "attack", "attack") if one_shot is None: return pool_mod, pool_mod return pool_mod, resolve_modifiers([*atk_mods, one_shot]) 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" # KB bestiary/lich.md: "Aura fear (60-ft. radius, DC 18)". _FEAR_AURA_DC = 18 _FEAR_AURA_RANGE_FT = 60 _FEAR_AURA_ROUNDS = 1 def _apply_fear_aura(self, state: CombatantState) -> None: """Apply the Lich's Fear Aura: enemies within 60 ft save Will (DC 18) 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"}), attacker=state, ) 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). PF1e (SRD "Total Defense"): "You can't make attacks of opportunity while using total defense" — the allowance drops to 0 while the total-defense bonus is active (until the start of the next turn). """ if state.total_defense: return 0 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, *, attacker: CombatantState | None = None, ) -> list[StatModifier]: """AC modifiers for one attack — the weapon-kind-dependent ones included. ``attacker`` is the source of the attack, threaded to ``_stat_modifiers`` so alignment-gated AC modifiers (SRD protection from evil/law/good/chaos: "+2 deflection bonus to AC ... against attacks made ... by evil creatures") can evaluate their condition. 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). The flat-footed base already excludes ALL Dex (bonus and penalty alike), so the mod is exactly -5 there; on the touch base the stored Dex modifier is stripped at the base (see resolve_attack) before this -5 is added.""" mods = self._stat_modifiers(target, "ac", attacker=attacker) 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): mods.append(StatModifier(target="ac", value=_DEX_ZERO_MOD)) 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 # PF1e (CRB "Conditions - Broken"): "If the item is a weapon, any # attacks made with the item suffer a -2 penalty on attack and # damage rolls. Such weapons only score a critical hit on a natural # 20 and only deal x2 damage on a confirmed critical hit." broken = weapon.id in attacker.weapon_broken 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, attack_roll_mod = self._attack_roll_modifiers(attacker, 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_roll_mod - pa_amt + fim_penalty + fd_penalty + attacker.aid_attack_bonus + (_BROKEN_WEAPON_PENALTY if broken else 0) ) # Check if target denies DEX to AC (flat-footed); a running target # loses its DEX bonus too (U5e), reusing the same AC path — unless it # has the Run feat (SRD Run: "You lose any Dexterity bonus to AC # unless you have the Run feat"). An invisible attacker also denies # the target's DEX bonus to AC. denies_dex = ( self.has_condition_flag(target, "denies_dex_to_ac") or self._running_denies_dex(target) or self._has_condition_named(attacker, "invisible") ) ac = self._attack_ac(attacker, target, weapon, dist_ft, denies_dex=denies_dex) hit = roll == _NATURAL_TWENTY or (roll != _NATURAL_ONE and total >= ac) # PF1e (Ultimate Combat, Firearm Rules — Misfires): "If the natural # result of your attack roll falls within a firearm's misfire value, # that shot misses [...] When a firearm misfires, it gains the broken # condition." The model uses the minimum misfire value (1): a natural # 1 with a firearm (``firearm: true``) breaks the weapon — it joins # ``weapon_broken`` and is unusable until Quick Clear or a repair. # Bounded to firearms; the exploding-early-firearm and increased # misfire-value rules are not modeled (a broken gun cannot be fired # again here, so it cannot misfire twice). if weapon.firearm and roll == _NATURAL_ONE: attacker.weapon_broken.add(weapon.id) self._log( f"round {self._current_round} {attacker.combatant.id}: " f"{weapon.name} MISFIRE (broken)" ) damage = 0 nonlethal = 0 concealment_miss = False crit = False if hit: crit, concealment_miss, damage, nonlethal = 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, broken=broken, ) if not concealment_miss: self._lose_hp(target, damage) damage = self._consume_up_close_and_deadly( attacker, target, weapon, dist_ft, damage, hit=hit ) if nonlethal: # SRD "Nonlethal Damage": the nonlethal part goes to the # separate track; only the max-hp overflow (if any) comes # back as lethal damage, folded into the reported total so # the callers' hp accounting stays exact. lethal_part, tracked = self._apply_nonlethal(target, nonlethal) damage += lethal_part nonlethal = tracked splash_total = 0 if weapon.splash is not None: # SRD "Throw Splash Weapon": a direct hit splashes every creature # within 5 feet of the target's square (the target itself took # the direct-hit damage, not an extra splash); any miss - attack # roll or concealment - scatters the flask and deals ONLY the # splash at where it lands. if hit and not concealment_miss: splash_total = self._splash_direct_hit( attacker, target, weapon.splash ) else: splash_total = self._splash_scatter(attacker, target, weapon) 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, nonlethal=nonlethal, splash=splash_total, ) def _attack_ac( self, attacker: CombatantState, target: CombatantState, weapon: AttackSpec, dist_ft: int, *, denies_dex: bool, ) -> int: """Effective AC of ``target`` against this attack (modifiers + cover).""" 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: # A flat-footed defender loses only a POSITIVE Dex bonus (a # Dex penalty stays on touch AC); a HELPLESS defender has an # effective Dex score of 0, so its whole stored Dex modifier # is stripped here and the exact -5 Dex-0 penalty is added by # _attack_ac_mods. dex_mod = target.combatant.abilities.dex_mod strip = dex_mod if self._is_helpless(target) else max(0, dex_mod) base_ac = target.combatant.ac.touch - strip else: base_ac = target.combatant.ac.touch else: base_ac = full_ac_base ac_mods = self._attack_ac_mods(target, weapon, attacker=attacker) ac = base_ac + resolve_modifiers(ac_mods) ac += target.defensive_ac_bonus ac += target.aid_ac_bonus ac += self._attack_cover_bonus( attacker, target, ranged=self._is_ranged_attack(weapon) ) return ac 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, broken: bool = False, ) -> tuple[bool, bool, int, int]: """Resolve a successful attack roll: crit confirm, concealment miss chance, sneak attack dice, then damage (PF1e order). Returns ``(crit, concealment_miss, lethal, nonlethal)``. 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. A melee attack named "Unarmed Strike" (_deals_nonlethal_by_default) routes its whole final damage to the nonlethal track, and the CRB "Damage" Minimum Damage floor of a lethal hit reduced below 1 lands as 1 nonlethal point (SRD "Nonlethal Damage"). """ crit = False # CRB "Broken": a broken weapon "only score[s] a critical hit on a # natural 20" — the threat range collapses to 20 and Improved # Critical cannot widen it (a broken weapon is not being wielded to # its full effect). threat_range = _NATURAL_TWENTY if broken else weapon.crit_range if ( not broken and 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) + (_BROKEN_WEAPON_PENALTY if broken 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, 0 sneak_total = 0 sneak_dice = self._sneak_attack_dice( attacker, target, weapon, dist_ft=dist_ft, denies_dex=denies_dex, concealment=concealment, ) if sneak_dice > 0: sneak_total = sum(self._rng.roll_die(6) for _ in range(sneak_dice)) self._log( f"round {self._current_round} {attacker.combatant.id}: " f"sneak attack vs {target.combatant.id}: " f"{sneak_dice}d6 = {sneak_total} precision damage" ) dmg_mods = self._stat_modifiers( attacker, "damage", weapon_name=weapon.name, weapon=weapon, dist_ft=dist_ft ) rolls = [c.formula.roll(self._rng) for c in weapon.damage] # PF1e (CRB, Critical Hits): "Precision damage ... and additional # damage dice from weapon special abilities (such as flaming) are not # multiplied when you score a critical hit." Only the base physical # dice ride the multiplier with the flat bonuses; energy-typed # component dice (flaming/frost/shock/...) are added at 1x. physical_dice = sum( roll for comp, roll in zip(weapon.damage, rolls, strict=True) if _PHYSICAL_DAMAGE_TYPES.issuperset(comp.types) ) energy_dice = sum(rolls) - physical_dice damage = physical_dice + weapon.damage_bonus damage += resolve_modifiers(dmg_mods) # CRB "Broken": the -2 rides the damage roll too (applied before the # multiplier, like every other flat damage modifier). if broken: damage += _BROKEN_WEAPON_PENALTY # 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: # CRB "Broken": a broken weapon "only deal[s] x2 damage on a # confirmed critical hit" even if its multiplier is higher. damage *= _BROKEN_CRIT_MULT if broken else weapon.crit_mult damage += sneak_total + energy_dice # PF1e (CRB Damage Reduction): "Damage reduction does not negate ... # energy damage dealt along with an attack" — DR reduces only the # non-energy share of the damage (physical dice, flat bonuses and # precision dice); energy components are cut solely by energy # resistance/immunity in the per-component step below. The energy # share is its 1x dice: special-ability dice never ride the crit # multiplier (CRB Critical Hits exception). energy_share = sum( roll for comp, roll in zip(weapon.damage, rolls, strict=True) if not _PHYSICAL_DAMAGE_TYPES.issuperset(comp.types) ) physical = max(0, damage - energy_share) damage = self._apply_dr(physical, weapon, target) + energy_share final = self._apply_energy_resistance_per_component( damage, weapon, rolls, target=target ) # PF1e (CRB "Damage" - Minimum Damage) and SRD "Unarmed Attacks": # the lethal/nonlethal split of the landed hit (floor included). return crit, False, *_split_landed_damage(weapon, final) def _splash_direct_hit( self, attacker: CombatantState, target: CombatantState, splash: SplashSpec, ) -> int: """Splash a direct hit: every creature within 5 feet of the point of impact (the target's square) takes the splash - the target itself took the direct-hit damage, not an extra splash (SRD "Throw Splash Weapon": "A hit deals direct hit damage to the target and splash damage to all creatures within 5 feet of the target"). """ return self._apply_splash(attacker, splash, target.pos, exclude=target) def _splash_scatter( self, attacker: CombatantState, target: CombatantState, weapon: AttackSpec ) -> int: """Splash a miss (SRD "Throw Splash Weapon"): roll 1d8 for the misdirection - 1 falls short in a straight line toward the thrower, 2-8 rotate clockwise around the target - then count squares equal to the range increment of the throw. The flask deals ONLY its splash (no direct hit) to every creature in the landing square and all adjacent squares - the original target included, it is a creature like any other in the blast. The d8 is rolled solely for splash weapons, so every other attack's RNG stream is untouched. """ splash = weapon.splash if splash is None: # unreachable: scatter only runs for splash weapons return 0 roll = self._rng.roll_die(_SPLASH_D8_SIDES) vec = _SPLASH_COMPASS_CW[ (self._splash_toward_thrower(attacker, target) + roll - 1) % _SPLASH_D8_SIDES ] increment = weapon.range_increment_ft steps = max(1, (increment if increment is not None else _SQUARE_FT) // _SQUARE_FT) landing = (target.pos[0] + vec[0] * steps, target.pos[1] + vec[1] * steps) self._log( f"round {self._current_round} {attacker.combatant.id}: " f"splash scatter d8={roll} -> cell {landing[0]},{landing[1]}" ) return self._apply_splash(attacker, splash, landing, exclude=None) @staticmethod def _splash_toward_thrower( attacker: CombatantState, target: CombatantState ) -> int: """Index into ``_SPLASH_COMPASS_CW`` of the unit direction from the target's square toward the thrower (the d8=1 "falls short" line). A same-square thrower (0,0) matches no compass vector and falls back to index 0 (north) - deterministic, no extra die. """ dr = (attacker.pos[0] > target.pos[0]) - (attacker.pos[0] < target.pos[0]) dc = (attacker.pos[1] > target.pos[1]) - (attacker.pos[1] < target.pos[1]) try: return _SPLASH_COMPASS_CW.index((dr, dc)) except ValueError: return 0 def _apply_splash( self, attacker: CombatantState, splash: SplashSpec, impact: Pos, *, exclude: CombatantState | None, ) -> int: """Deal the splash's flat energy damage to every creature in or adjacent to the impact square (SRD "Throw Splash Weapon": "no attack roll, no save" - the splash is automatic). It is energy damage: the component-based energy resistance/immunity path applies and DR does not; the flat amount never rides the crit multiplier (it is outside the weapon-damage path entirely). Dying/unconscious creatures are caught like any area effect; corpses are not. Returns the total splash damage dealt (booked to the attacker's damage_dealt and each victim's damage_taken). """ total = 0 attacker_live = self._stats[attacker.combatant.id] damage_types = frozenset({splash.type}) for state in self._creatures_in_range(impact, 1, active_only=False): if state is exclude: continue reduction = self._energy_reduction(splash.damage, damage_types, state) damage = max(0, splash.damage - reduction) old_hp = state.hp self._lose_hp(state, damage) self._stats[state.combatant.id].damage_taken += damage attacker_live.damage_dealt += damage total += damage self._log( f"round {self._current_round} {attacker.combatant.id}: " f"splash {damage} {splash.type} -> {state.combatant.id} " f"(hp {old_hp}->{state.hp})" ) return total def _attack_cover_bonus( self, attacker: CombatantState, target: CombatantState, *, ranged: bool ) -> int: """Cover the target has against one attack from ``attacker`` (terrain + soft cover from interposed creatures on ranged lines only). A dying/unconscious creature still grants soft cover (SRD cover.md counts "a square occupied by a creature", not an active one — see ``CombatantState.occupies_space``); a corpse does not. """ occupied = frozenset( cell for s in self._states if s.occupies_space and s is not attacker and s is not target for cell in s.occupied_cells() ) return cover_bonus_cells( self._grid, attacker.occupied_cells(), target.occupied_cells(), ranged=ranged, occupied=occupied, ) def _concealment_chance(self, target: CombatantState) -> int: """Highest miss chance (%) among the target's active concealment conditions.""" chances = [ac.condition.miss_chance for ac in target.active_conditions] chances += [cond.miss_chance for cond in target.conditions] return max((c for c in chances if c > 0), default=0) def _sneak_attack_dice( self, attacker: CombatantState, target: CombatantState, weapon: AttackSpec, *, dist_ft: int, denies_dex: bool, concealment: int, ) -> int: """Sneak attack d6 count for this swing (0 when the sneak does not apply). PF1e: precision damage when the target is flanked (melee only, melee touch included) or denied its Dex bonus to AC (melee, or ranged within 30 ft); dice = (level + 1) // 2. ANY concealment (miss chance > 0) denies the sneak attack (SRD rogue "Sneak Attack": "A rogue cannot sneak attack while striking a creature with concealment" — the rogue must see the target well enough to pick a vital spot). A BLINDED attacker faces total concealment against ALL its opponents (CRB "Conditions — Blinded": "All opponents are considered to have total concealment (50% miss chance) against the blinded character"), so it cannot sneak attack either — same max(target, 50) convention as _resolve_hit_damage. PF1e (CRB): undead and constructs are immune to precision damage — ooze/plant immunity is not modeled (no such creature types exist in the data model). """ effective_concealment = concealment if weapon.splash is not None: # SRD "Throw Splash Weapon": "Splash weapons cannot deal # precision-based damage (such as sneak attack)." return 0 if self._has_condition_named(attacker, "blinded"): effective_concealment = max(concealment, _TOTAL_CONCEALMENT_PCT) if ( effective_concealment > 0 or not self._has_feat(attacker, _SNEAK_ATTACK_FEATURE) ): return 0 if target.combatant.creature_type.lower() in _PRECISION_IMMUNE_TYPES: return 0 dice = (attacker.combatant.level + 1) // 2 is_melee = self._is_melee_attack(weapon) if is_melee and self._flanking_bonus(attacker, target) > 0: return dice if denies_dex and ( is_melee or (self._is_ranged_attack(weapon) and dist_ft <= _POINT_BLANK_RANGE_FT) ): return dice return 0 def _firing_into_melee_penalty( self, attacker: CombatantState, target: CombatantState ) -> int: """PF1e firing into melee: -4 on a ranged attack whose target is engaged in melee, avoided entirely (0) when the target stands 10+ ft from every friendly character of the attacker. Precise Shot removes it. PF1e (SRD "Attack" — Shooting or Throwing into a Melee): "If your target (or the part of your target you're aiming at, if it's a big target) is at least 10 feet away from the nearest friendly character, you can avoid the -4 penalty" — the exemption is measured from the TARGET to the attacker's nearest friendly character (a creature on the attacker's side other than the attacker), NOT from the attacker's own position; an archer 30 ft away still takes the -4 when the target is locked in melee with one of the archer's allies at 5 ft. The 10+ ft clause is therefore 0, not -2. The -2 applies only when the target is two size categories larger than the friendly characters it is engaged with (and 0 at three or more): size-category reduction is NOT modeled (documented simplification). The target is engaged when an active enemy of the target (not the attacker itself) threatens the target's occupied cells with a melee weapon. """ if self._has_feat(attacker, _PRECISE_SHOT_FEATURE): return 0 threats = [ s for s in self._states if s is not attacker and s.active and s.side != target.side and self.threatened_squares(s) & target.occupied_cells() ] if not threats: return 0 allies = [ s for s in self._states if s is not attacker and s.active and s.side == attacker.side ] if all( self.creature_distance(target, ally) * _SQUARE_FT >= _FIRING_MELEE_SAFE_FT for ally in allies ): return 0 return _FIRING_MELEE_PENALTY def _engaged_in_melee(self, state: CombatantState) -> bool: """True when an active enemy with a melee weapon threatens this combatant's occupied cells (same threat machinery as AoO/flanking).""" occupied = state.occupied_cells() return any( self.threatened_squares(s) & occupied for s in self._states if s is not state and s.active and s.side != state.side ) def _consume_up_close_and_deadly( self, attacker: CombatantState, target: CombatantState, weapon: AttackSpec, dist_ft: int, damage: int, *, hit: bool, ) -> int: """Consume the Up Close and Deadly deed on the first attack resolved. The deed applies +1d6 to a FIREARM attack that hits a target within 30 ft (Ultimate Combat "Deeds - Up Close and Deadly": "makes a successful attack with a firearm against a target within 30 feet"); the flag is reset after the first attack in all cases (the benefit is lost if the attack misses, is not a firearm, is out of range or is not ranged at all). """ if not attacker.deed_up_close_and_deadly: return damage if ( hit and weapon.firearm and self._is_ranged_attack(weapon) and dist_ft <= _UP_CLOSE_RANGE_FT ): bonus = self._rng.roll_die(6) damage += bonus self._lose_hp(target, bonus) attacker.deed_up_close_and_deadly = False return damage def _dr_bypassed(self, dr: DamageReduction, types: frozenset[str]) -> bool: """PF1e (CRB): "DR X/Y and Z" requires EVERY listed component to bypass. A single-component bypass behaves as before (that type defeats the DR); a multi-component bypass is an "and" list — all components must be present on the attack. Documented gap: the weapon model exposes no "magic" property, so a bypass list containing "magic" (the lich's DR 15/bludgeoning and magic) can never be satisfied by a mundane weapon — correctly, a non-magical weapon must NOT bypass it. """ if not dr.bypass: return False return dr.bypass <= types def _apply_dr(self, damage: int, weapon: AttackSpec, target: CombatantState) -> int: dr: DamageReduction | None = target.combatant.dr if dr is None: return damage types = frozenset(t for comp in weapon.damage for t in comp.types) if self._dr_bypassed(dr, types): return damage return max(0, damage - dr.amount) def _apply_dr_for_types( self, damage: int, damage_types: frozenset[str], target: CombatantState ) -> int: dr: DamageReduction | None = target.combatant.dr if dr is None: return damage if self._dr_bypassed(dr, damage_types): return damage return max(0, damage - dr.amount) def _check_spell_resistance( self, caster: CombatantState, target: CombatantState ) -> bool: """SR check: d20 + caster level vs target spell_resistance. Returns True if the spell affects the target (SR passed or no SR). Returns False if the target resists the spell (all effects skipped). """ sr = target.combatant.spell_resistance if sr == 0: return True caster_level = caster.combatant.level roll = self._rng.d20() total = roll + caster_level if total >= sr: return True self._log( f"round {self._current_round} {caster.combatant.id}: " f"spell resistance {target.combatant.id}: " f"d20({roll}) + CL({caster_level}) = {total} vs SR {sr} " f"— resisted" ) return False def _apply_energy_resistance_per_component( self, damage: int, weapon: AttackSpec, rolls: list[int], *, target: CombatantState, ) -> int: """Energy resistance / immunity applied PER DAMAGE COMPONENT (B21). PF1e (CRB "Energy Resistance"/"Immunity to Energy Damage"): each damage type's resistance reduces only the dice of that type — a weapon dealing 1d6 slashing + 1d6 fire against fire resistance 5 loses only its fire portion; the slashing dice, the flat bonuses (damage_bonus, ability/damage modifiers, Power Attack) and the untyped sneak precision dice are never eaten by energy resistance. Each component's share of the final damage is its own roll at 1x: energy (weapon special ability) dice are never multiplied on a crit (CRB Critical Hits exception), so no crit factor rides this step. A component carrying several energy types uses the largest matching resistance (documented simplification: one die roll cannot be split across the listed types). Physical-only components match nothing in the target's resistance/immunity maps — they rode the DR path just before this step, which reduces only the non-energy share of the damage (CRB: DR does not negate energy damage dealt along with an attack); single-component weapons keep the exact arithmetic of the old lumped-total path. """ for comp, roll in zip(weapon.damage, rolls, strict=True): damage -= self._energy_reduction(roll, frozenset(comp.types), target) return max(0, damage) def _energy_reduction( self, damage: int, damage_types: frozenset[str], target: CombatantState ) -> int: """Reduction the target's immunity/resistance applies to one damage portion of the given types, capped at the portion itself.""" immunities = target.combatant.immunities if immunities and damage_types & frozenset(immunities): return damage resistances = target.combatant.resistances if not resistances: return 0 max_resist = max( (resistances[t] for t in damage_types if t in resistances), default=0, ) return min(damage, max_resist) def _apply_energy_resistance( self, damage: int, damage_types: frozenset[str], target: CombatantState, ) -> int: """Energy resistance / immunity applied to one whole damage portion. Used by the spell-damage path: a ``DamageEffect`` is a single component (one formula, one type list), so the per-component rule (see ``_apply_energy_resistance_per_component``) reduces to exactly this. Immunity: if any damage type matches → 0. Resistance: subtract the max resistance among matching energy types, floored at 0. Physical types (slashing/piercing/bludgeoning/physical) are ignored — they stay on the DR path. """ return max(0, damage - self._energy_reduction(damage, damage_types, target)) def resolve_save( self, state: CombatantState, save_type: Literal["fort", "ref", "will"], dc: int, *, save_descriptors: frozenset[str] = frozenset(), flat_bonus: int = 0, attacker: CombatantState | None = None, ) -> 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. ``flat_bonus`` is an untyped situational bonus added to the total (used for the cover bonus on Reflex saves, CRB "Cover and Reflex Saves"); natural 1/20 still override the roll regardless of it. ``attacker`` is the creature that created the effect being saved against (the alignment context for the "attacker_is_*" conditions, SRD protection from evil: "+2 resistance bonus on saves ... against ... effects created by evil creatures"); None when no source is in scope. """ roll = self._rng.d20() base = getattr(state.combatant.saves, save_type) save_mods = self._stat_modifiers( state, save_type, save_descriptors=save_descriptors, attacker=attacker ) # PF1e one-shot save bonus (SRD spells/guidance.md, save track): the # first save of the bearer discharges it (stacked by bonus type). one_shot = self._one_shot_modifier(state, "save", save_type) if one_shot is not None: save_mods.append(one_shot) bonus = resolve_modifiers(save_mods) total = roll + base + bonus + flat_bonus success = roll != _NATURAL_ONE and (roll == _NATURAL_TWENTY or total >= dc) return SaveResult(success=success, roll=roll, total=total, dc=dc) def skill_check( self, state: CombatantState, skill: str, *, extra_mods: tuple[StatModifier, ...] = (), ) -> SkillCheckResult: """Roll one d20 skill check: d20 + ranks + ability mod + class-skill +3 (trained class skills only) + armor check penalty + every typed bonus/penalty the effect system carries for the skill (trait, feat, condition, circumstance - all through ``_skill_static_bonus``). PF1e (CRB "Skill Checks", KB rules/prpg-core-rulebook/skill-checks.md): "you roll 1d20 and then add your ranks and the appropriate ability score modifier"; "If the skill you're using is a class skill (and you have invested ranks into that skill), you gain a +3 bonus"; "Armor check penalty applies to all Strength- and Dexterity-based skill checks". A skill check has NO natural 1/20 auto-fail/auto-success - the SRD grants those overrides to attack rolls, saves and combat maneuver checks only; success is purely "result equal to or greater than the DC". A PF1e one-shot skill bonus (SRD spells/guidance.md, skill track) discharges on this roll (stacked by bonus type). """ roll = self._rng.d20() shot = self._discharge_one_shot(state, "skill") extra = list(extra_mods) if shot is not None: if not is_stat_target(skill): msg = f"Unknown skill: {skill}" raise ValueError(msg) extra.append( StatModifier(target=skill, value=shot.value, bonus_type=shot.bonus_type) ) bonus = self._skill_static_bonus(state, skill, extra_mods=tuple(extra)) return SkillCheckResult(roll=roll, total=roll + bonus) def resolve_skill( self, state: CombatantState, skill: str, dc: int ) -> SaveResult: """Roll a skill check against ``dc``.""" check = self.skill_check(state, skill) # Natural 1/20 are not auto-fail/success for skills return SaveResult( success=check.total >= dc, roll=check.roll, total=check.total, dc=dc ) def _skill_static_bonus( self, state: CombatantState, skill: str, *, extra_mods: tuple[StatModifier, ...] = (), ) -> 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. CRB "Skill Checks" (KB rules/prpg-core-rulebook/ # skill-checks.md): "If the skill you're using is a class skill (and # you have invested ranks into that skill), you gain a +3 bonus" - # Table 4-2 names the row "Trained Class Skill": an untrained class # skill gets NO synergy bonus. class_bonus = 3 if skill_rank and skill_rank.class_skill and rank > 0 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")) mods.extend(extra_mods) mod_bonus = resolve_modifiers(mods) return rank + class_bonus + ability_mod + armor_penalty + mod_bonus def _roll_initiative(self) -> list[CombatantState]: rolls: dict[str, tuple[int, int]] = {} for state in self._states: roll = self._rng.d20() rolls[state.combatant.id] = (roll, roll + state.combatant.initiative_mod) self._initiative_keys = { s.combatant.id: ( rolls[s.combatant.id][1], s.combatant.initiative_mod, self._states.index(s), ) for s in self._states } order = sorted( self._states, key=lambda s: self._initiative_keys[s.combatant.id], reverse=True, ) for state in order: roll, total = rolls[state.combatant.id] self._log( f"initiative: {state.combatant.id} " f"d20={roll}+{state.combatant.initiative_mod}={total}" ) return order def _build_round_order(self) -> list[CombatantState]: """Build initiative order for this round from current ``_states``. Summoned creatures (not present at the start of combat) inherit their caster's initiative key so they act immediately after the caster via stable sort. """ def sort_key(s: CombatantState) -> tuple[float, int, int]: key = self._initiative_keys.get(s.combatant.id) if key is not None: return key caster_id = s.summon_caster_id if caster_id and caster_id in self._initiative_keys: ck = self._initiative_keys[caster_id] return (ck[0], ck[1], ck[2]) return (0, 0, -999) return sorted(self._states, key=sort_key, reverse=True) def _expire_summons(self) -> None: """Decrement summon durations and remove expired summoned creatures.""" expired: list[CombatantState] = [] for state in list(self._states): if state.summon_duration > 0: state.summon_duration -= 1 if state.summon_duration <= 0: expired.append(state) for state in expired: self._log( f"round {self._current_round} {state.combatant.id}: summon expired" ) self._states.remove(state) if state in self._pending_summons: self._pending_summons.remove(state) def _winner_side(self) -> str | None: active_sides = {s.side for s in self._states if s.active} if len(active_sides) == 1: return active_sides.pop() return None def threatened_squares(self, state: CombatantState) -> frozenset[Pos]: """Squares this combatant threatens with a melee weapon. (API publique, lecture pour politique LLM.) Natural reach (plain weapons and natural attacks) is a disk: every square up to the effective reach, adjacency included. A reach weapon threatens only the ring beyond the wielder's natural reach up to double it (PF1e reach dead zone). The union over usable melee weapons is returned. """ if not state.active: return frozenset() melee = [w for w in state.usable_attacks() if w.kind == "melee"] if not melee: return frozenset() threatened: set[Pos] = set() for weapon in melee: threatened |= self._weapon_threatened_squares(state, weapon) return frozenset(threatened) def _weapon_threatened_squares(self, state: CombatantState, weapon: AttackSpec) -> set[Pos]: """Squares one melee weapon threatens: disk for natural reach, ring for reach weapons. The union over every occupied cell of the creature's footprint; the creature's own cells are never threatened. """ inner_cells, outer_cells = _melee_reach_zone_cells(state, weapon) threatened: set[Pos] = set() for r, c in state.occupied_cells(): threatened |= self._reach_band_cells((r, c), inner_cells, outer_cells) threatened -= state.occupied_cells() return threatened def _reach_band_cells(self, center: Pos, inner: int, outer: int) -> set[Pos]: """Cells whose 5-10-5 distance from ``center`` lies in ``[inner, outer]``. ``inner == 0`` gives the plain disk (the center itself is never included); ``inner > 0`` gives the reach-weapon ring dead-zone band. """ r, c = center return { (r + dr, c + dc) for dr in range(-outer, outer + 1) for dc in range(-outer, outer + 1) if (dr, dc) != (0, 0) and inner <= self._grid.distance(center, (r + dr, c + dc)) <= outer } def enemies_threatening(self, state: CombatantState, pos: Pos) -> list[CombatantState]: """Active enemies whose threatened squares include pos (in self._states order). (API publique, lecture pour politique LLM.) """ return [ s for s in self._states if s is not state and s.active and s.side != state.side and pos in self.threatened_squares(s) ] def reachable_ft(self, state: CombatantState, budget: int | None = None) -> dict[Pos, int]: """Movement cost in feet from state's anchor to each reachable cell. (API publique, lecture pour politique LLM.) Same cost model as ``Grid.reachable``: 5-10-5 diagonals, terrain, elevation, blocked creatures excluded. ``budget`` is in feet; None = full field. """ blocked = self._blocked(state) if budget is None: field = self._grid.reachable(state.pos, None, blocked) else: budget_cells = budget // _SQUARE_FT if budget_cells < 1: return {} field = self._grid.reachable(state.pos, budget_cells, blocked) return {pos: cost * _SQUARE_FT for pos, cost in field.items()} def movement_cost_ft(self, state: CombatantState, target: CombatantState) -> int | None: """Minimal movement cost in feet for ``state`` to reach a cell from which it can melee ``target``, or None if unreachable under its speed. (API publique, lecture pour politique LLM.) Uses the same cost model as ``_move_path``: a full Dijkstra field from the target's anchor (other active states' cells blocked), then the cheapest field value over ``state``'s footprint, compared against the combatant's effective (land-or-fly) speed. Read-only: never mutates state nor consumes RNG. """ blocked = self._blocked(state, target) field = self._grid.reachable(target.pos, None, blocked) cost_cells = self._footprint_cost(state.occupied_cells(), field) if cost_cells is None: return None speed_cells = _speed_ft(state) // _SQUARE_FT if cost_cells > speed_cells: return None return cost_cells * _SQUARE_FT def _aoo_defense_block( self, attacker: CombatantState, target: CombatantState ) -> str | None: """Why an AoO cannot be executed against the target, or None. PF1e (CRB "Cover"): "You can't execute an attack of opportunity against an opponent with cover relative to you"; (CRB "Concealment"): "You can't execute an attack of opportunity against an opponent with total concealment" (miss chance >= 50%). A BLINDED attacker considers ALL its opponents to have total concealment (CRB "Conditions — Blinded": "All opponents are considered to have total concealment (50% miss chance) against the blinded character"), so its AoOs are suppressed too. Melee line: terrain cover only — creatures grant no melee cover. """ concealment = self._concealment_chance(target) if self._has_condition_named(attacker, "blinded"): concealment = max(concealment, _TOTAL_CONCEALMENT_PCT) if concealment >= _TOTAL_CONCEALMENT_PCT: return "total concealment" if cover_bonus_cells( self._grid, attacker.occupied_cells(), target.occupied_cells(), ranged=False, occupied=frozenset(), ): return "cover" return None def _resolve_aoo(self, attacker: CombatantState, target: CombatantState) -> bool: """Resolve one attack of opportunity (single melee attack at normal bonus). AoOs are always taken (PF1e allows declining, but the simulator always strikes). A target with cover or total concealment relative to the attacker cannot be struck at all — the opportunity is suppressed without spending it. Returns True if the target dropped. """ if self.has_condition_flag(attacker, "cant_aoo"): return False blocked = self._aoo_defense_block(attacker, target) if blocked is not None: self._log( f"round {self._current_round} {attacker.combatant.id}: " f"AoO vs {target.combatant.id} suppressed ({blocked})" ) return False self._aoo_used[attacker.combatant.id] = self._aoo_used.get(attacker.combatant.id, 0) + 1 weapon = next((w for w in attacker.usable_attacks() if w.kind == "melee"), None) if weapon is None: return False return self._resolve_swing( attacker, target, weapon, label="AoO" ) def _check_move_aoo( self, mover: CombatantState, *, movement: bool = True ) -> bool: """Resolve AoOs provoked by leaving any threatened cell of the mover's footprint. Each enemy threatening a cell the mover occupies strikes once (capped by the per-round AoO allowance). PF1e (CRB "Attacks of Opportunity — Combat Reflexes and Additional Attacks of Opportunity"): "Moving out of more than one square threatened by the same opponent in the same round doesn't count as more than one opportunity for that opponent" — with ``movement`` the per-round per-opponent dedupe in ``_move_aoo_provoked`` skips a second movement-triggered strike from the same enemy. Stand-up (``movement=False``) is a separate trigger and always provokes, subject to the allowance. Returns True if the mover dropped. """ provoked = self._move_aoo_provoked.setdefault(mover.combatant.id, set()) for cell in mover.occupied_cells(): for enemy in self.enemies_threatening(mover, cell): if movement and enemy.combatant.id in provoked: continue if self._aoo_used.get(enemy.combatant.id, 0) >= self._aoo_allowance(enemy): continue if movement: provoked.add(enemy.combatant.id) self._resolve_aoo(enemy, mover) if not mover.active: return True return False def _check_enter_aoo(self, mover: CombatantState, next_pos: Pos) -> bool: """Resolve AoOs provoked by ENTERING a hostile creature's square. PF1e (SRD "Movement, Position, and Distance" / "Moving Through a Square"): "You can move through a square occupied by a creature three size categories larger than you" / "a creature three size categories smaller than it" — the pass-through provokes an attack of opportunity from the occupant. The strike fires when the mover's footprint at ``next_pos`` overlaps a hostile occupant's cells but the current footprint does not (a fresh entry, not an existing co-occupancy). Co-occupiable pairs (Tiny-or-smaller vs strictly larger, S4c) keep their no-AoO sharing behaviour. Capped by the per-round AoO allowance; no per-opponent dedupe (``_move_aoo_provoked`` dedupes only leaving threatened cells). Returns True if the mover dropped. """ before = mover.occupied_cells() after = self._footprint_at(next_pos, mover) for s in self._states: if not s.active or s is mover or s.side == mover.side: continue if not _can_pass_through(mover.combatant.size, s.combatant.size): continue if _can_cooccupy(mover.combatant.size, s.combatant.size): continue # S4c: sharing a square never provokes cells = s.occupied_cells() if not (after & cells): continue if before & cells: continue # already co-occupying: no fresh entry if self._aoo_used.get(s.combatant.id, 0) >= self._aoo_allowance(s): continue self._resolve_aoo(s, 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 _entangled_concentration_dc(self, spell_level: int) -> int: """SRD Table 9-1: "Entangled while casting" => DC 15 + spell level.""" return 15 + spell_level def _check_concentration( self, caster: CombatantState, spell_level: int, damage_taken: int, *, dc_override: int | None = None, combat_casting_applies: bool = True, ) -> 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 (SRD feats/combat-casting.md: "a +4 bonus on concentration checks made to cast a spell or use a spell-like ability when casting on the defensive or while grappled") - never for damage taken while casting, never while entangled. ``combat_casting_applies`` is passed False by the situations outside the feat (entangled); ``dc_override`` pins the DC (used by the entangled gate). """ dc = ( self._concentration_dc(caster, spell_level, damage_taken) if dc_override is None else dc_override ) injured_while_casting = damage_taken > 0 and caster.grappled_with is None has_combat_casting = ( combat_casting_applies and 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 _charge_speed_mult(self, state: CombatantState) -> int: """Charge speed multiplier: 2x normally; 1x for a creature restricted to a single standard action — PF1e (SRD "Charge"): "If you are able to take only a standard action on your turn, you can still charge, but you are only allowed to move up to your speed (instead of up to double your speed)" — staggered/disabled creatures and the surprise round (mirrors the Restricted Withdraw exception). """ if self._surprise_round or self._is_staggered_limited(state): return 1 return 2 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 self._has_condition_named(state, "blinded"): # SRD "Charge" (charge.md): "If you don't have line of sight to # the opponent at the start of your turn, you can't charge that # opponent." A blinded creature has line of sight to nothing. return ChargeResult(weapon=None, distance_ft=0, reason="no_line_of_effect") 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 = self._charge_speed_mult(state) * 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 _path_cost_5105(self, path: list[Pos]) -> int: """Total 5-10-5 movement cost of a path (diagonal parity threaded).""" total = 0 diagonals_used = 0 for frm, to in pairwise(path): total += self._grid.step_cost(frm, to, diagonals_used) if frm[0] != to[0] and frm[1] != to[1]: diagonals_used += 1 return total 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: continue if not self._charge_path_clear(path, state): continue # The charge budget is a movement budget: count it in 5-10-5 # squares (diagonal parity threaded), not raw cells. if self._path_cost_5105(path) > max_cells: 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. PF1e (SRD "Measuring Distance"): "You can't move diagonally past a corner" - a diagonal step whose two flanking squares are not both passable is illegal, so a charge may never cut a wall corner (the same corner rule the Dijkstra descent enforces in ``_best_move_step``). PF1e (SRD "Charge"): "If any line from your starting space to the ending space passes through a square that blocks movement, slows movement, or contains a creature (even an ally), you can't charge" - the straight Bresenham anchor line is the engine's model of that path, walked under the full-footprint gates below. Difficult terrain is tested over the WHOLE footprint, not just the anchor cell (SRD "Movement, Position and Distance": "You can't run or charge across difficult terrain" - a 2x2 creature with one trailing square in rubble cannot charge). """ prev = state.pos for pos in path: footprint = self._footprint_at(pos, state) if not self._cells_free(footprint, state): return False if self._footprint_touches_difficult(footprint): return False if ( prev[0] != pos[0] and prev[1] != pos[1] and not self._grid.diagonal_allowed(prev, pos) ): return False prev = pos 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 = self._charge_speed_mult(attacker) * 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. PF1e (SRD "Total Defense"): "You can't make attacks of opportunity while using total defense" — the flag drops the AoO allowance to 0 (see ``_aoo_allowance``) — and "You can't combine total defense with fighting defensively": the bonus is SET (never stacked on a prior +2) and a pending fighting-defensively declaration is voided (under total defense no attack is made, so its -4 is moot). """ state.total_defense = True state.fight_defensively = False 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. PF1e (SRD "Total Defense"): "You can't combine total defense with fighting defensively" — the declaration is rejected while total defense is active. """ if state.total_defense: self._log( f"round {self._current_round} {state.combatant.id}: " f"fight_defensively rejected ({_REASON_CANNOT_COMBINE})" ) return ActionResult( kind="fight_defensively", ok=False, reason=_REASON_CANNOT_COMBINE ) 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 state.aid_grants.append((target.combatant.id, mode)) 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 _expire_aid_grants(self, aider: CombatantState) -> None: """Remove this aider's unspent aid bonuses from its beneficiaries. PF1e (SRD "Aid Another"): the +2 lasts "as long as that attack comes before the beginning of your next turn" — your = the AIDER's turn, so expiry is tracked on the aider. Each grant subtracts its +2 from the beneficiary's pool (floor 0 — a bonus already consumed by an attack is untouched). Simplification: an aider removed before its next turn never expires its grants (the engine has no death hook for them). """ for target_id, mode in aider.aid_grants: for state in self._states: if state.combatant.id != target_id: continue if mode == "ac": state.aid_ac_bonus = max(0, state.aid_ac_bonus - _AID_ANOTHER_BONUS) else: state.aid_attack_bonus = max( 0, state.aid_attack_bonus - _AID_ANOTHER_BONUS ) break aider.aid_grants = [] def _tick_run_endurance(self, state: CombatantState) -> None: """Per-round run-endurance bookkeeping at the start of the turn. PF1e (SRD "Full-Round Actions — Run"): the consecutive-run streak breaks when a round passes without running; the 10-round rest forced by a failed endurance check ticks once per round (the failing round itself counts as the first). Then the running flag lapses with the turn. """ if not state.running: state.run_rounds = 0 state.run_checks = 0 if state.run_rest_rounds > 0: state.run_rest_rounds -= 1 state.running = False def _execute_run( self, state: CombatantState, target: CombatantState | None, action: Action ) -> ActionResult: """Execute the run action (PF1e, U5e): full-round movement at 4x speed. PF1e (SRD "Full-Round Actions — Run"): "When you run, you can move up to four times your speed in a straight line" — a run is NOT a ``move``: it cannot turn a corner. The path is the straight Bresenham line from the combatant's position toward an explicit destination (U5c) or the resolved target, walked under a 4x speed budget with the usual step-by- step AoO provocation, and truncated at the first obstacle (impassable terrain, a difficult-terrain cell, or a blocking creature) exactly like the charge's clear-path rule (``_run_line_path``). PF1e forbids running across difficult terrain (CRB "Movement, Position and Distance — Difficult Terrain": "You can't run or charge across difficult terrain"), so a difficult cell stops the run short. An auto-directed run advances until the obstacle and stops there (deterministic); a hostile creature (the target included) halts it one cell short, so it ends adjacent to the target. Friendly squares are still crossed, never ended on (SRD Movement, conformance L5). On completion the combatant is ``running`` until the start of its next turn, which denies it its Dexterity bonus to AC unless it has the Run feat (SRD Run: "You lose any Dexterity bonus to AC unless you have the Run feat"). PF1e bounds the run by endurance: "You can run for a number of rounds equal to your Constitution score, but after that you must make a DC 10 Constitution check to continue running. You must check again each round in which you continue to run, and the DC of this check increases by 1 for each check you have made. When you fail this check, you must stop running. A character who has run to his limit must rest for 1 minute (10 rounds) before running again." The Run feat grants +4 on the check. Out of scope (documented, not approximated): the heavy- armor clause "or three times your speed if you're in heavy armor" — the model carries no armor field (AC is a final sheet value). An explicit destination that the straight line cannot honour within the 4x budget (blocked by terrain, difficult terrain, a creature, or out of range) is rejected with ``unreachable_destination``; with neither target nor destination the U1 gate rejects the action with ``missing_target``. """ kind = "run" if state.moved_this_turn: # Defense in depth, mirroring the _move/_5foot_step no-op-ok # convention. Unreachable through the filtered path: run is a # full-round action, so no move-slot action can precede it in a # kept sequence (the budget filter would have dropped one). return ActionResult(kind=kind, ok=True, reason=_REASON_OK) if self.has_condition_flag(state, "cant_move"): self._log(f"round {self._current_round} {state.combatant.id}: wait (cannot move)") return ActionResult(kind=kind, ok=True, reason=_REASON_OK) # SRD "Conditions — Fatigued": "A fatigued character can neither run # nor charge" (same for exhausted/entangled via cant_charge). SRD # "Full-Round Actions — Run" (full-round-actions.md): "You can't run # across difficult terrain or if you can't see where you're going" — # a blinded creature sees nowhere. if self.has_condition_flag(state, "cant_charge") or self._has_condition_named( state, "blinded" ): self._log(f"round {self._current_round} {state.combatant.id}: wait (cannot run)") return ActionResult(kind=kind, ok=True, reason=_REASON_OK) # SRD Run endurance: the past-the-limit Constitution check and the # 10-round rest that follows a failure gate the run (the turn is # forfeited either way, with the verdict logged by the gate). if not self._run_endurance_gate(state): return ActionResult(kind=kind, ok=True, reason=_REASON_OK) if action.destination is not None: return self._run_to_destination(state, action.destination) if target is not None: path = self._run_line_path(state, target.pos) if self._walk_steps(state, path): state.moved_this_turn = True return self._finish_run(state) def _run_to_destination(self, state: CombatantState, destination: Pos) -> ActionResult: """Run to an explicit destination (U5c): the straight line must honour it. A destination equal to the current position is a valid zero-length run; a straight line that cannot reach the destination within the 4x budget is rejected with ``unreachable_destination`` (no silent short-stop). """ kind = "run" if destination == state.pos: path: list[Pos] = [] # already there: a valid zero-length run else: path = self._run_line_path(state, destination) if not path or path[-1] != destination: path = [] # the straight line could not honour it if not path and destination != state.pos: self._log( f"round {self._current_round} {state.combatant.id}: " f"{kind} rejected ({_REASON_UNREACHABLE_DESTINATION})" ) return ActionResult( kind=kind, ok=False, reason=_REASON_UNREACHABLE_DESTINATION ) self._walk_steps(state, path) state.moved_this_turn = True return self._finish_run(state) def _finish_run(self, state: CombatantState) -> ActionResult: """Complete a run round: extend the streak and set the running flag.""" state.run_rounds += 1 state.running = True self._log(f"round {self._current_round} {state.combatant.id}: run") return ActionResult(kind="run", ok=True, reason=_REASON_OK) def _running_denies_dex(self, state: CombatantState) -> bool: """True when the running state denies the Dex bonus to AC. PF1e (SRD "Full-Round Actions — Run"): "You lose any Dexterity bonus to AC unless you have the Run feat." """ return state.running and not self._has_feat(state, _RUN_FEAT) def _run_endurance_gate(self, state: CombatantState) -> bool: """Endurance gate for one more run round (SRD "Full-Round Actions — Run"). During the post-failure rest the character "can move no faster than a normal move action", so the run is forfeited. Past a number of consecutive rounds equal to the Constitution score, each further round requires the escalating Constitution check (see ``_run_endurance_check``). Returns True when the run may proceed. """ if state.run_rest_rounds > 0: self._log( f"round {self._current_round} {state.combatant.id}: " f"wait (cannot run, resting {state.run_rest_rounds} rounds)" ) return False return self._run_endurance_check(state) def _run_endurance_check(self, state: CombatantState) -> bool: """Constitution check to keep running past the runner's CON score. PF1e (SRD "Full-Round Actions — Run"): "You can run for a number of rounds equal to your Constitution score, but after that you must make a DC 10 Constitution check to continue running. You must check again each round in which you continue to run, and the DC of this check increases by 1 for each check you have made. When you fail this check, you must stop running. A character who has run to his limit must rest for 1 minute (10 rounds) before running again." The Run feat grants "+4 bonus on this check" (SRD Feats — Run). Undead and constructs have no Constitution score and cannot be fatigued, so they are exempt. An ability check takes no automatic success/failure on a natural roll (CRB "Using Ability Scores"). Returns True when the run may proceed. """ if state.combatant.creature_type != "living": return True if state.run_rounds < state.combatant.abilities.con_score: return True dc = _RUN_CHECK_BASE_DC + state.run_checks con_mod = (state.combatant.abilities.con_score - 10) // 2 bonus = _RUN_FEAT_CHECK_BONUS if self._has_feat(state, _RUN_FEAT) else 0 roll = self._rng.d20() total = roll + con_mod + bonus state.run_checks += 1 check = f"d20({roll}) + CON({con_mod})" if bonus: check += f" + Run({bonus})" check += f" = {total} vs DC {dc}" if total >= dc: self._log( f"round {self._current_round} {state.combatant.id}: " f"keeps running ({check})" ) return True state.run_rounds = 0 state.run_checks = 0 state.run_rest_rounds = _RUN_REST_ROUNDS self._log( f"round {self._current_round} {state.combatant.id}: run stopped " f"({check} failed, rests {_RUN_REST_ROUNDS} rounds)" ) return False 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", exclude=action.channel_exclude, ) elif kind == "hex": self._execute_hex(state, target, action.hex_name) elif kind == "deed": self._execute_deed(state, target, action.deed_name) elif kind == "spell_combat": self._execute_spell_combat(state, target, action.spell_name) elif kind == "inspire_courage": self._execute_inspire_courage(state, target) def _execute_movement( self, state: CombatantState, target: CombatantState | None, action: Action ) -> ActionResult | None: """Execute a movement-style action (5foot_step, move, wait). With an explicit destination, move/5foot_step walk to that cell and report unreachable_destination when it cannot be honoured (U5c); without one they auto-path toward the target as before. """ kind = action.kind if kind == "5foot_step": if action.destination is not None: return self._step5_to(state, action.destination) if target is None: return None self._step5(state, target) elif kind == "move": if action.destination is not None: return self._move_to(state, action.destination) if target is None: return None self._move(state, target) elif kind == "wait": self._log(f"round {self._current_round} {state.combatant.id}: wait") elif kind == "activate_power_attack": self._execute_activate_power_attack(state) return None def _execute_immediate( self, state: CombatantState, target: CombatantState | None, action: Action ) -> None: """Execute an immediate action (off-turn or in-turn). PF1e (SRD Immediate Actions): "Using an immediate action on your turn is the same as using a swift action and counts as your swift action for that turn." An immediate fired during the creature's OWN turn therefore spends the CURRENT turn's swift (``swift_used_this_turn``: swift actions later in the sequence are dropped, and no further immediate fires this round). Off-turn, the immediate consumes the NEXT turn's swift (``immediate_used``), which suppresses swift/free bonus actions on the following turn. The effect is resolved by the action's carrier fields: a spell, a maneuver, a class ability, or a single attack on ``target``. """ if self._turn_actor is state: state.swift_used_this_turn = True else: state.immediate_used = True if action.spell_name is not None: self._execute_cast_spell( state, target, action.spell_name, target_pos=action.target_pos, metamagic=action.metamagic, cast_defensively=action.cast_defensively, ) elif action.maneuver is not None: self._execute_maneuver(state, target, action.maneuver) elif action.class_ability is not None: self._execute_class_ability(state, target, action) elif target is not None: self._execute_attack(state, target, "attack") def _execute_activate_power_attack(self, state: CombatantState) -> None: """Activate Power Attack as a free action (requires the feat).""" if not self._has_feat(state, "Power Attack"): self._log( f"round {self._current_round} {state.combatant.id}: " f"activate_power_attack: no Power Attack feat" ) return state.power_attack = True self._log( f"round {self._current_round} {state.combatant.id}: " f"Power Attack activated" ) def _check_reactions(self, event: ReactionEvent) -> None: """Fire readied actions first, then offer the event to the reaction policy. A pending readied action fires independently of ``reaction_policy``; at most one trigger resolves per event (a fired readied action suppresses the policy loop). One immediate per event: the first active combatant (in state order) whose policy returns an action gets to act. PF1e (SRD "Immediate Actions"): "You also cannot use an immediate action if you are flat-footed" — flat-footed states are skipped. Reactions never cascade — while a reaction is executing, further events are ignored. """ if self._in_reaction: return if self._check_readied(event): return if self._reaction_policy is None: return for state in self._states: if ( not state.active or state.immediate_used or state.swift_used_this_turn or self._has_condition_named(state, "flat_footed") ): continue action = self._reaction_policy(self, state, event) if action is None: continue self._in_reaction = True self._execute(state, action) self._in_reaction = False return def _expire_readied_action(self, state: CombatantState) -> None: """PF1e (SRD "Ready — Initiative Consequences of Readying"): "If you come to your next action and have not yet performed your readied action, you don't get to take the readied action (though you can ready the same action again)." An untriggered readied action expires at the START of the combatant's next turn, before the policy runs (a forfeited turn still expires it).""" if state.readied is None: return self._log( f"round {self._current_round} {state.combatant.id}: " "ready expired (trigger never occurred)" ) state.readied = None state.readied_trigger = None def _check_readied(self, event: ReactionEvent) -> bool: """Fire the first matching readied action; True when one fired. Any active combatant (in state order) whose pending trigger matches the event executes its payload off-turn with no swift cost. The pending action is cleared BEFORE the payload runs, so a readied action is one-shot and never re-fires on its own events; the payload's own events are additionally suppressed by ``_in_reaction``. """ for state in self._states: payload = state.readied if not state.active or payload is None: continue if not self._ready_trigger_matches(state, event): continue trigger = state.readied_trigger state.readied = None state.readied_trigger = None self._log( f"round {self._current_round} {state.combatant.id}: " f"ready fires ({trigger})" ) self._ready_shift_initiative(state, event.actor) target = next( (s for s in self._states if s.combatant.id == payload.target_id), None, ) self._in_reaction = True self._dispatch_action(state, target, payload) self._in_reaction = False return True return False def _ready_shift_initiative( self, state: CombatantState, trigger_actor: CombatantState ) -> None: """Move the readier's initiative to the count it acted on (PF1e). PF1e (SRD "Ready — Initiative Consequences of Readying"): "Your initiative result becomes the count on which you took the readied action" and you "act immediately ahead of the character whose action triggered your readied action" — for the rest of the encounter. The slot is written into ``_initiative_keys`` with the same tuple shape ``_execute_delay`` uses (a 0.5 step off the anchor total, keeping the readier's initiative modifier and prior list index) so ``_build_round_order`` holds the new position for every following round. The step is ADDED (not subtracted as for delay): a readied action interrupts and acts immediately AHEAD of the trigger, whereas a delay postpones to act after — the two intents are opposite. Deterministic: no RNG is consumed and the current round's order (already built) is untouched — only subsequent rounds reorder. """ key = self._initiative_keys.get(state.combatant.id) prior_index = key[2] if key is not None else self._states.index(state) trigger_key = self._initiative_keys.get(trigger_actor.combatant.id) trigger_total = trigger_key[0] if trigger_key is not None else 0.0 self._initiative_keys[state.combatant.id] = ( trigger_total + _DELAY_INITIATIVE_STEP, state.combatant.initiative_mod, prior_index, ) def _ready_trigger_matches( self, state: CombatantState, event: ReactionEvent ) -> bool: """True when the event matches the combatant's pending readied trigger. Firing requires the event actor to be an ENEMY of the readier (different side); ``enemy_hits_ally`` additionally requires the damage target to share the readier's side. ``enemy_enters_reach`` fires on any enemy movement step — documented simplification: reach is not re-computed per step. """ trigger = state.readied_trigger if trigger is None: return False hostile = event.actor.side != state.side if trigger == "enemy_enters_reach": return hostile and event.kind == "movement_step" if trigger == "enemy_casts": return hostile and event.kind == "spell_cast" if trigger == "enemy_attacks": return hostile and event.kind == "attack_resolved" return ( hostile and event.kind == "damage_taken" and event.target is not None and event.target.side == state.side ) def _execute_cast_spell( self, state: CombatantState, target: CombatantState | None, spell_name: str | None, *, target_pos: tuple[int, int] | None = None, metamagic: tuple[str, ...] = (), cast_defensively: bool = False, ) -> ActionResult | None: """Cast the carried spell; an explicit ``target_pos`` (U7b) is validated first — an off-map point or one without line of effect from the caster is rejected without casting. ``metamagic`` (B7) selects the feats applied to THIS cast; ``cast_defensively`` (B24) trades the casting AoO for a concentration check.""" if self.has_condition_flag(state, "cant_attack"): # PF1e (CRB): a nauseated creature cannot cast spells or # concentrate; the cant_attack flag blocks the cast entirely. self._log( f"round {self._current_round} {state.combatant.id}: " f"cast_spell rejected (cannot attack)" ) return None if target_pos is not None: rejection = self._point_target_rejection(state, target_pos, spell_name) 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], spell_name: str | None, ) -> ActionResult | None: """U7b gate: a point-targeted area must be in bounds and reachable. The point must always be in bounds. Line of effect caster -> point is required only for a BURST (or a spell with no area, conservatively): the burst's point of origin is that space and SRD aiming-a-spell.md demands "a clear line of effect ... to any space in which you wish to create an effect", and a burst "can't affect creatures with total cover from its point of origin". A cone or line instead "shoots away from you ... in the direction you designate" and "starts from any corner of your square": the aim point only names a DIRECTION from the caster's own space, so no line of effect to it is required — LoE is enforced by the template itself (the cone drops cells it cannot reach from its origin, the line stops at the first barrier). """ if not self._grid.in_bounds(target_pos): 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 ) if self._point_cast_area_shape(spell_name) == "burst" and not ( has_line_of_effect_cells( self._grid, state.occupied_cells(), frozenset({target_pos}) ) ): 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 ) return None def _point_cast_area_shape(self, spell_name: str | None) -> AreaShape: """Shape of the spell's top-level area: 'burst' when it has none. A spell with no AreaEffect never honours ``target_pos`` downstream; the gate keeps its historical burst-grade strictness for those. """ spell = self._spell_registry.get(spell_name) if spell_name is not None else None if spell is not None: for effect in spell.effects: if isinstance(effect, AreaEffect): return effect.shape return "burst" 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) # PF1e (SRD Conditions - Grappled / Pinned): a grappled or pinned # creature takes a -2 penalty on "all attack rolls AND combat # maneuver checks, except for those made to escape or break a # grapple". Escape returned to its own handler above; initiating a # grapple is the exempt check. _maneuver_cmb stays a pure stat # (the concentration DC reads the grappler's CMB, not a check). grapple_penalty = ( _GRAPPLED_CMB_PENALTY if maneuver != "grapple" and ( self._has_condition_named(state, "grappled") or self._has_condition_named(state, "pinned") ) else 0 ) attacker_cmb = self._maneuver_cmb(state) - pa_amt - grapple_penalty target_cmd = self._maneuver_cmd(target) roll = self._rng.d20() # PF1e (SRD Combat Maneuvers): "If your target is immobilized, # unconscious, or otherwise incapacitated, your maneuver # automatically succeeds (treat as if you rolled a natural 20 on # the attack roll)." The engine's incapacitated notion is the # helpless set (helpless/paralyzed/asleep/unconscious, CRB # "Conditions"); the natural 20 also feeds the margin-based # outcomes (bull rush / overrun / reposition / drag / dirty trick). if self._is_helpless(target): roll = _NATURAL_TWENTY # 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: """Pick a stolen or dropped weapon back up. Removes ``weapon_id`` from ``weapon_stolen`` (steal maneuvers) or ``weapon_dropped`` (disarm maneuvers — SRD Disarm: the disarmed creature "drops one item it is carrying"; retrieving a dropped item is a move action). No-op (with a log line) when ``weapon_id`` is None or the id was never stolen or dropped. 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 in state.weapon_stolen: state.weapon_stolen.remove(weapon_id) elif weapon_id in state.weapon_dropped: state.weapon_dropped.remove(weapon_id) else: self._log( f"round {self._current_round} {state.combatant.id}: " f"retrieve {weapon_id}: nothing to retrieve" ) return 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. A DESTROYED weapon (hp <= 0, "ruined" per CRB "Smashing an Object") cannot be repaired by this action. """ if weapon_id is None: self._log( f"round {self._current_round} {state.combatant.id}: " f"repair: nothing to repair" ) return if weapon_id in state.weapon_destroyed: self._log( f"round {self._current_round} {state.combatant.id}: " f"repair {weapon_id}: weapon destroyed" ) 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) 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. # A pre-baked CMB on a Tiny-or-smaller creature ALREADY carries that # substitution (SRD: they "use their Dexterity modifier in place of # their Strength modifier to determine their CMB"), so it must never # seed the CMD base — the extra Dexterity below would be double- # counted and Strength lost. Use the raw CMD formula for those sizes. if target.combatant.size.lower() in _TINY_OR_SMALLER_SIZES: target_base = ( target.combatant.bab + target.combatant.abilities.str_mod + self._size_mod(target) ) else: target_base = target.combatant.cmb or ( target.combatant.bab + target.combatant.abilities.str_mod + self._size_mod(target) ) # PF1e (SRD Combat Maneuver Defense): "A creature can also add any # circumstance, deflection, dodge, insight, luck, morale, profane, # and sacred bonuses to AC to its CMD. [...] A flat-footed creature # does not add its Dexterity bonus to its CMD." A Dexterity penalty # is not a bonus, so it still applies when flat-footed. dex_mod = target.combatant.abilities.dex_mod if self.has_condition_flag(target, "denies_dex_to_ac"): dex_mod = min(0, dex_mod) # PF1e (SRD Combat Maneuvers): "Any penalties to a creature's AC also # apply to its CMD." — negative AC modifiers ride the CMD whatever # their bonus type; only positive ones are restricted to the eight # SRD-listed types. ac_mods = [ m for m in self._stat_modifiers(target, "ac") if m.bonus_type in _CMD_AC_BONUS_TYPES or m.value < 0 ] # A combat maneuver is a melee attack roll (SRD: "Combat maneuvers are # attack rolls"), so the prone defender's -4 melee AC penalty applies. if self._has_condition_named(target, "prone"): ac_mods.append(StatModifier(target="ac", value=-4)) ac_bonuses = resolve_modifiers(ac_mods) # defensive_ac_bonus (total defense / fight defensively) is a dodge # bonus and aid_ac_bonus a circumstance bonus to AC — both types the # SRD lets a creature add to its CMD. return ( 10 + target_base + dex_mod + ac_bonuses + target.defensive_ac_bonus + target.aid_ac_bonus ) def _maneuver_trip( self, state: CombatantState, target: CombatantState, m: _ManeuverRoll ) -> None: if not m.success: self._maneuver_fail(state, target, "trip", m) # PF1e (SRD Trip): "If your attack fails by 10 or more, you are # knocked prone instead." if m.total <= m.target_cmd - _TRIP_RETALIATION_MARGIN: state.active_conditions.append( ActiveCondition( condition=CONDITIONS_BY_NAME["prone"], rounds_left=1, source="maneuver: trip retaliation", ) ) self._log( f"round {self._current_round} {state.combatant.id}: " f"trip backfire -> attacker prone" ) return target.active_conditions.append( ActiveCondition( condition=CONDITIONS_BY_NAME["prone"], rounds_left=1, source="maneuver: trip", ) ) self._log( f"round {self._current_round} {state.combatant.id}: trip vs " f"{target.combatant.id} d20={m.roll}+{m.attacker_cmb}={m.total} " f"CMD {m.target_cmd} -> SUCCESS (prone)" ) def _maneuver_disarm( self, state: CombatantState, target: CombatantState, m: _ManeuverRoll ) -> None: if not m.success: self._maneuver_fail(state, target, "disarm", m) # PF1e (SRD Disarm): "If your attack fails by 10 or more, you # drop the weapon that you were using to attempt the disarm." if m.total <= m.target_cmd - _DISARM_RETALIATION_MARGIN: own = next( (w for w in state.usable_attacks() if w.kind == "melee"), None ) if own is not None: state.weapon_dropped.add(own.id) self._log( f"round {self._current_round} {state.combatant.id}: " f"disarm backfire -> drops {own.name}" ) return # PF1e (SRD Disarm): "If your attack is successful, your target drops # one item it is carrying of your choice" — the weapon lands on the # ground (unusable until a retrieve_weapon move action picks it back # up), NOT a synthetic -4 attack penalty. The engine chooses the # target's first usable melee weapon (the wielded weapon, same choice # as the AoO machinery). 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}: disarm vs " f"{target.combatant.id} d20={m.roll}+{m.attacker_cmb}={m.total} " f"CMD {m.target_cmd} -> SUCCESS (no weapon to drop)" ) return target.weapon_dropped.add(weapon.id) 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 ({weapon.name} dropped)" ) # PF1e (SRD Disarm): "If you successfully disarm your opponent # without using a weapon, you may automatically pick up the item # dropped." The engine does not track WHICH weapon a maneuver used, # so "without using a weapon" = the attacker has no usable melee # weapon at all (a true unarmed attempt). The picked-up item is not # one of the attacker's AttackSpecs, so it lands in the attacker's # carried_items (held, not wielded — the attacker cannot swing it; # documented simplification). attacker_armed = any( w.kind == "melee" for w in state.usable_attacks() ) if not attacker_armed: state.carried_items.append(weapon.name) self._log( f"round {self._current_round} {state.combatant.id}: " f"unarmed disarm pickup ({weapon.name} taken)" ) 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 # PF1e (SRD Sunder): "you can attempt to sunder an item held or worn # by your opponent" — a natural weapon is not an item, so only # manufactured melee weapons can be targeted. weapon = next( ( w for w in target.usable_attacks() if w.kind == "melee" and not w.natural ), None, ) prefix = ( 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}" ) if weapon is None: self._log(f"{prefix} -> SUCCESS (no weapon to damage)") return # PF1e (SRD Sunder): "If your attack is successful, you deal damage # to the item normally" — the attacker's melee weapon damage (physical # components only; objects are immune to energy per CRB "Smashing an # Object" energy rules, which are out of scope). A broken attacker # weapon deals -2 (CRB "Broken"). striker = next((w for w in state.usable_attacks() if w.kind == "melee"), None) raw = 0 if striker is not None: raw = sum( comp.formula.roll(self._rng) for comp in striker.damage if _PHYSICAL_DAMAGE_TYPES.issuperset(comp.types) ) + striker.damage_bonus if striker.id in state.weapon_broken: raw += _BROKEN_WEAPON_PENALTY # PF1e (CRB "Smashing an Object"): "subtract its hardness from the # damage. Only damage in excess of its hardness is deducted from the # object's hit points." hardness, max_hp = weapon.object_stats() dealt = max(0, raw - hardness) accumulated = target.weapon_damage.get(weapon.id, 0) + dealt target.weapon_damage[weapon.id] = accumulated hp_left = max_hp - accumulated if hp_left <= 0: # SRD Sunder: "If the damage you deal would reduce the object to # less than 0 hit points, you can choose to destroy it" — the # engine always takes the destructive option (no choice payload # on Action; documented simplification). target.weapon_broken.discard(weapon.id) target.weapon_destroyed.add(weapon.id) self._log(f"{prefix} -> SUCCESS (weapon destroyed)") return if hp_left * 2 <= max_hp: # SRD Sunder: "If an object has equal to or less than half its # total hit points remaining, it gains the broken condition." target.weapon_broken.add(weapon.id) self._log( f"{prefix} -> SUCCESS ({dealt} damage to weapon, broken)" ) return self._log(f"{prefix} -> SUCCESS ({dealt} damage to weapon)") def _nearest_anchor_adjacent_to( self, grappler: CombatantState, target: CombatantState ) -> Pos | None: """Closest free anchor where ``target``'s footprint is adjacent to ``grappler``'s occupied cells (SRD "Grapple" pull-to-adjacent). Candidates are scored by 5-10-5 distance from the target's current anchor; ties break on (row, col) for determinism. The target's own current cells are excluded from the occupancy check (it is the thing being moved), walls/out-of-bounds are never candidates, and adjacency means the two footprints' cells are exactly one square apart. """ grappler_cells = grappler.occupied_cells() best: tuple[int, Pos] | None = None for row in range(self._grid.height): for col in range(self._grid.width): anchor: Pos = (row, col) footprint = self._footprint_at(anchor, target) if not all( self._grid.passable(cell) for cell in footprint ): continue blocked = self._blocked(grappler, target) if footprint & (blocked | grappler_cells): continue if not any( self._grid.distance(g, t) == 1 for g in grappler_cells for t in footprint ): continue dist = self._grid.distance(anchor, target.pos) if best is None or (dist, anchor) < (best[0], best[1]): best = (dist, anchor) return None if best is None else best[1] def _maneuver_grapple( self, state: CombatantState, target: CombatantState, m: _ManeuverRoll ) -> None: if not m.success: self._maneuver_fail(state, target, "grapple", m) return # PF1e (SRD "Grapple"): "If you successfully grapple a creature that # is not adjacent to you, move that creature to an adjacent open # space (if no space is available, your grapple fails)." if self.creature_distance(state, target) > 1: anchor = self._nearest_anchor_adjacent_to(state, target) if anchor is None: self._log( f"round {self._current_round} {state.combatant.id}: grapple vs " f"{target.combatant.id} d20={m.roll}+{m.attacker_cmb}={m.total} " f"CMD {m.target_cmd} -> FAILED (no adjacent space)" ) return start = target.pos target.pos = anchor self._log( f"round {self._current_round} {state.combatant.id}: grapple pulls " f"{target.combatant.id} ({start[0]},{start[1]})->" f"({anchor[0]},{anchor[1]})" ) target.active_conditions.append( ActiveCondition( condition=CONDITIONS_BY_NAME["grappled"], rounds_left=10, source=f"maneuver: grapple by {state.combatant.id}", ) ) target.grappled_with = state.combatant.id state.grappled_with = target.combatant.id self._log( f"round {self._current_round} {state.combatant.id}: grapple vs " f"{target.combatant.id} d20={m.roll}+{m.attacker_cmb}={m.total} " f"CMD {m.target_cmd} -> SUCCESS (grappled)" ) def _maneuver_fail( self, state: CombatantState, target: CombatantState, maneuver: str, m: _ManeuverRoll, ) -> None: self._log( f"round {self._current_round} {state.combatant.id}: {maneuver} vs " f"{target.combatant.id} d20={m.roll}+{m.attacker_cmb}={m.total} " f"CMD {m.target_cmd} -> FAILED" ) def _maneuver_overrun( self, state: CombatantState, target: CombatantState, m: _ManeuverRoll ) -> None: if not m.success: self._maneuver_fail(state, target, "overrun", m) self._overrun_fail_stop(state, target) return # PF1e (CRB "Overrun"): a successful maneuver moves you through the # target's square; the target is knocked prone only if the attack # exceeds its CMD by 5 or more. knocked_prone = m.total - m.target_cmd >= _OVERRUN_PRONE_MARGIN if knocked_prone: target.active_conditions.append( ActiveCondition( condition=CONDITIONS_BY_NAME["prone"], rounds_left=1, source="maneuver: overrun", ) ) beyond = self._beyond_cell(state.pos, target.pos) moved_through = self._cells_free(self._footprint_at(beyond, state), state) if moved_through: state.pos = beyond achieved = ", ".join( label for label, flag in (("prone", knocked_prone), ("moved through", moved_through)) if flag ) outcome = f"SUCCESS ({achieved})" if achieved else "SUCCESS" self._log( f"round {self._current_round} {state.combatant.id}: overrun vs " f"{target.combatant.id} d20={m.roll}+{m.attacker_cmb}={m.total} " f"CMD {m.target_cmd} -> {outcome}" ) def _overrun_fail_stop( self, state: CombatantState, target: CombatantState ) -> None: """Park a failed overrun on the open approach cell in front of the target. PF1e (SRD Overrun): "If your overrun attempt fails, you stop in the space directly in front of the opponent, or the nearest open space in front of the creature if there are other creatures occupying that space." An already-adjacent attacker is in front: no move, no log. """ ar, ac = state.pos tr, tc = target.pos steps = max(abs(tr - ar), abs(tc - ac)) if steps <= 1: return dr = 1 if tr - ar > 0 else (-1 if tr - ar < 0 else 0) dc = 1 if tc - ac > 0 else (-1 if tc - ac < 0 else 0) for i in range(steps - 1, 0, -1): cell = (ar + dr * i, ac + dc * i) if self._cells_free(self._footprint_at(cell, state), state): start = state.pos state.pos = cell self._log( f"round {self._current_round} {state.combatant.id}: " f"overrun blocked, stopped ({start[0]},{start[1]})->" f"({cell[0]},{cell[1]})" ) return def _maneuver_dirty_trick( self, state: CombatantState, target: CombatantState, m: _ManeuverRoll, *, trick_condition: str | None = None, ) -> None: # ``trick_condition`` is typed ``str`` here (not the TrickCondition # Literal) because the APG RAW list is enforced at RUNTIME by # ``_DIRTY_TRICK_CONDITIONS`` — untrusted policy payloads must be # rejected, not just statically annotated away. if not m.success: self._maneuver_fail(state, target, "dirty_trick", m) return condition_name = trick_condition or "dazzled" if condition_name not in _DIRTY_TRICK_CONDITIONS: self._log( f"round {self._current_round} {state.combatant.id}: " f"dirty trick unknown condition {condition_name!r}" ) return condition = CONDITIONS_BY_NAME[condition_name] # PF1e (APG Dirty Trick): "This condition lasts for 1 round. For # every 5 by which your attack exceeds your opponent's CMD, the # penalty lasts 1 additional round." rounds = 1 + max(0, m.total - m.target_cmd) // _DIRTY_TRICK_EXTRA_ROUND_MARGIN target.active_conditions.append( ActiveCondition( condition=condition, rounds_left=rounds, source="maneuver: dirty trick", ) ) self._log( f"round {self._current_round} {state.combatant.id}: dirty trick vs " f"{target.combatant.id} d20={m.roll}+{m.attacker_cmb}={m.total} " f"CMD {m.target_cmd} -> SUCCESS ({condition_name} {rounds}r)" ) def _maneuver_steal( self, state: CombatantState, target: CombatantState, m: _ManeuverRoll ) -> None: if not m.success: self._maneuver_fail(state, target, "steal", m) return # PF1e (APG Steal): "This maneuver can be used in melee to take any # item that is neither held nor hidden in a bag or pack [...] 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." Only ``carried_items`` (non-wielded, non-closely- # worn gear) are stealable; weapons live in ``attacks`` and stay # immune here. The RAW lets the thief choose the item ("You must # select the item to be taken before the check is made") — the action # carries no item slot, so the engine takes the FIRST carried item # (deterministic list order). The RAW "at least one hand free" gate is # not modeled (the engine has no hand bookkeeping). if not target.carried_items: 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)" ) return item = target.carried_items.pop(0) state.carried_items.append(item) 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 (took {item})" ) def _maneuver_reposition( self, state: CombatantState, target: CombatantState, m: _ManeuverRoll, *, direction: tuple[int, int] | None = None, ) -> None: if not m.success: self._maneuver_fail(state, target, "reposition", m) return # PF1e (APG Reposition): "you may move your target 5 feet to a new # location. For every 5 by which your attack exceeds your opponent's # CMD, you can move the target an additional 5 feet" — the walk stops # at the first obstacle ("You cannot move a creature into a square # that is occupied by a solid object or obstacle"). distance = 1 + max(0, m.total - m.target_cmd) // _REPOSITION_EXTRA_PER_MARGIN step = direction if step is None: # First free neighbor (the attacker's own square is occupied, so # it can never be picked here). for dr, dc in _NEIGHBOR_OFFSETS: cand = (target.pos[0] + dr, target.pos[1] + dc) if self._cells_free(self._footprint_at(cand, target), target): step = (dr, dc) break moved = False if step is not None: for _ in range(distance): pos = (target.pos[0] + step[0], target.pos[1] + step[1]) if not self._cells_free(self._footprint_at(pos, target), target): break target.pos = pos moved = True outcome = f"SUCCESS (moved to {target.pos})" if moved else "SUCCESS (no space)" self._log( f"round {self._current_round} {state.combatant.id}: reposition vs " f"{target.combatant.id} d20={m.roll}+{m.attacker_cmb}={m.total} " f"CMD {m.target_cmd} -> {outcome}" ) def _maneuver_drag( self, state: CombatantState, target: CombatantState, m: _ManeuverRoll, *, direction: tuple[int, int] | None = None, ) -> None: if not m.success: self._maneuver_fail(state, target, "drag", m) return # PF1e (APG Drag): "both you and your target are moved 5 feet back, # with your opponent occupying your original space and you in the # space behind that in a straight line. For every 5 by which your # attack exceeds your opponent's CMD, you can drag the target back an # additional 5 feet" — the line stops at the first obstacle. distance = 1 + max(0, m.total - m.target_cmd) // _DRAG_EXTRA_PER_MARGIN dragged = 0 for _ in range(distance): ar, ac = state.pos tr, tc = target.pos adjacent = max(abs(tr - ar), abs(tc - ac)) == 1 if not adjacent: break if direction is not None: beyond = (tr + direction[0], tc + direction[1]) else: beyond = self._beyond_cell(state.pos, target.pos) if not self._cells_free(self._footprint_at(beyond, target), target): break target_old = target.pos target.pos = beyond state.pos = target_old dragged += 1 outcome = "SUCCESS (dragged)" if dragged else "SUCCESS (no space)" self._log( f"round {self._current_round} {state.combatant.id}: drag vs " f"{target.combatant.id} d20={m.roll}+{m.attacker_cmb}={m.total} " f"CMD {m.target_cmd} -> {outcome}" ) def _maneuver_escape( self, state: CombatantState, target: CombatantState ) -> None: escape_cmb = self._maneuver_cmb(state) escape_skill = ( self._skill_static_bonus(state, "escape_artist") if hasattr(state.combatant, "skills") and "escape_artist" in state.combatant.skills else 0 ) escape_bonus = max(escape_cmb, escape_skill) target_cmd = self._maneuver_cmd(target) roll = self._rng.d20() total = roll + escape_bonus # PF1e (SRD Combat Maneuvers): "Rolling a natural 20 while attempting # a combat maneuver is always a success (except when attempting to # escape from bonds)" — escaping a grapple is a combat maneuver, not # bonds, so the natural 20 auto-succeeds here too. success = roll == _NATURAL_TWENTY or (roll != _NATURAL_ONE and total >= target_cmd) if success: target.active_conditions = [ ac for ac in target.active_conditions if ac.condition.name != "grappled" ] target.conditions = [c for c in target.conditions if c.name != "grappled"] state.active_conditions = [ ac for ac in state.active_conditions if ac.condition.name != "grappled" ] state.conditions = [c for c in state.conditions if c.name != "grappled"] target.grappled_with = None state.grappled_with = None else: self._log( f"round {self._current_round} {state.combatant.id}: escape vs " f"{target.combatant.id} d20={roll}+{escape_bonus}={total} " f"CMD {target_cmd} -> FAILED" ) def _execute_channel_energy( self, state: CombatantState, target: CombatantState | None, channel_mode: Literal["heal", "harm"], *, exclude: tuple[str, ...] = (), ) -> 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). ``exclude`` is the Selective Channeling payload (see ``_selective_channel``). """ 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] hit = self._selective_channel(state, hit, exclude) # 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 _selective_channel( self, state: CombatantState, hit: list[CombatantState], exclude: tuple[str, ...], ) -> list[CombatantState]: """Apply the Selective Channeling feat to a resolved channel target list. PF1e (SRD Feats > Selective Channeling, feats/selective-channeling.md): "When you channel energy, you can choose a number of targets in the area up to your Charisma modifier. These targets are not affected by your channeled energy. Normal: All targets in a 30-foot burst are affected when you channel energy. You can only choose whether or not you are affected." Without the feat the exclusion list is ignored (the engine's default is "all targets affected, channeler included"); with it, the first ``Cha modifier`` ids are dropped from the affected set (deterministic truncation of an over-long list). The policy chooses the ids: default_policy never excludes (available via a custom policy, like the other opt-in payloads). """ if not exclude: return hit if not self._has_feat(state, _SELECTIVE_CHANNELING_FEAT): self._log( f"round {self._current_round} {state.combatant.id}: " f"selective channeling ignored (no feat)" ) return hit cha_mod = (state.combatant.abilities.cha_score - 10) // 2 allowed = set(exclude[:max(0, cha_mod)]) kept = [s for s in hit if s.combatant.id not in allowed] dropped = [s.combatant.id for s in hit if s.combatant.id in allowed] if dropped: self._log( f"round {self._current_round} {state.combatant.id}: " f"selective channeling excludes {', '.join(dropped)}" ) return kept def _channel_heal( self, state: CombatantState, affected_state: CombatantState, dmg: int ) -> None: dmg = self._bitter_reduced(state, affected_state, dmg) 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 # SRD "Nonlethal Damage": curing hit point damage removes an equal # amount of nonlethal damage (see _apply_heal_effect). if actual > 0: affected_state.nonlethal = max(0, affected_state.nonlethal - actual) # Healing is NOT damage dealt: the damage counters stay damage-only # (README "degats infligés/subis"); restored hp are booked to the # healer's separate healing_done counter (mirrors spell HealEffect). self._stats[state.combatant.id].healing_done += 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 self._lose_hp(affected_state, dealt) self._stats[state.combatant.id].damage_dealt += dealt self._stats[affected_state.combatant.id].damage_taken += dealt self._log( f"round {self._current_round} {state.combatant.id}: channel energy " f"damages {affected_state.combatant.id} {dealt} " f"(Will DC{dc} d20={save.roll}+{save.total - save.roll}={save.total} " f"{'SAVED half' if save.success else 'FAILED full'}) " f"({hp_before}->{affected_state.hp})" ) if affected_state.dead: self._log( f"round {self._current_round} {state.combatant.id}: " f"{affected_state.combatant.id} dead" ) self._check_reactions( ReactionEvent( kind="damage_taken", actor=state, target=affected_state, damage=dealt, ) ) def _execute_hex( self, state: CombatantState, target: CombatantState | None, hex_name: str | None ) -> None: """Execute a witch hex (evil_eye, slumber, cackle). APG Cackle is a free action with no hex resource cost, so it is dispatched before the resource/target gate; evil_eye and slumber are standard actions that each spend one use.""" if hex_name == "cackle": self._hex_cackle(state) return if not state.spend_resource("hexes", 1): self._log( f"round {self._current_round} {state.combatant.id}: hex failed (no resources)" ) return if target is None or not target.active: return if hex_name not in ("evil_eye", "slumber"): self._log( f"round {self._current_round} {state.combatant.id}: unknown hex {hex_name}" ) return hex_dc = 10 + state.combatant.level // 2 + (state.combatant.abilities.int_score - 10) // 2 if hex_name == "evil_eye": self._hex_evil_eye(state, target, hex_dc) else: self._hex_slumber(state, target, hex_dc) _EVIL_EYE_ROUNDS = 3 _EVIL_EYE_SAVED_ROUNDS = 1 def _hex_evil_eye( self, state: CombatantState, target: CombatantState, hex_dc: int ) -> None: """Evil Eye (APG): -2 on one chosen option for 3 rounds; a Will save reduces it to 1 round (never negates). Simplification: the chosen option is attack rolls — the engine's other hex targets are not selectable per cast.""" save_result = self.resolve_save(target, "will", hex_dc) rounds = self._EVIL_EYE_SAVED_ROUNDS if save_result.success else self._EVIL_EYE_ROUNDS target.active_buffs.append( ActiveBuff( modifiers=(StatModifier(target="attack", value=-2),), rounds_left=rounds, source=f"hex: evil_eye by {state.combatant.id}", ) ) self._log( f"round {self._current_round} {state.combatant.id}: evil eye on " f"{target.combatant.id} -> -2 attack ({rounds}r)" ) def _hex_slumber( self, state: CombatantState, target: CombatantState, hex_dc: int ) -> None: """Slumber (APG): the target remains asleep for a number of rounds equal to the witch's level; a successful Will save negates. Simplification documented in README: the RAW ending of the hex when the target takes damage is not modeled (no wake-on-damage hook).""" save_result = self.resolve_save(target, "will", hex_dc) if save_result.success: self._log( f"round {self._current_round} {state.combatant.id}: slumber on " f"{target.combatant.id} -> SAVED" ) return rounds = state.combatant.level target.active_conditions.append( ActiveCondition( condition=CONDITIONS_BY_NAME["asleep"], rounds_left=rounds, source=f"hex: slumber by {state.combatant.id}", ) ) self._log( f"round {self._current_round} {state.combatant.id}: slumber on " f"{target.combatant.id} -> asleep ({rounds}r)" ) _CACKLE_RANGE_FT = 30 def _hex_cackle(self, state: CombatantState) -> None: """Cackle (APG): a free action that extends the duration of every evil eye, fortune or misfortune hex on all creatures within 30 ft by 1 round. Fortune/misfortune are not implemented, so the extension covers evil_eye buffs; each buff list is rebuilt in a single pass so an extended hex is never re-extended.""" extended = 0 for other in self._states: if not other.active: continue if self.creature_distance(state, other) * _SQUARE_FT > self._CACKLE_RANGE_FT: continue other.active_buffs[:] = [ ActiveBuff( modifiers=buff.modifiers, rounds_left=buff.rounds_left + 1, source=buff.source, ) if buff.source.startswith("hex: evil_eye") else buff for buff in other.active_buffs ] extended += sum( 1 for buff in other.active_buffs if buff.source.startswith("hex: evil_eye") ) if extended: self._log( f"round {self._current_round} {state.combatant.id}: cackle extends " f"{extended} evil eye hex(es)" ) else: self._log( f"round {self._current_round} {state.combatant.id}: cackle - no hex " f"to extend" ) def _execute_deed( self, state: CombatantState, target: CombatantState | None, deed_name: str | None ) -> None: """Execute a gunslinger deed (up_close_and_deadly, quick_clear).""" if deed_name not in ("up_close_and_deadly", "quick_clear"): return if deed_name == "up_close_and_deadly": # Up Close and Deadly: +1d6 damage within 30ft if target is None or not target.active: return dist_ft = self.creature_distance(state, target) * _SQUARE_FT if dist_ft > _UP_CLOSE_RANGE_FT: self._log( f"round {self._current_round} {state.combatant.id}: deed Up Close " f"and Deadly - target out of range" ) return if not state.spend_resource("grit", 1): self._log( f"round {self._current_round} {state.combatant.id}: deed failed (no grit)" ) return # Apply +1d6 damage to next attack this round state.deed_up_close_and_deadly = True self._log( f"round {self._current_round} {state.combatant.id}: " f"deed Up Close and Deadly activated (+1d6 dmg within 30ft)" ) elif deed_name == "quick_clear": # PF1e (Ultimate Combat, Deeds — Quick Clear): "As a standard # action, you can spend 1 point from your grit pool to remove the # broken condition from a single firearm you are holding, as long # as that condition was gained by a firearm misfire." The move # variant (clear ALL misfired firearms as part of a move action) # is not modeled: the deed always targets one firearm. broken_firearm = next( ( weapon.id for weapon in state.combatant.attacks if weapon.firearm and weapon.id in state.weapon_broken ), None, ) if broken_firearm is None: self._log( f"round {self._current_round} {state.combatant.id}: " f"deed Quick Clear - no misfire to clear" ) return if not state.spend_resource("grit", 1): self._log( f"round {self._current_round} {state.combatant.id}: deed failed (no grit)" ) return state.weapon_broken.discard(broken_firearm) self._log( f"round {self._current_round} {state.combatant.id}: " f"deed Quick Clear - {broken_firearm} cleared" ) def _execute_spell_combat( self, state: CombatantState, target: CombatantState | None, spell_name: str | None ) -> None: """Execute Spell Combat (cast spell + full attack in same round). PF1e (CRB, Magus Spell Combat): as a full-round action the magus casts a standard-action spell and makes all of his melee attacks at -2. The penalty rides the full-attack chain to every swing and crit confirm, and — "any attack roll made as part of this spell also takes this penalty" — the spell's own melee/ranged touch attack too. Simplification: the spell may target any resolved enemy (the RAW spellcasting-as-move restriction and arcane pool cost are not modeled). Spell Combat requires a casting time of 1 standard action; the policy only proposes such spells, and a one-round spell would simply resolve on the caster's next turn via ``PendingCast``. """ if target is None or not target.active or spell_name is None: return if not state.spend_resource("arcane_pool", 1): # Arcane pool not tracked yet, allow for now pass # Cast the spell — PF1e (CRB, Magus Spell Combat): "any attack roll # made as part of this spell also takes this penalty", so the -2 rides # the spell's touch attack too. self._cast_spell( state, target, spell_name, attack_penalty=_SPELL_COMBAT_ATTACK_PENALTY ) # Then make a full attack at -2 (Spell Combat penalty) self._execute_attack( state, target, "full_attack", attack_penalty=_SPELL_COMBAT_ATTACK_PENALTY ) def _execute_inspire_courage( self, state: CombatantState, target: CombatantState | None ) -> None: """Execute Inspire Courage (30ft radius, costs bardic_rounds). PF1e (CRB, Bardic Performance — Inspire Courage): +1 competence bonus on attack rolls and weapon damage rolls, and a +1 morale bonus on saving throws against charm and fear effects. Simplifications: the scaling (+1 per four bard levels) is not modeled (flat +1), and the engine has no "charm" descriptor, so mind_affecting stands in for it (documented over-coverage: compulsion effects also count). The save bonus is carried by two conditional modifiers (fear / mind_affecting); a save with both descriptors still totals +1 because morale does not stack with itself. """ if not state.spend_resource("bardic_rounds", 1): self._log( f"round {self._current_round} {state.combatant.id}: inspire courage " f"failed (no bardic rounds)" ) return if target is None: target = state # Apply to all allies within 30ft (6 cells) radius_cells = 30 // _SQUARE_FT affected: list[CombatantState] = [] for state_other in self._states: if not state_other.active: continue if state_other.side == state.side: dist = self.creature_distance(state, state_other) if dist <= radius_cells: affected.append(state_other) if not affected: self._log( f"round {self._current_round} {state.combatant.id}: inspire courage - " f"no allies in range" ) return # Apply +1 competence attack/damage and +1 morale vs charm/fear saves for ally in affected: ally.active_buffs.append( ActiveBuff( modifiers=( StatModifier(target="attack", value=1, bonus_type="competence"), StatModifier(target="damage", value=1, bonus_type="competence"), StatModifier( target="will", value=1, bonus_type="morale", condition="fear", ), StatModifier( target="will", value=1, bonus_type="morale", condition="mind_affecting", ), ), rounds_left=1, source=f"inspire courage by {state.combatant.id}", ) ) self._log( f"round {self._current_round} {state.combatant.id}: " f"inspire courage +1 competence attack/damage, +1 morale vs " f"charm/fear saves to {len(affected)} allies" ) def _execute_attack( self, state: CombatantState, target: CombatantState | None, kind: str, weapon_id: str | None = None, *, attack_penalty: int = 0, ) -> ActionResult | None: """Execute an attack / full_attack (U5d: optional explicit weapon). With ``weapon_id`` the attack is made with THAT weapon; a choice that is unknown, broken, stolen, or unable to strike the target is logged and rejected with ``weapon_unavailable`` (no attack is made). Without it the ``weapon_for`` auto-selection is unchanged. ``attack_penalty`` is applied to every roll of the action (Spell Combat's -2). Returns an explicit rejection, or None when the helper ran (or silently no-oped) and the caller reports ok. """ if self.has_condition_flag(state, "cant_attack"): self._log(f"round {self._current_round} {state.combatant.id}: wait (cannot attack)") return None if target is None: return None chosen: AttackSpec | None = None if weapon_id is not None: chosen = self._chosen_weapon(state, target, weapon_id) if chosen is None: self._log( f"round {self._current_round} {state.combatant.id}: " f"{kind} rejected ({_REASON_WEAPON_UNAVAILABLE})" ) return ActionResult( kind=kind, ok=False, reason=_REASON_WEAPON_UNAVAILABLE ) if kind == "attack": weapon = chosen if chosen is not None else self.weapon_for(state, target) if weapon is None: return None self._single_attack(state, target, weapon) elif kind == "full_attack": if chosen is not None: self._full_attack_weapon( state, target, chosen, attack_penalty=attack_penalty ) else: self._full_attack(state, target, attack_penalty=attack_penalty) return None def _chosen_weapon( self, attacker: CombatantState, target: CombatantState, weapon_id: str ) -> AttackSpec | None: """The usable weapon ``weapon_id``, if it can strike ``target`` (U5d). None when the id is unknown or the weapon is broken/stolen (not in ``usable_attacks()``), or when it cannot reach the target / lacks line of effect. """ for weapon in attacker.usable_attacks(): if weapon.id == weapon_id: if self._weapon_usable_against(attacker, target, weapon): return weapon return None return None def _execute_charge(self, state: CombatantState, target: CombatantState | None) -> None: if target is None or not target.active: return if self.has_condition_flag(state, "cant_charge"): self._log(f"round {self._current_round} {state.combatant.id}: wait (cannot charge)") return result = self.can_charge(state, target) if result is None or result.weapon is None: return self._charge(state, target, result.weapon) def _casting_ability_mod(self, caster: CombatantState) -> int: """Modifier of the casting ability (the one used for bonus spells).""" ability_name = caster.combatant.casting_ability.lower() ability_score = getattr(caster.combatant.abilities, f"{ability_name}_score") return (ability_score - 10) // 2 def _spell_dc( self, caster: CombatantState, spell: SpellSpec, *, slot_level: int | None = None, metamagic: tuple[str, ...] = (), ) -> int: """Spell save DC: 10 + spell level + casting ability modifier. PF1e (SRD, Metamagic Feats): a metamagic spell occupies a higher slot but its save DC uses the spell's BASE level - "An empowered spell uses up a spell slot two levels higher than the spell's actual level" while "Saving throws and opposed rolls are not affected" (feats/empower-spell.md). The one exception is Heighten Spell: "Unlike other metamagic feats, Heighten Spell actually increases the effective level of the spell [...] All effects dependent on spell level (such as saving throw DCs [...]) are calculated according to the heightened level" (feats/heighten-spell.md) - so ``slot_level`` (the level the spell is cast at) feeds the DC only when Heighten Spell is applied. """ if "Heighten Spell" in metamagic and slot_level is not None: return 10 + slot_level + self._casting_ability_mod(caster) return 10 + spell.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, "harmless": False, "area": None, "cast_time": "standard", "components": (), } 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, "harmless": spell.harmless, "area": _spell_area_dict(spell), "cast_time": spell.cast_time, "components": tuple(sorted(spell.components)), } def _cast_spell( self, caster: CombatantState, target: CombatantState | None, spell_name: str, *, center_pos: tuple[int, int] | None = None, metamagic: tuple[str, ...] = (), cast_defensively: bool = False, attack_penalty: int = 0, ) -> ActionResult | None: """Cast ``spell_name``. ``center_pos`` (U7b) centers a top-level ``AreaEffect`` on that cell instead of the target creature's position. It is honoured only for spells carrying an area; every other effect still resolves on the target. A cast with no resolved target requires ``center_pos`` and an area effect — the primary save is skipped and each creature in the area rolls its own. ``metamagic`` (B7) lists the feats applied to THIS cast (only feats the caster knows apply, and the spell then occupies — and must have — a slot of base level + adjustment). ``cast_defensively`` (B24) skips the casting AoO for a concentration check. ``attack_penalty`` folds into this cast's touch attack rolls (PF1e CRB Magus Spell Combat: "any attack roll made as part of this spell also takes this penalty"). Returns an explicit rejection (``no_slot``) when the required slot is unavailable; None means the cast proceeded through the gates. """ spell = self._spell_registry.get(spell_name) if spell is None: self._log( f"round {self._current_round} {caster.combatant.id}: " f"cast {spell_name} -> UNKNOWN SPELL" ) return None # PF1e (CRB, Metamagic Feats): metamagic is chosen per cast and only # the feats the caster actually knows can be applied (B7). known = set(caster.combatant.metamagic_feats) applied = tuple(f for f in metamagic if f in known) used_level = spell.level + sum( _METAMAGIC_SLOT_ADJUST.get(f, 0) for f in applied ) has_area = any(isinstance(e, AreaEffect) for e in spell.effects) if self._spell_cast_rejected( caster, target, spell, spell_name, center_pos, has_area=has_area ): return None # PF1e (CRB): a caster cannot cast without an available slot of the # spell's level (metamagic-inflated, B7); cantrips (level 0) are cast # freely. The slot is spent up-front — a later failed concentration # check or missed touch attack still burns it. if used_level > 0 and not caster.spend_spell_slot(used_level): self._log( f"round {self._current_round} {caster.combatant.id}: " f"cast {spell_name} -> NO SPELL SLOT (level {used_level})" ) return ActionResult( kind="cast_spell", ok=False, reason="no_slot", detail=f"level {used_level}", ) # PF1e (SRD "Holding the Charge"): "If you cast another spell, the # touch spell dissipates." if caster.held_charge is not None: self._log( f"round {self._current_round} {caster.combatant.id}: " f"held charge of {caster.held_charge.spell_name} dissipates " f"(casting {spell_name})" ) caster.held_charge = None # PF1e (CRB, Concentration, B24): grappled/defensive/threatened casts # must clear their interruption gate before the spell is delivered. # PF1e (SRD Cast a Spell - Components): then the component failure # rolls (deafened + verbal, arcane + somatic + armor), made during # the cast itself - the slot is already spent, mirroring the # concentration-fizzle path. Each d100 is rolled ONLY when its # condition applies, so a non-deafened or unarmored caster keeps a # bit-identical RNG stream. interrupted = self._spell_cast_interrupted( caster, used_level, cast_defensively=cast_defensively ) if interrupted or self._spell_component_failure(caster, spell, spell_name): return None if spell.cast_time == "one_round": # PF1e (SRD "Casting Time"): "A spell that takes 1 round to cast # is a full-round action. It comes into effect just before the # beginning of your turn in the round after you began casting the # spell." The slot is spent and the start-of-casting gates have # run; delivery (touch attack, SR, saves, effects — "You make all # pertinent decisions about a spell [...] when the spell comes # into effect") is deferred to _resolve_pending_cast. caster.pending_cast = PendingCast( spell_name=spell_name, target_id=target.combatant.id if target is not None else None, center_pos=center_pos, metamagic=applied, slot_level=used_level, cast_defensively=cast_defensively, hp_at_cast=caster.hp, ) self._log( f"round {self._current_round} {caster.combatant.id}: " f"begins casting {spell_name} (1 round)" ) return None # PF1e (CRB): hostile touch delivery needs a melee touch attack (B14) # and spell resistance can still turn the spell away. A confirmed # touch crit doubles the spell's damage (CRB "Range — Touch"). self._deliver_cast( caster, target, spell, spell_name, center_pos, metamagic=applied, slot_level=used_level, attack_penalty=attack_penalty, ) return None def _deliver_cast( self, caster: CombatantState, target: CombatantState | None, spell: SpellSpec, spell_name: str, center_pos: tuple[int, int] | None, *, metamagic: tuple[str, ...], slot_level: int, attack_penalty: int = 0, ) -> None: """Post-gate delivery: touch attack, spell resistance, then effects. Shared by the immediate cast path and the resolution of a pending one-round cast (SRD "Casting Time"). """ has_area = any(isinstance(e, AreaEffect) for e in spell.effects) blocked, delivery_crit = self._spell_delivery_blocked( caster, target, spell, spell_name, has_area=has_area, attack_penalty=attack_penalty, metamagic=metamagic, slot_level=slot_level, ) if blocked: return self._apply_and_log_cast( caster, target, spell, spell_name, center_pos, metamagic=metamagic, slot_level=slot_level, spell_crit=delivery_crit, attack_penalty=attack_penalty, ) def _spell_cast_interrupted( self, caster: CombatantState, spell_level: int, *, cast_defensively: bool ) -> bool: """Pre-delivery interruption gate (B24). PF1e (CRB, Concentration): while grappled the caster must pass DC 10 + grappler's CMB + spell level to cast at all; casting defensively forgoes the provocation but requires DC 15 + 2x spell level (Combat Casting's +4 applies to both); otherwise casting in a threatened square provokes the AoO (with the injured-casting check). True = the spell is lost (the slot is already spent). """ if caster.grappled_with is not None or cast_defensively: if not self._check_concentration(caster, spell_level, 0): dc = self._concentration_dc(caster, spell_level, 0) situation = ( "while grappled" if caster.grappled_with is not None else "defensively" ) self._log( f"round {self._current_round} {caster.combatant.id}: " f"casting {situation}: concentration failed (DC {dc}), spell lost" ) return True return False if self._has_condition_named(caster, "entangled"): # PF1e (SRD Table 9-1 Concentration Check DCs): "Entangled while # casting" requires a DC 15 + spell level check or the spell is # lost. Combat Casting's +4 does NOT apply: the feat covers only # "casting on the defensive or while grappled" (SRD # feats/combat-casting.md). dc = self._entangled_concentration_dc(spell_level) if not self._check_concentration( caster, spell_level, 0, dc_override=dc, combat_casting_applies=False, ): self._log( f"round {self._current_round} {caster.combatant.id}: " f"casting while entangled: concentration failed (DC {dc}), " f"spell lost" ) return True return self._check_casting_aoo(caster, spell_level) def _spell_component_failure( self, caster: CombatantState, spell: SpellSpec, spell_name: str ) -> bool: """Component failure rolls, made during the cast (SRD "Cast a Spell - Spell Components"). - Deafened + verbal: "A spellcaster who has been deafened has a 20% chance to spoil any spell he tries to cast if that spell has a verbal component" (rules/prpg-core-rulebook/cast-a-spell.md). - Arcane + somatic + armor: "Armor restricts the complicated gestures required while casting any spell that has a somatic component" (rules/prpg-core-rulebook/arcane-spells-and-armor.md) - divine casters (``arcane_caster`` false) never roll. Each d100 is rolled ONLY when its condition applies, so a non-deafened, unarmored or divine caster keeps a bit-identical RNG stream. Convention mirrors the concealment roll: failure on d100 <= the percent. True = the spell is spoiled (logged; the slot stays spent), False = the cast survives every applicable roll. """ if "V" in spell.components and self._has_condition_named( caster, "deafened" ): pct = self._rng.d100() if pct <= _DEAFENED_VERBAL_FAILURE_PCT: self._log( f"round {self._current_round} {caster.combatant.id}: " f"casting failed (deafened): d100({pct}) <= " f"{_DEAFENED_VERBAL_FAILURE_PCT}% - {spell_name} lost" ) return True asf = caster.combatant.armor_spell_failure if "S" in spell.components and caster.combatant.arcane_caster and asf > 0: pct = self._rng.d100() if pct <= asf: self._log( f"round {self._current_round} {caster.combatant.id}: " f"arcane spell failure: d100({pct}) <= {asf}% - " f"{spell_name} lost" ) return True return False def _spell_delivery_blocked( self, caster: CombatantState, target: CombatantState | None, spell: SpellSpec, spell_name: str, *, has_area: bool, attack_penalty: int = 0, metamagic: tuple[str, ...] = (), slot_level: int | None = None, ) -> tuple[bool, bool]: """Delivery gates: a hostile touch spell must land its melee touch attack (B14) and a hostile ranged touch spell (ray) must land its ranged touch attack (cover, concealment and crits included), then spell resistance must be overcome. Returns ``(blocked, crit)``: True blocked = no effect; ``crit`` is True when a damage-dealing touch delivery was confirmed on a natural 20 (the spell's damage dice are then doubled). A MISSED melee touch does not expend the spell - SRD "Holding the Charge": "you can hold the charge indefinitely. You can continue to make touch attacks round after round" - so the caster keeps a HeldCharge (the slot stays spent). A missed ranged touch IS expended (SRD: "ranged touch attacks cannot be held until a later turn").""" crit = False deals_damage = any(isinstance(e, DamageEffect) for e in spell.effects) # PF1e multi-projectile rays (Scorching Ray): each projectile rolls its # own ranged touch attack, resolved per-projectile in # _apply_damage_effect, so the single delivery attack is skipped here. per_projectile_attack = _spell_declares_projectiles(spell) if ( target is not None and spell.range == "touch" and target.side != caster.side ): hit, crit = self._spell_touch_attack_hits( caster, target, attack_penalty=attack_penalty, deals_damage=deals_damage, ) if not hit: caster.held_charge = HeldCharge( spell_name=spell_name, target_id=target.combatant.id, metamagic=metamagic, slot_level=spell.level if slot_level is None else slot_level, ) self._log( f"round {self._current_round} {caster.combatant.id}: " f"holds the charge of {spell_name}" ) return True, False if ( target is not None and spell.requires_ranged_touch and not has_area and not per_projectile_attack and target.side != caster.side ): hit, ranged_crit = self._spell_ranged_touch_attack_hits( caster, target, attack_penalty=attack_penalty, deals_damage=deals_damage, ) if not hit: return True, False crit = ranged_crit if ( target is not None and not has_area and spell.spell_resistance and not self._check_spell_resistance(caster, target) ): return True, False return False, crit def _spell_touch_attack_hits( self, caster: CombatantState, target: CombatantState, *, attack_penalty: int = 0, deals_damage: bool = False, ) -> tuple[bool, bool]: """Melee touch attack to deliver a touch-range spell (B14). PF1e (CRB): d20 + caster's melee attack bonus vs the target's touch AC, PLUS the target's cover bonus (a touch attack is an attack roll — CRB "Cover") and subject to the target's concealment miss chance (CRB "Concealment"); a spell that deals damage threatens on a natural 20 and deals double damage on a successful confirmation (CRB "Cast a Spell — Touch Attacks": "You can score critical hits with either type of attack as long as the spell deals damage"; CRB "Range — Touch"). ``attack_penalty`` folds into the bonus (Spell Combat: "any attack roll made as part of this spell also takes this penalty"). Engine simplification (documented): the bonus is BAB + Strength modifier — Weapon Finesse and other situational touch bonuses are not modeled; natural 20 hits and natural 1 misses per the engine's attack-roll convention. The touch AC reuses the resolve_attack denies-Dex path (flat-footed/running target, invisible attacker). Returns ``(hit, crit)``. """ roll = self._rng.d20() bonus = caster.combatant.bab + caster.combatant.abilities.str_mod + attack_penalty denies_dex = ( self.has_condition_flag(target, "denies_dex_to_ac") or self._running_denies_dex(target) or self._has_condition_named(caster, "invisible") ) if denies_dex: ac = target.combatant.ac.touch - max(0, target.combatant.abilities.dex_mod) else: ac = target.combatant.ac.touch ac += self._attack_cover_bonus(caster, target, ranged=False) return self._resolve_touch_delivery( caster, target, label="touch attack", roll=roll, bonus=bonus, ac=ac, deals_damage=deals_damage, ) def _spell_ranged_touch_attack_hits( self, caster: CombatantState, target: CombatantState, *, attack_penalty: int = 0, deals_damage: bool = False, ) -> tuple[bool, bool]: """Ranged touch attack to deliver a ray spell. PF1e (CRB, Magic — Ranged Touch Attacks): d20 + caster's ranged attack bonus vs the target's touch AC, PLUS the target's ranged cover bonus (terrain + soft cover, CRB "Cover") and subject to the concealment miss chance (CRB "Concealment"); a ray that deals damage can score a critical hit (CRB "Cast a Spell — Touch Attacks"). The spell's range is a single absolute value so there is no range-increment penalty. ``attack_penalty`` folds into the bonus (Spell Combat: "any attack roll made as part of this spell also takes this penalty"). Engine simplification (documented): the bonus is BAB + Dexterity modifier — size modifier, Weapon Finesse and other situational bonuses are not modeled; natural 20 hits and natural 1 misses per the engine's attack-roll convention. The touch AC reuses the resolve_attack denies-Dex path (flat-footed/running target, invisible attacker). Returns ``(hit, crit)``. """ roll = self._rng.d20() bonus = caster.combatant.bab + caster.combatant.abilities.dex_mod + attack_penalty denies_dex = ( self.has_condition_flag(target, "denies_dex_to_ac") or self._running_denies_dex(target) or self._has_condition_named(caster, "invisible") ) if denies_dex: ac = target.combatant.ac.touch - max(0, target.combatant.abilities.dex_mod) else: ac = target.combatant.ac.touch ac += self._attack_cover_bonus(caster, target, ranged=True) return self._resolve_touch_delivery( caster, target, label="ranged touch attack", roll=roll, bonus=bonus, ac=ac, deals_damage=deals_damage, ) def _resolve_touch_delivery( self, caster: CombatantState, target: CombatantState, *, label: str, roll: int, bonus: int, ac: int, deals_damage: bool, ) -> tuple[bool, bool]: """Finish a touch-attack roll: hit check, crit threat/confirm (only when the spell deals damage, CRB "Cast a Spell — Touch Attacks"), then the concealment miss chance (a blinded caster faces total concealment, same max(target, 50) convention as _resolve_hit_damage).""" hit = roll == _NATURAL_TWENTY or (roll != _NATURAL_ONE and roll + bonus >= ac) self._log( f"round {self._current_round} {caster.combatant.id}: {label} vs " f"{target.combatant.id} d20={roll}+{bonus}={roll + bonus} AC {ac} " f"-> {'HIT' if hit else 'MISS'}" ) if not hit: return False, False crit = False if deals_damage and roll == _NATURAL_TWENTY: confirm = self._rng.d20() crit = confirm == _NATURAL_TWENTY or ( confirm != _NATURAL_ONE and confirm + bonus >= ac ) self._log( f"round {self._current_round} {caster.combatant.id}: {label} " f"crit confirm d20={confirm}+{bonus}={confirm + bonus} AC {ac} " f"-> {'CRIT' if crit else 'no crit'}" ) concealment = self._concealment_chance(target) blinded = self._has_condition_named(caster, "blinded") miss_chance = max(concealment, _TOTAL_CONCEALMENT_PCT) if blinded else concealment if miss_chance > 0: pct = self._rng.d100() missed = pct <= miss_chance source = "attacker blinded" if blinded else "concealment" self._log( f"round {self._current_round} {caster.combatant.id}: " f"{source} vs {target.combatant.id}: " f"d100({pct}) {'<=' if missed else '>'} {miss_chance}% " f"— {'spell lost' if missed else 'spell delivered'}" ) if missed: return False, False return True, crit def _spell_cast_rejected( self, caster: CombatantState, target: CombatantState | None, spell: SpellSpec, spell_name: str, center_pos: tuple[int, int] | None, *, has_area: bool, ) -> bool: """Pre-cast gates: resolvable target (or U7b point cast of an area spell), spell range and line of effect. True = not castable.""" if target is None: point_ok = self._point_cast_castable( caster, spell, spell_name, center_pos, has_area=has_area ) return not point_ok if not target.active and not any( isinstance(e, StabilizeEffect) for e in spell.effects ): # Stabilize (SRD spells/stabilize.md) targets a living creature # at -1 or fewer hit points — an inactive state every other spell # cannot reach, so it alone bypasses the active-target gate. 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 _save_forgone( self, caster: CombatantState, target: CombatantState, spell: SpellSpec ) -> bool: """True when a willing target forgoes the save of a harmless spell. PF1e (SRD "Spell Descriptions — Saving Throw", rules/prpg-core-rulebook/saving-throw.md): "(harmless): The spell is usually beneficial, not harmful, but a targeted creature can attempt a saving throw if it desires" and "Voluntarily Giving up a Saving Throw: A creature can voluntarily forego a saving throw and willingly accept a spell's result". The engine's willingness proxy is the side: self and same-side creatures are willing, so a spell flagged ``harmless`` rolled on them consumes NO save die. Two faithful exceptions keep the roll: a hostile target is not willing (it may attempt the save), and a cure spell is HARMFUL to an undead target (SRD cure-light-wounds.md: it "deals damage to them instead of curing their wounds [...] can attempt a Will save to take half damage"), so an undead never forgoes that save. """ if not spell.harmless: return False if target is not caster and target.side != caster.side: return False return not any( isinstance(effect, HealEffect) and target.combatant.creature_type == "undead" for effect in spell.effects ) def _apply_and_log_cast( self, caster: CombatantState, target: CombatantState | None, spell: SpellSpec, spell_name: str, center_pos: tuple[int, int] | None, *, metamagic: tuple[str, ...] = (), slot_level: int | None = None, spell_crit: bool = False, attack_penalty: int = 0, ) -> None: """Resolve save, apply effects and log a spell cast (post-gates). ``metamagic`` holds the feats chosen for THIS cast (already filtered to known ones, B7); ``slot_level`` is the spell's level in the slot used - it drives the slot cost and the concentration checks, but NOT the save DC: metamagic other than Heighten Spell does not raise the DC (SRD feats/empower-spell.md: "Saving throws and opposed rolls are not affected"; feats/heighten-spell.md: only Heighten Spell "increases the effective level of the spell", and its DC is computed from the heightened level). ``spell_crit`` is a confirmed touch-delivery critical: the spell's damage dice are doubled (CRB "Range — Touch": "deals double damage on a successful critical hit"). ``attack_penalty`` (Spell Combat) folds into each projectile's ranged touch attack for multi-projectile ray spells. """ dc = self._spell_dc(caster, spell, slot_level=slot_level, metamagic=metamagic) save_result = ( self.resolve_save( target, spell.save, dc, save_descriptors=spell_save_descriptors(spell), attacker=caster, ) if spell.save != "none" and target is not None and not _spell_centered_on_caster(spell) and not self._save_forgone(caster, target, spell) else None ) caster_live = self._stats[caster.combatant.id] target_live = ( self._stats[target.combatant.id] if target is not None else caster_live ) # Apply only the metamagic feats chosen for this cast (B7) metamagic_effects = self._apply_metamagic(spell, metamagic) # PF1e multi-projectile rays (Scorching Ray): the delivery gate skipped # its single ranged touch attack, so each projectile rolls its own here # — only against a hostile target (a friendly ray needs no attack). per_projectile_attack = ( spell.requires_ranged_touch and _spell_declares_projectiles(spell) and target is not None and target.side != caster.side ) hp_before = target.hp if target is not None else caster.hp parts = self._apply_spell_effects_from_list( metamagic_effects, target, save_result, caster_live, target_live, caster=caster, spell_name=spell.name, empower="Empower Spell" in metamagic, maximize="Maximize Spell" in metamagic, dc=dc, center_pos=center_pos, save_type=spell.save, crit=spell_crit, ranged_touch=per_projectile_attack, attack_penalty=attack_penalty, spell_resistance=spell.spell_resistance, ) save_str = _save_label(save_result) if target is None and center_pos is not None: self._log( f"round {self._current_round} {caster.combatant.id}: " f"cast {spell_name} at ({center_pos[0]},{center_pos[1]}) " f"DC{dc} {save_str} " f"-> {'; '.join(parts)}" ) elif target is not None: self._log( f"round {self._current_round} {caster.combatant.id}: " f"cast {spell_name} vs {target.combatant.id} " f"DC{dc} {save_str} " f"-> {'; '.join(parts)} " f"({hp_before}->{target.hp})" ) if target.dead: self._log( f"round {self._current_round} {caster.combatant.id}: " f"{target.combatant.id} dead" ) self._check_reactions( ReactionEvent(kind="spell_cast", actor=caster, target=target) ) def _apply_spell_effects( self, spell: SpellSpec, target: CombatantState, save_result: SaveResult | None, caster_live: _LiveStats, target_live: _LiveStats, ) -> list[str]: """Legacy wrapper for compatibility.""" return self._apply_spell_effects_from_list( spell.effects, target, save_result, caster_live, target_live, caster=target, spell_name=spell.name, save_type=spell.save ) def _apply_spell_effects_from_list( self, effects: tuple[SpellEffect, ...], target: CombatantState | None, save_result: SaveResult | None, caster_live: _LiveStats, target_live: _LiveStats, *, caster: CombatantState, spell_name: str, empower: bool = False, maximize: bool = False, dc: int = 15, center_pos: tuple[int, int] | None = None, save_type: SaveType = "none", crit: bool = False, ranged_touch: bool = False, attack_penalty: int = 0, spell_resistance: bool = True, ) -> list[str]: saved = save_result is not None and save_result.success return [ self._apply_single_spell_effect( effect, target, caster_live, target_live, caster=caster, spell_name=spell_name, empower=empower, maximize=maximize, saved=saved, dc=dc, center_pos=center_pos, save_type=save_type, crit=crit, ranged_touch=ranged_touch, attack_penalty=attack_penalty, spell_resistance=spell_resistance, ) for effect in effects ] def _apply_single_spell_effect( self, effect: SpellEffect, target: CombatantState | None, caster_live: _LiveStats, target_live: _LiveStats, caster: CombatantState, *, spell_name: str, empower: bool = False, maximize: bool = False, saved: bool, dc: int = 15, center_pos: tuple[int, int] | None = None, save_type: SaveType = "none", crit: bool = False, ranged_touch: bool = False, attack_penalty: int = 0, spell_resistance: bool = True, ) -> str: if isinstance(effect, AreaEffect): return self._apply_area_effect( effect, target, caster_live, target_live, caster=caster, spell_name=spell_name, saved=saved, empower=empower, maximize=maximize, dc=dc, center_pos=center_pos, save_type=save_type, spell_resistance=spell_resistance, ) if isinstance(effect, SummonEffect): return self._apply_summon_effect(effect, caster, caster_live, spell_name) if target is None: return "" return self._apply_targeted_spell_effect( effect, target, caster_live, target_live, caster=caster, saved=saved, empower=empower, maximize=maximize, spell_name=spell_name, crit=crit, ranged_touch=ranged_touch, attack_penalty=attack_penalty, ) def _apply_targeted_spell_effect( self, effect: ( DamageEffect | ConditionEffect | HealEffect | BuffEffect | PenaltyEffect | OneShotEffect | TempHpEffect | StabilizeEffect ), target: CombatantState, caster_live: _LiveStats, target_live: _LiveStats, *, caster: CombatantState, saved: bool, empower: bool, maximize: bool, spell_name: str, crit: bool = False, ranged_touch: bool = False, attack_penalty: int = 0, ) -> str: if isinstance(effect, DamageEffect): return self._apply_damage_effect( effect, target, caster_live, target_live, saved=saved, empower=empower, maximize=maximize, caster=caster, crit=crit, ranged_touch=ranged_touch, attack_penalty=attack_penalty, spell_name=spell_name, ) if isinstance(effect, ConditionEffect): return self._apply_condition_effect( effect, target, saved=saved, spell_name=spell_name, caster_level=caster.combatant.level, ) if isinstance(effect, HealEffect): return self._apply_heal_effect( effect, target, caster_level=caster.combatant.level, empower=empower, maximize=maximize, saved=saved, healer=caster, caster_live=caster_live, target_live=target_live, ) if isinstance(effect, PenaltyEffect): return self._apply_penalty_effect( effect, target, caster=caster, saved=saved, empower=empower, maximize=maximize, spell_name=spell_name, ) if isinstance(effect, (OneShotEffect, TempHpEffect, StabilizeEffect)): return self._apply_simple_effect( effect, target, saved=saved, spell_name=spell_name, ) return self._apply_buff_effect( effect, target, spell_name=spell_name, caster_level=caster.combatant.level, ) def _apply_simple_effect( self, effect: OneShotEffect | TempHpEffect | StabilizeEffect, target: CombatantState, *, saved: bool, spell_name: str, ) -> str: if isinstance(effect, OneShotEffect): return self._apply_one_shot_effect( effect, target, saved=saved, spell_name=spell_name, ) if isinstance(effect, TempHpEffect): return self._apply_temp_hp_effect(effect, target, saved=saved) return self._apply_stabilize_effect(target, saved=saved) def _apply_stabilize_effect( self, target: CombatantState, *, saved: bool ) -> str: """Stabilize a dying creature (SRD spells/stabilize.md). The spell targets a living creature at -1 or fewer hit points and automatically stabilizes it: stabilized_hp is set to the target's current hp, the same marker a successful Constitution check writes in _process_dying, so the per-round hp loss stops. New damage below that hp clears the marker and the creature resumes dying normally; a creature at hp >= 0 or dead is unaffected. """ if saved: return "stabilize negated" if target.dead or target.hp >= 0: return "stabilize no effect" target.stabilized_hp = target.hp return f"stabilized (hp {target.hp})" def _apply_one_shot_effect( self, effect: OneShotEffect, target: CombatantState, *, saved: bool, spell_name: str ) -> str: """Store a one-shot typed bonus on the target (SRD guidance). A successful save negates it (SRD spells/guidance.md: "Will negates (harmless)"). Same-type charges do not merge: each casting is its own charge, discharged one-per-roll by ``_discharge_one_shot``. """ if saved: return "one-shot negated" target.one_shot_bonuses.append( ActiveOneShot( track=effect.track, value=effect.value, bonus_type=effect.bonus_type, rounds_left=effect.duration_rounds, source=f"spell: {spell_name}", ) ) return ( f"one-shot +{effect.value} {effect.bonus_type} on {effect.track} " f"({effect.duration_rounds}r)" ) def _apply_temp_hp_effect( self, effect: TempHpEffect, target: CombatantState, *, saved: bool ) -> str: """Grant temporary hit points (SRD spells/virtue.md). Temp HP do not stack (CRB "Temporary Hit Points"): the creature keeps the higher amount, ties keep the longer remaining duration. """ if saved: return "temp hp negated" if target.temp_hp > effect.amount or ( target.temp_hp == effect.amount and target.temp_hp_rounds_left >= effect.duration_rounds ): return f"temp hp kept ({target.temp_hp})" target.temp_hp = effect.amount target.temp_hp_rounds_left = effect.duration_rounds return f"temp hp +{effect.amount} ({effect.duration_rounds}r)" def _alignment_gate_class( self, gate: AlignmentGate, target: CombatantState ) -> Literal["immune", "neutral", "full"]: """Classify a target against a spell's alignment gate. PF1e (SRD Shard of Chaos / Spear of Purity): the immune side (chaotic / good) is unaffected, the full side (lawful / evil) takes full damage plus the gate condition on a failed save, and a creature neutral on that axis takes half damage and no condition. The loader guarantees immune/full match the declared axis, so the axis alone selects the flags. """ combatant = target.combatant if gate.axis == "law_chaos": if combatant.is_chaotic: return "immune" if combatant.is_lawful: return "full" else: if combatant.is_good: return "immune" if combatant.is_evil: return "full" return "neutral" def _apply_damage_effect( self, effect: DamageEffect, target: CombatantState, caster_live: _LiveStats, target_live: _LiveStats, *, saved: bool, empower: bool, maximize: bool, caster: CombatantState, crit: bool = False, ranged_touch: bool = False, attack_penalty: int = 0, spell_name: str = "", ) -> str: # PF1e (CRB, Magic — Caster Level): per-level spell damage is resolved # against the caster's level at cast time; the scaled formula then # flows through crits, energy resistance and metamagic unchanged. formula = effect.effective_formula(caster.combatant.level) # PF1e alignment gate (SRD Shard of Chaos / Spear of Purity): the # target's alignment decides how the damage lands. An immune-side # creature is unaffected - no dice are rolled at all. gate = effect.alignment_gate gate_class = None if gate is None else self._alignment_gate_class(gate, target) if gate_class == "immune": return "no effect (alignment immune)" # PF1e multi-projectile spells (SRD Magic Missile: "one missile, plus # one additional missile per two caster levels beyond 1st [...] # maximum of five"; Scorching Ray: "one ray, plus one additional ray # for every four levels beyond 3rd [...] maximum of three"): each # projectile rolls its own damage and, for a ray, its own attack. count = ( effect.projectiles.count_for(caster.combatant.level) if effect.projectiles is not None else 1 ) total = 0 for _ in range(count): dmg = self._resolve_one_projectile( effect, formula, target, caster, saved=saved, empower=empower, maximize=maximize, crit=crit, ranged_touch=ranged_touch, attack_penalty=attack_penalty, gate_neutral=gate_class == "neutral", ) if dmg is None: continue # this ray missed — no damage, no stat/reaction caster_live.damage_dealt += dmg target_live.damage_taken += dmg self._check_reactions( ReactionEvent( kind="damage_taken", actor=caster, target=target, damage=dmg ) ) total += dmg # PF1e: if the target drops mid-sequence the remaining projectiles # are lost (the engine's existing multi-attack convention). if not target.active: break # PF1e recurring damage (S2c, Acid Arrow): a landed hit leaves acid # that keeps burning for rounds_for(caster_level) rounds. Only attached # while the target is still standing (a dead target has no turns). if effect.recurring is not None and target.active: self._attach_recurring_damage(effect.recurring, target, caster, spell_name) # PF1e alignment gate (SRD Shard of Chaos: "A lawful outsider [...] is # slowed (as slow) for 1 round. A successful Will save reduces the # damage to half and negates the slow effect"; Spear of Purity: blinded # for 1 round): the full-side creature that FAILED its save and # actually took damage suffers the gate condition. if ( gate is not None and gate_class == "full" and gate.full_condition is not None and not saved and total > 0 and target.active ): imposed = self._impose_condition( target, gate.full_condition, gate.full_condition_rounds, spell_name ) if imposed: return f"{total} damage, {imposed}" return f"{total} damage" def _resolve_one_projectile( self, effect: DamageEffect, formula: DiceExpr, target: CombatantState, caster: CombatantState, *, saved: bool, empower: bool, maximize: bool, crit: bool, ranged_touch: bool, attack_penalty: int, gate_neutral: bool = False, ) -> int | None: """Resolve one projectile's hit + damage; None when the ray misses. Rolls the projectile's own ranged touch attack (when the spell is a ray), then its own damage dice through maximize / crit / save / empower / energy resistance, and applies the damage to the target. """ proj_crit = crit if ranged_touch: # Each ray rolls its own ranged touch attack (cover, concealment # and critical threat included). hit, proj_crit = self._spell_ranged_touch_attack_hits( caster, target, attack_penalty=attack_penalty, deals_damage=True, ) if not hit: return None raw = formula.roll(self._rng) if maximize: raw = formula.max_value() if proj_crit: # PF1e (CRB "Range — Touch"): a touch/ranged-touch spell that # deals damage threatens on a natural 20 and deals double # damage on a successful critical hit. raw *= 2 if saved and effect.half_on_save: dmg = max(0, raw // 2) elif saved and effect.save_negates: dmg = 0 else: dmg = raw if gate_neutral: # PF1e alignment gate (SRD Shard of Chaos: "This spell deals only # half damage to creatures that are neither lawful nor chaotic"): # the gate halving stacks with the save halving (integer order # immaterial), and the neutral side never suffers the condition. dmg //= 2 if empower: dmg = int(dmg * 1.5) dmg = self._apply_energy_resistance(dmg, frozenset(effect.types), target) self._lose_hp(target, dmg) return dmg def _attach_recurring_damage( self, spec: RecurringDamageSpec, target: CombatantState, caster: CombatantState, spell_name: str, ) -> None: """Add a recurring-damage source to a target that just took the hit.""" rounds = spec.rounds_for(caster.combatant.level) if rounds <= 0: return target.recurring_damage.append( RecurringDamage( formula=spec.formula, types=frozenset(spec.types), rounds_left=rounds, source=f"acid: {spell_name}", ) ) def _apply_condition_effect( self, effect: ConditionEffect, target: CombatantState, *, saved: bool, spell_name: str, caster_level: int = 0, ) -> str: # SRD spells/bleed.md targets "a living creature that has -1 or fewer # hit points" - a healthy target is simply not eligible for the effect. if effect.requires_dying and target.hp >= 0: return "no effect (target not dying)" duration = effect.duration_rounds if effect.duration_rounds_per_level > 0: duration = max(1, caster_level * effect.duration_rounds_per_level) if effect.max_duration_rounds > 0: duration = min(duration, effect.max_duration_rounds) if saved and effect.save_negates: # PF1e partial save (SRD spells/fear.md): a successful save does # not always spare the target - it may impose a weaker condition # (Fear: shaken for 1 round instead of panicked). if effect.save_partial is not None: name, partial_duration = effect.save_partial applied = self._impose_condition(target, name, partial_duration, spell_name) if not applied: return "" return f"{name} ({partial_duration}r) (partial save)" return f"{effect.condition_name} negated" if effect.images is not None: return self._impose_mirror_images( target, effect, duration, spell_name, caster_level ) return self._impose_condition( target, effect.condition_name, duration, spell_name ) def _impose_mirror_images( self, target: CombatantState, effect: ConditionEffect, duration_rounds: int, spell_name: str, caster_level: int, ) -> str: """Impose Mirror Image's concealment with a cast-time image roll. PF1e (SRD spells/mirror-image.md): "1d4 images plus one image per three caster levels (maximum eight images total)". The engine has no figment-destruction track, so the image count is folded into the existing concealment machinery as a dynamic miss chance on the condition instance: an attack that would hit strikes a figment with probability images / (images + 1). """ spec = effect.images if spec is None: return "" base = CONDITIONS_BY_NAME.get(effect.condition_name) if base is None: return "" rolled = spec.dice.roll(self._rng) images = min(spec.max_images, rolled + caster_level // spec.per_levels) if images <= 0: return "0 images" miss_pct = 100 * images // (images + 1) target.active_conditions.append( ActiveCondition( condition=replace(base, miss_chance=miss_pct), rounds_left=duration_rounds, source=f"spell: {spell_name}", ) ) return f"{effect.condition_name} {miss_pct}% ({images} images, {duration_rounds}r)" def _impose_condition( self, target: CombatantState, condition_name: str, duration_rounds: int, spell_name: str, ) -> str: """Append a timed condition to the target; handle the bleeding DoT.""" cond = CONDITIONS_BY_NAME.get(condition_name) if cond is None: return "" target.active_conditions.append( ActiveCondition( condition=cond, rounds_left=duration_rounds, source=f"spell: {spell_name}", ) ) if condition_name == "bleeding": # The bleeding condition's damage-over-time is engine-driven # (CombatantState.bleed_damage, CRB Conditions — bleeding); the # SRD Bleed cantrip (spells/bleed.md) deals 1 bleed damage/round. target.bleed_damage = max(target.bleed_damage, 1) return f"{condition_name} ({duration_rounds}r)" def _bitter_reduced( self, healer: CombatantState, target: CombatantState, amount: int ) -> int: """PF1e (Drawback "Bitter"): "When you receive healing from an ally's class feature, spell, or spell-like ability, reduce the amount of that healing by 1 hit point." Applies only to ally-sourced healing (an ally is another creature on the same side); self-healing is untouched. """ if ( amount > 0 and healer is not target and healer.side == target.side and self._has_feat(target, "Bitter") ): return amount - 1 return amount def _apply_heal_effect( self, effect: HealEffect, target: CombatantState, *, caster_level: int, empower: bool, maximize: bool, saved: bool, healer: CombatantState | None = None, caster_live: _LiveStats | None = None, target_live: _LiveStats | None = None, ) -> str: formula = effect.effective_formula(caster_level) heal = formula.roll(self._rng) if maximize: heal = formula.max_value() if empower: heal = int(heal * 1.5) if target.combatant.creature_type == "undead": # PF1e (SRD "Cure Light Wounds"): "Since undead are powered by # negative energy, this spell deals damage to them instead of # curing their wounds. An undead creature can apply spell # resistance, and can attempt a Will save to take half damage." # SR is resolved upstream (_spell_delivery_blocked); the rolled # save halves here. Cure damage is spell energy: never reduced by # DR, and it does not stop bleeding or stabilize. Bitter reduces # healing, not this damage. dmg = heal // 2 if saved else heal self._lose_hp(target, dmg) if caster_live is not None: caster_live.damage_dealt += dmg if target_live is not None: target_live.damage_taken += dmg return f"{dmg} damage (undead)" if healer is not None: heal = self._bitter_reduced(healer, target, heal) 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 # PF1e (SRD "Nonlethal Damage" - Healing Nonlethal Damage): "When a # spell or ability cures hit point damage, it also removes an equal # amount of nonlethal damage." (The out-of-combat recovery of 1 hp per # hour per level is out of combat scope - documented, not modeled.) if actual > 0: target.nonlethal = max(0, target.nonlethal - actual) if caster_live is not None: caster_live.healing_done += actual self._stop_bleeding(target) return f"+{actual} HP" def _apply_buff_effect( self, effect: BuffEffect, target: CombatantState, *, spell_name: str, caster_level: int, ) -> str: # PF1e caster-level scaling on buff values (SRD shield of faith # "+2 deflection, +1 per six caster levels (maximum +5)"): the flat # extra is added to EVERY modifier of the buff. modifiers = effect.modifiers if effect.scaling_flat is not None: extra = min( effect.scaling_flat.max_amount, (caster_level // effect.scaling_flat.per_levels) * effect.scaling_flat.amount, ) if extra > 0: modifiers = tuple( StatModifier( target=mod.target, value=mod.value + extra, bonus_type=mod.bonus_type, condition=mod.condition, weapon_filter=mod.weapon_filter, ) for mod in modifiers ) target.active_buffs.append( ActiveBuff( modifiers=modifiers, rounds_left=effect.duration_rounds, source=f"spell: {spell_name}", ) ) return f"buff {len(modifiers)} mods ({effect.duration_rounds}r)" def _apply_penalty_effect( self, effect: PenaltyEffect, target: CombatantState, *, caster: CombatantState, saved: bool, empower: bool, maximize: bool, spell_name: str, ) -> str: """Apply a rolled ability penalty (SRD Ray of Enfeeblement). The penalty is rolled (maximized/empowered like spell damage), halved on a successful save, and applied as negative untyped StatModifiers on the listed targets for ``duration_rounds_per_level`` rounds per caster level. A penalty of 0 or less has no effect. """ formula = effect.effective_formula(caster.combatant.level) if maximize: penalty = formula.max_value() else: penalty = formula.roll(self._rng) if empower: penalty = int(penalty * 1.5) if saved and effect.half_on_save: penalty //= 2 if penalty <= 0: return "no penalty" duration = max(1, caster.combatant.level * effect.duration_rounds_per_level) target.active_buffs.append( ActiveBuff( modifiers=tuple( StatModifier(target=t, value=-penalty, bonus_type="") for t in effect.targets ), rounds_left=duration, source=f"spell: {spell_name}", ) ) return f"-{penalty} {'/'.join(effect.targets)} ({duration}r)" def _apply_area_effect( self, effect: AreaEffect, target: CombatantState | None, caster_live: _LiveStats, target_live: _LiveStats, *, caster: CombatantState, spell_name: str, saved: bool, empower: bool = False, maximize: bool = False, dc: int = 15, center_pos: tuple[int, int] | None = None, save_type: SaveType = "none", spell_resistance: bool = True, ) -> str: """Apply an area effect to all creatures within its template. PF1e (CRB, Magic - Aiming a Spell): a burst/spread centers on ``center_pos`` when given (U7b point targeting), otherwise on the resolved target's position; a cone or line starts at the CASTER's square and is aimed at that same resolved cell (the cone "shoots away from you in a quarter-circle in the direction you designate", the line "shoots away from you in a line in the direction you designate"). Every creature whose occupied cells intersect the template is subject to the effect. """ center = ( caster.pos if effect.centered_on_caster else _burst_center(target, center_pos) ) if center is None: return "no targets in area" affected = self._area_creatures(effect, caster, center) if not affected: return "no targets in area" total_damage = 0 total_conditions = 0 total_buffs = 0 # Reflex-save cover is measured from the effect's point of origin: # the burst center, or the caster's square for a cone/line (CRB # "Cover and Reflex Saves": the bonus applies "against attacks that # originate or burst out from a point on the other side of the cover # from you"). origin_cells = ( frozenset({center}) if effect.shape == "burst" else caster.occupied_cells() ) for affected_state in affected: if spell_resistance and not self._check_spell_resistance(caster, affected_state): continue creature_saved = self._area_save( # A caster-centered burst has NO primary save (skipped at the # cast site): the resolved target must roll its own like every # other creature, so it is not special-cased here. affected_state, None if effect.centered_on_caster else target, effect, saved=saved, dc=dc, save_type=save_type, origin_cells=origin_cells, attacker=caster, ) for sub_effect in effect.effects: target_live = self._stats[affected_state.combatant.id] result = self._apply_single_spell_effect( sub_effect, affected_state, caster_live, target_live, caster=caster, spell_name=spell_name, saved=creature_saved, empower=empower, maximize=maximize, ) if "damage" in result: total_damage += int(result.split()[0]) elif "damage" not in result and result: if "negated" not in result and "buff" not in result: total_conditions += 1 elif "buff" in result: total_buffs += 1 return ( f"area: {len(affected)} targets, {total_damage} total damage, " f"{total_conditions} conditions, {total_buffs} buffs" ) def _area_creatures( self, effect: AreaEffect, caster: CombatantState, aim: tuple[int, int] ) -> list[CombatantState]: """Creatures intersecting the template of an area effect. Burst: circle of ``radius_ft`` around the resolved center ``aim``, minus every creature that has no line of effect from that center (PF1e CRB "Aiming a Spell" — Burst: a burst "can't affect creatures with total cover from its point of origin (in other words, its effects don't extend around corners)"; CRB "Line of Effect": a burst "affects only an area, creature, or object to which it has line of effect from its origin"). Cone/line: the template originates at the caster's anchor cell and opens toward ``aim`` (PF1e CRB "Aiming a Spell" — see ``areas.py``); a creature is affected when ANY of its occupied cells lies inside the template. Dying/unconscious (inactive but alive) creatures are caught like any other (SRD aiming-a-spell: an area affects "whatever it catches in its area, including creatures that you can't see"); corpses are not (``occupies_space``). ``effect.targets`` restricts the selection (SRD bless/bane bursts): "allies" keeps only creatures on the caster's side (the caster included — SRD bless "The caster and all allies"), "enemies" keeps only the opposite side, "all" keeps everyone. """ span_cells = effect.radius_ft // _SQUARE_FT if effect.shape == "burst": affected = [ state for state in self._creatures_in_range( aim, span_cells, active_only=False ) if has_line_of_effect_cells( self._grid, frozenset({aim}), state.occupied_cells() ) ] else: origin = caster.pos if effect.shape == "cone": cells = cone_cells( self._grid, origin, aim, span_cells, caster_cells=caster.occupied_cells(), ) else: cells = line_cells( self._grid, origin, aim, span_cells, caster_cells=caster.occupied_cells(), ) affected = [ state for state in self._states if state.occupies_space and state.occupied_cells() & cells ] if effect.targets == "allies": return [s for s in affected if s.side == caster.side] if effect.targets == "enemies": return [s for s in affected if s.side != caster.side] return affected def _creatures_in_range( self, center: tuple[int, int], radius_cells: int, *, active_only: bool = True, ) -> list[CombatantState]: """Return creatures within radius_cells of center. The burst center is a CELL and the radius is counted center-to-center under 5-10-5 - a documented approximation of the SRD's intersection-to-intersection far-edge measurement (see the "BURST MEASUREMENT" note in ``areas.py`` for why a faithful intersection origin is deferred). ``active_only`` (default) keeps only acting creatures; an area spell passes False so its burst also catches dying/unconscious creatures (SRD aiming-a-spell.md: a burst "affects whatever it catches in its area, including creatures that you can't see"). Corpses stay excluded either way (see ``CombatantState.occupies_space``). """ affected: list[CombatantState] = [] for state in self._states: if not (state.active if active_only else state.occupies_space): 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", origin_cells: frozenset[Pos] = frozenset(), attacker: CombatantState | None = 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. ``attacker`` is the caster who created the area effect, threaded to ``resolve_save`` so alignment-gated save bonuses (SRD protection from evil: "+2 resistance bonus on saves ... against ... effects created by evil creatures") can evaluate their condition. PF1e (CRB "Cover and Reflex Saves"): cover grants a Reflex bonus against effects that burst from a point on the other side of the cover - half the AC value (+1 partial / +2 standard / +4 improved); soft cover from creatures grants none (CRB "Soft Cover"). The bonus is measured from the effect's origin to the rolling creature. Documented simplification: the primary target's reused save was rolled on the generic spell path before the area resolved; for a target-centered burst the origin IS its own square (bonus 0), so the reuse only under-protects a cone/line primary standing behind cover from the caster. """ if save_type == "none": return False if affected_state is target: return saved flat_bonus = ( self._reflex_cover_bonus(origin_cells, affected_state) if save_type == "ref" else 0 ) save_result = self.resolve_save( affected_state, save_type, dc, save_descriptors=save_descriptors_for_effects(effect.effects), flat_bonus=flat_bonus, attacker=attacker, ) return save_result.success def _reflex_cover_bonus(self, origin_cells: frozenset[Pos], state: CombatantState) -> int: """Reflex-save cover bonus from an area effect's origin (terrain only). CRB "Cover and Reflex Saves": "Cover grants you a +2 bonus on Reflex saves against attacks that originate or burst out from a point on the other side of the cover from you" - the Reflex bonus is half the AC bonus (partial +2 AC/+1 Ref, standard +4/+2, improved +8/+4). CRB "Soft Cover": creatures "provide no bonus on Reflex saves", so the interposed-creature set is empty here. """ cover = cover_bonus_cells( self._grid, origin_cells, state.occupied_cells(), ranged=True, occupied=frozenset(), ) return cover // 2 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, ConditionEffect): duration = effect.duration_rounds * 2 if has_extend else effect.duration_rounds per_level = ( effect.duration_rounds_per_level * 2 if has_extend else effect.duration_rounds_per_level ) max_rounds = ( effect.max_duration_rounds * 2 if has_extend else effect.max_duration_rounds ) partial = effect.save_partial if partial is not None and has_extend: partial = (partial[0], partial[1] * 2) return ConditionEffect( condition_name=effect.condition_name, duration_rounds=duration, save_negates=effect.save_negates, save_partial=partial, duration_rounds_per_level=per_level, max_duration_rounds=max_rounds, images=effect.images, requires_dying=effect.requires_dying, ) if isinstance(effect, BuffEffect): duration = effect.duration_rounds * 2 if has_extend else effect.duration_rounds return BuffEffect( modifiers=effect.modifiers, duration_rounds=duration, scaling_flat=effect.scaling_flat, ) if isinstance(effect, PenaltyEffect): duration = ( effect.duration_rounds_per_level * 2 if has_extend else effect.duration_rounds_per_level ) return PenaltyEffect( formula=effect.formula, scaling_flat=effect.scaling_flat, targets=effect.targets, duration_rounds_per_level=duration, half_on_save=effect.half_on_save, ) if isinstance(effect, SummonEffect): # PF1e (SRD feats/extend-spell.md): "An extended spell lasts # twice as long as normal. A spell with a duration of # concentration, instantaneous, or permanent is not affected" - # summon monster's "1 round/level (D)" is none of those, so # Extend doubles it (both the fixed and the per-level form). duration = effect.duration_rounds * 2 if has_extend else effect.duration_rounds per_level = ( effect.duration_rounds_per_level * 2 if has_extend else effect.duration_rounds_per_level ) return SummonEffect( creature=effect.creature, duration_rounds=duration, duration_rounds_per_level=per_level, ) if isinstance(effect, AreaEffect): return AreaEffect( shape=effect.shape, radius_ft=effect.radius_ft, effects=tuple( self._metamagic_transform_sub(sub, has_extend=has_extend) for sub in effect.effects ), save_for_half=effect.save_for_half, targets=effect.targets, ) return effect def _disambiguated_summon_id(self, combatant: Combatant) -> Combatant: """Give a summoned creature a unique id, mirroring build_states. The first occurrence of a creature id keeps the bare name; later ones get -2, -3... suffixes. Without this, two summons of the same creature share one _stats slot and their attrition merges (and the second summon resets the first one's counters). """ base = combatant.id count = self._id_counts.get(base, 0) + 1 self._id_counts[base] = count if count == 1: return combatant return combatant.model_copy(update={"id": f"{base}-{count}"}) def _apply_summon_effect( self, effect: SummonEffect, state: CombatantState, _caster_live: _LiveStats, _spell_name: str, ) -> str: """Apply a summoning effect - create a summoned creature.""" # PF1e (SRD spells/summon-monster-1.md): "Duration 1 round/level (D)" # - a per-level duration resolves against the caster's level at cast # time, mirroring the ConditionEffect convention; a fixed duration is # the pre-conformance fallback. duration = effect.duration_rounds if effect.duration_rounds_per_level > 0: duration = max( 1, state.combatant.level * effect.duration_rounds_per_level ) # Load the summoned creature try: summoned_combatant = load_combatant(Path(effect.creature)) except CombatantLoadError: return f"summon failed: could not load {effect.creature}" summoned_combatant = self._disambiguated_summon_id(summoned_combatant) # 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 = duration 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} ({duration}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 weapon.secondary: # PF1e (CRB "Natural Attacks"): secondary natural attacks are at # base attack bonus -5; the Multiattack feat reduces this to -2. penalty = 2 if self._has_feat(attacker, "Multiattack") else 5 base = weapon.attack_bonus if override is None else override override = base - penalty 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 += _hit_damage_segment(result, 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)" if result.splash: line += f" + splash {result.splash}" 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 if weapon.natural: # PF1e (CRB "Natural Attacks"): "You do not receive additional # natural attacks for a high base attack bonus." One swing per # natural weapon, at its own attack bonus. 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 # SRD "Take 5-Foot Step": "You can only take a 5-foot-step if your # movement isn't hampered by difficult terrain or darkness." — a step # out of OR into a difficult cell is blocked (darkness not modeled). if self._footprint_touches_difficult(self._footprint_at(state.pos, state)): self._log(f"round {self._current_round} {state.combatant.id}: wait (cannot step)") return path = self._move_path(state, target) start = state.pos if self._footprint_touches_difficult(self._footprint_at(path[0], state)): self._log(f"round {self._current_round} {state.combatant.id}: wait (cannot step)") return 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) # SRD "Take 5-Foot Step": "You can only take a 5-foot-step if your # movement isn't hampered by difficult terrain or darkness." — a step # out of OR into a difficult cell is blocked (darkness not modeled). step_clear = not ( self._footprint_touches_difficult(self._footprint_at(state.pos, state)) or self._footprint_touches_difficult(self._footprint_at(destination, state)) ) if not adjacent or not free or not corner_clear or not step_clear: self._log( f"round {self._current_round} {state.combatant.id}: " f"{kind} rejected ({_REASON_UNREACHABLE_DESTINATION})" ) return ActionResult( kind=kind, ok=False, reason=_REASON_UNREACHABLE_DESTINATION ) start = state.pos state.pos = destination state.moved_this_turn = True self._log( f"round {self._current_round} {state.combatant.id}: " f"5ft step ({start[0]},{start[1]})->({destination[0]},{destination[1]})" ) return ActionResult(kind=kind, ok=True, reason=_REASON_OK) def _move(self, state: CombatantState, target: CombatantState) -> None: if state.moved_this_turn: return if self.has_condition_flag(state, "cant_move"): self._log(f"round {self._current_round} {state.combatant.id}: wait (cannot move)") return path = self._move_path(state, target, friendly_passable=True) start = state.pos actual_path = self._walk_steps(state, path) if actual_path: state.moved_this_turn = True coords = "->".join(f"({p[0]},{p[1]})" for p in (start, *actual_path)) self._log(f"round {self._current_round} {state.combatant.id}: move {coords}") def _move_to(self, state: CombatantState, destination: Pos) -> ActionResult: """Move action to an explicit cell within speed (U5c). Validated against the Dijkstra cost field from the combatant's position under its speed budget; walked step-by-step with the same AoO machinery as the auto-pathed move. """ kind = "move" if state.moved_this_turn: return ActionResult(kind=kind, ok=True, reason=_REASON_OK) if self.has_condition_flag(state, "cant_move"): self._log(f"round {self._current_round} {state.combatant.id}: wait (cannot move)") return ActionResult(kind=kind, ok=True, reason=_REASON_OK) if not self._destination_reachable(state, destination, friendly_passable=True): self._log( f"round {self._current_round} {state.combatant.id}: " f"{kind} rejected ({_REASON_UNREACHABLE_DESTINATION})" ) return ActionResult( kind=kind, ok=False, reason=_REASON_UNREACHABLE_DESTINATION ) path = self._descend_path(state, destination, friendly_passable=True) self._walk_steps(state, path) state.moved_this_turn = True self._log( f"round {self._current_round} {state.combatant.id}: " f"move -> ({destination[0]},{destination[1]})" ) return ActionResult(kind=kind, ok=True, reason=_REASON_OK) def _destination_reachable( self, state: CombatantState, destination: Pos, *, speed_factor: int = 1, friendly_passable: bool = False, avoid_difficult: bool = False, ) -> bool: """True when destination is passable, unoccupied and within this speed. ``speed_factor`` widens the budget (run: 4x speed, U5e). ``friendly_passable`` routes the cost field around allies' squares (move/run may cross them, SRD Movement); the destination itself must still be completely free. ``avoid_difficult`` removes difficult cells from the cost field, mirroring the descent constraint of a run (CRB: "You can't run or charge across difficult terrain") so the reachability verdict and the walked path agree. """ speed_cells = int( _speed_ft(state) // _SQUARE_FT * self._get_speed_mult(state) * speed_factor ) if speed_cells <= 0: return destination == state.pos if not self._cells_free(self._footprint_at(destination, state), state): return False if avoid_difficult and self._footprint_touches_difficult( self._footprint_at(destination, state) ): return False blocked = self._blocked_hostile(state) if friendly_passable else self._blocked(state) if avoid_difficult: blocked = blocked | self._difficult_cells() field = self._grid.reachable(state.pos, speed_cells, blocked) return destination in field def _difficult_cells(self) -> frozenset[Pos]: """Every passable cell on the grid whose terrain costs more than one.""" return frozenset( (row, col) for row in range(self._grid.height) for col in range(self._grid.width) if self._is_difficult_cell((row, col)) ) def _walk_steps( self, state: CombatantState, path: list[Pos] ) -> list[Pos]: """Walk a precomputed path cell by cell, resolving AoOs and reactions.""" actual_path: list[Pos] = [] for step in path: if self._check_move_aoo(state): break if not state.active: break # SRD "Moving Through a Square": "Creatures moving through squares # occupied by other creatures provoke attacks of opportunity from # those creatures." — resolved BEFORE the step is committed, so a # mover dropped by the entry AoO never lands on the square. if self._check_enter_aoo(state, step): 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 diagonals_used = 0 while budget > 0: best = self._best_move_step( state, current, to_target, budget, diagonals_used, avoid_difficult=avoid_difficult, friendly_passable=friendly_passable, ) if best is None: break _, step_cost, nxt = best # PF1e 5-10-5: the diagonal cost alternates 1-2-1-2, so the # running diagonal count must follow the walk (grid.reachable # already tracks it; the greedy descent must too). if current[0] != nxt[0] and current[1] != nxt[1]: diagonals_used += 1 current = nxt path.append(current) budget -= step_cost if friendly_passable: while path and not self._cells_free(self._footprint_at(path[-1], state), state): path.pop() return path def _run_line_path(self, state: CombatantState, goal: Pos) -> list[Pos]: """Straight-line run path toward ``goal``, truncated at the first obstacle. PF1e (SRD "Full-Round Actions — Run"): "When you run, you can move up to four times your speed in a straight line" — a run CANNOT turn a corner the way a Dijkstra ``move`` does. The path is the Bresenham line from the combatant's anchor toward ``goal``, walked under the 4x speed budget and stopped at the first obstacle, mirroring the charge's straight-line clear-path logic (CRB "Charge"): impassable terrain, a difficult-terrain cell (CRB "Difficult Terrain": "You can't run or charge across difficult terrain"), a diagonal step past a blocked corner (SRD "Measuring Distance": "You can't move diagonally past a corner"), or a blocking creature. A hostile creature (including the target itself) halts the walk one cell short, so an auto-directed run ends adjacent to the target — except one the mover may pass through (three size categories apart, SRD "Moving Through a Square"), which is crossed with an entry AoO and never ended on. Friendly squares are still crossed (SRD Movement, conformance L5) but never ended on — the tail is trimmed like ``_descend_path``. """ speed_mult = self._get_speed_mult(state) budget = int( _speed_ft(state) // _SQUARE_FT * speed_mult * _RUN_SPEED_FACTOR ) if budget <= 0: return [] path: list[Pos] = [] current = state.pos diagonals_used = 0 for nxt in _bresenham_line(state.pos, goal): # PF1e (SRD "Measuring Distance"): "You can't move diagonally # past a corner" - a diagonal step with a blocked flank is # illegal, so a run stops at the corner just like an obstacle # (same rule _best_move_step enforces for the Dijkstra descent). if ( current[0] != nxt[0] and current[1] != nxt[1] and not self._grid.diagonal_allowed(current, nxt) ): break footprint = self._footprint_at(nxt, state) if self._footprint_touches_difficult(footprint): break if not self._cells_walkable(footprint, state): break step = self._grid.step_cost(current, nxt, diagonals_used) if step > budget: break path.append(nxt) if current[0] != nxt[0] and current[1] != nxt[1]: diagonals_used += 1 current = nxt budget -= step while path and not self._cells_free(self._footprint_at(path[-1], state), state): path.pop() return path def _best_move_step( self, state: CombatantState, current: Pos, to_target: dict[Pos, int], budget: int, diagonals_used: 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._footprint_step_cost(state, current, nxt, diagonals_used) 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 a fully free anchor that descends the field is reachable from ``pos``. The free anchor may sit beyond a CHAIN of occupied anchors: crossing a creature three size categories away (SRD "Moving Through a Square") walks through several non-free cells before emerging on the far side, so a one-step lookahead is not enough. The chain must strictly descend the cost field at every hop, so the walk never dead-ends on an occupied cell (movement may not END there). """ seen: set[Pos] = {pos} frontier: list[tuple[Pos, int]] = [(pos, pos_cost)] while frontier: current, current_cost = frontier.pop() row, col = current for d_row, d_col in _STEP_DELTAS: nxt = (row + d_row, col + d_col) footprint = self._footprint_at(nxt, state) cost = self._footprint_cost(footprint, to_target) if cost is None or cost >= current_cost: continue if self._cells_free(footprint, state): return True if nxt not in seen: seen.add(nxt) frontier.append((nxt, cost)) 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 _footprint_step_cost( self, state: CombatantState, frm: Pos, to: Pos, diagonals_used: int ) -> int: """Cost of one anchor step priced by the WHOLE destination footprint. SRD "Movement, Position, and Distance": "If you occupy squares with different kinds of terrain, you can move only as fast as the most difficult terrain you occupy will allow." A 2x2 creature straddling rubble is hampered even when its ANCHOR square is plain floor, so the step is priced by the most difficult square the footprint would cover at ``to`` (the 5-10-5 + elevation formula stays in ``grid.py``). For a 1x1 creature the footprint is the anchor cell, so this is exactly ``grid.step_cost`` - bit-identical to the old anchor-only pricing. """ footprint = self._footprint_at(to, state) hardest = 1 for cell in footprint: if not self._grid.in_bounds(cell): continue cost = self._grid.terrain_at(cell).move_cost if cost is not None and cost > hardest: hardest = cost return self._grid.step_cost_with_terrain(frm, to, diagonals_used, hardest) def _is_difficult_cell(self, pos: Pos) -> bool: """True for a passable cell whose terrain costs more than one square.""" if not self._grid.in_bounds(pos): return False terrain = self._grid.terrain_at(pos) return terrain.move_cost is not None and terrain.move_cost > 1 def _flee_path(self, state: CombatantState, threat: CombatantState) -> list[Pos]: """Movement path away from ``threat``, up to double speed. A staggered/disabled creature taking Restricted Withdraw (SRD "Full-Round Actions": "you can withdraw as a standard action. In this case, you may move up to your speed") gets a single-speed budget instead. Uses the Dijkstra cost field from the threat and greedily picks the step that maximizes distance (cost) from the threat each cell. """ speed_mult = self._get_speed_mult(state) withdraw_mult = 1 if self._is_staggered_limited(state) else 2 max_cells = int( withdraw_mult * (_speed_ft(state) // _SQUARE_FT) * speed_mult ) if max_cells <= 0: return [] blocked = self._blocked(state) from_threat = self._grid.reachable(threat.pos, None, blocked) if self._footprint_cost(state.occupied_cells(), from_threat) is None: return [] path: list[Pos] = [] current = state.pos budget = max_cells diagonals_used = 0 while budget > 0: best = self._best_flee_step( state, current, from_threat, budget, diagonals_used ) if best is None: break _, step_cost, nxt = best if current[0] != nxt[0] and current[1] != nxt[1]: diagonals_used += 1 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, diagonals_used: 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._footprint_step_cost(state, current, nxt, diagonals_used) if step > budget: continue score = -(nxt_cost * 100) + step if best is None or score < best[0]: best = (score, step, nxt) return best def _withdraw(self, state: CombatantState, threat: CombatantState | None) -> None: """Full-round action: move up to 2x speed away from the nearest enemy. Restricted Withdraw (SRD "Full-Round Actions"): a staggered/disabled creature takes this as a standard action and moves up to its speed (single, not double — see ``_flee_path``). The starting square is not threatened — no AoO when leaving it. Subsequent squares provoke AoOs normally (step-by-step, as for move). Like every other movement it is mutually exclusive with movement already spent this turn (``moved_this_turn``). SRD "Full-Round Actions — Withdraw" (full-round-actions.md): "you can't withdraw from combat if you're blinded" — a blinded creature cannot even take the restricted (standard-action) withdraw. """ if state.moved_this_turn: return if threat is None: return if self._has_condition_named(state, "blinded"): self._log( f"round {self._current_round} {state.combatant.id}: " f"withdraw rejected (blinded)" ) 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 a living ally below half HP. ``hp * 2 < hp_max`` is strictly-below-half for every hp_max; the old ``hp < hp_max // 2`` floored the threshold (hp_max 5 -> 2), so a level-1 character at 2/5 hp counted as healthy. Living-only (SRD Channel Energy: heal mode heals living creatures; undead take damage from positive energy, never healing): an undead ally below half must not trigger the heal paths - channel heal would spend the channel without helping it, and a cure spell would hurt it. """ for ally_state in engine.states: below_half = ally_state.hp * 2 < ally_state.combatant.hp_max living = ally_state.combatant.creature_type == "living" if ally_state.side == caster.side and ally_state.active and living and below_half: return ally_state return None def _spell_reaches( engine: CombatEngine, caster: CombatantState, target: CombatantState, spell: SpellSpec, ) -> bool: """True if the spell's range covers caster -> target with line of effect (the same gate `_cast_spell` applies; a proposed cast failing it wastes the whole turn on an OUT OF RANGE / NO LINE OF EFFECT rejection).""" dist_ft = engine.creature_distance(caster, target) * _SQUARE_FT return dist_ft <= spell_range_ft(spell, caster.combatant.level) and ( has_line_of_effect_cells( engine.grid, caster.occupied_cells(), target.occupied_cells() ) ) 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.""" 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 if _spell_reaches(engine, caster, target, spell): return name return None def _find_buff_spell( engine: CombatEngine, spells_known: list[str] ) -> str | None: """Find a buff spell the policy will actually cast. PF1e cantrips (level 0) have no spell slot in the engine's budget (``spell_slots_left`` has no level-0 key) and the policy deliberately never auto-casts them (a cantrip buff would be cast every round, degrading the policy), so a cantrip buff such as Resistance must not preempt a leveled buff (Divine Favor) in the known-spell order. Leveled buffs keep the historical first-match behavior: if the first one's slots are exhausted the buff branch gives up (the golden pins that). """ for name in spells_known: spell = engine.spell_registry.get(name) if ( spell and spell.level > 0 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 (within range and line of effect: # a touch-range Cure on a distant ally is rejected by _cast_spell and # would waste the whole turn). 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) spell = engine.spell_registry.get(heal_spell) if ally is not None and spell is not None and _spell_reaches( engine, caster, ally, spell ): 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 # PF1e (SRD Conditions, nauseated): "The only action such a character can # take is a single move action per turn" — genuine move actions only: the # 5-foot step and dropping prone are free actions, not move actions, and # run/charge/withdraw are full-round. _NAUSEA_MOVE_KINDS: frozenset[str] = frozenset( {"move", "stand_up", "retrieve_weapon", "repair_weapon"} ) # Casting-time plumbing for the budget filters: a cast_spell action consumes # the slot its spell's CastTime maps to. The pure filters take a resolver so # tests can drive them without a registry; the engine binds its own. CastTimeResolver = Callable[[Action], CastTime] def _default_cast_time(_action: Action) -> CastTime: return "standard" def _cast_time_slot(cast_time: CastTime) -> _ActionEconomySlot: """Budget slot consumed by a cast_spell of the given casting time. PF1e (CRB, Magic — Casting Time): a standard/swift casting time takes that kind of action; an immediate action is off-turn and consumes the next swift (handled via immediate_used, so it is budget-free here); a free-action cast is free. one_round occupies the full-round slot (it IS a full-round action, SRD "Casting Time") and additionally defers the spell's effect to the start of the caster's next turn via ``PendingCast``. A long casting time (more than 1 round: 3 rounds, 10 minutes, 1 hour, ...) cannot be scheduled by the turn budget at all — the engine drops such casts before the filter runs, so reaching this function with "long" is a programming error. """ if cast_time == "standard": return "standard" if cast_time == "swift": return "swift" if cast_time in ("immediate", "free"): return "free" if cast_time in ("full_round", "one_round"): return "full_round" msg = f"long casting time cannot be scheduled by the budget filter: {cast_time!r}" raise ValueError(msg) def _effective_slot( action: Action, cast_time: CastTimeResolver, *, free_draw: bool = False ) -> _ActionEconomySlot: """The budget slot an action consumes, resolving cast_spell by cast time and the cackle hex by its APG free-action cost. ``free_draw`` (SRD "Move Actions": "If you have a base attack bonus of +1 or higher, you may draw a weapon as a free action combined with a regular move") makes retrieve_weapon occupy the free slot instead of the move slot — the caller sets it only when the turn's sequence carries a move action and the actor's BAB is at least +1. """ if action.kind == "cast_spell": return _cast_time_slot(cast_time(action)) if action.kind == "hex" and action.hex_name == "cackle": return "free" if action.kind == "deed" and action.deed_name == "quick_clear": # PF1e (Ultimate Combat, Deeds — Quick Clear): a STANDARD action # (unlike the swift grit deeds); the kind-level deed slot stays swift. return "standard" if action.kind == "retrieve_weapon" and free_draw: return "free" return _action_economy_slot(action.kind) def _without_swift_bonus( actions: tuple[Action, ...], *, cast_time: CastTimeResolver = _default_cast_time, ) -> tuple[Action, ...]: """Drop swift/free bonus actions (deed, inspire_courage, swift). An immediate action consumes the combatant's next swift action; the policy-returned sequence is filtered so no swift bonus fires that turn — including a swift-action cast_spell, which occupies the same slot. The check is by effective slot so the standard-action Quick Clear deed (Ultimate Combat) survives. """ return tuple( a for a in actions if _effective_slot(a, cast_time) != "swift" ) def _dropped_kinds( before: tuple[Action, ...], after: tuple[Action, ...] ) -> tuple[str, ...]: """Kinds present in ``before`` but absent from the filtered ``after``.""" kept = {a.kind for a in after} return tuple(a.kind for a in before if a.kind not in kept) def _staggered_filter( actions: tuple[Action, ...], *, cast_time: CastTimeResolver = _default_cast_time, free_draw: bool = False, ) -> tuple[Action, ...]: """Staggered: one move OR one standard action, plus every swift and free. PF1e (CRB, Conditions — Staggered): "A staggered creature may take a single move action or standard action each round (but not both, nor can he take full-round actions). A staggered creature can still take free, swift, and immediate actions." So the filter walks the sequence in order and keeps every swift and free action, the FIRST move-or-standard action, and drops full-round actions plus any SECOND move-or-standard. A leading ``full_attack`` (already in range) is downgraded to a single standard ``attack`` that consumes the one move-or-standard slot; a further full-round action (charge, run, a full-round/one-round cast, spell_combat) is dropped. Exceptions (SRD "Full-Round Actions — Restricted Withdraw", SRD "Charge — restricted"): "If you are limited to taking only a standard action each round you can withdraw as a standard action. In this case, you may move up to your speed" / "If you are able to take only a standard action on your turn, you can still charge, but you are only allowed to move up to your speed" — ``withdraw`` and ``charge`` are kept as the one standard action and their movement budgets halve to single speed (``_flee_path``, ``_charge_speed_mult``). """ kept: list[Action] = [] slot_used = False # the single move-OR-standard slot is spent for action in actions: if action.kind == "full_attack": # A full attack is a full-round action; staggered it collapses to # a single standard attack (the kept move-or-standard action). if not slot_used: kept.append(Action(kind="attack", target_id=action.target_id)) slot_used = True continue slot = _effective_slot(action, cast_time, free_draw=free_draw) if slot == "full_round": if action.kind in ("withdraw", "charge") and not slot_used: # Restricted Withdraw / Restricted Charge: standard action. kept.append(action) slot_used = True continue if slot in ("standard", "move"): if slot_used: continue kept.append(action) slot_used = True else: # swift / free — always allowed while staggered (CRB) kept.append(action) return tuple(kept) def _surprise_filter( actions: tuple[Action, ...], *, cast_time: CastTimeResolver = _default_cast_time, free_draw: bool = False, ) -> tuple[Action, ...]: """Surprise round: one standard OR move action, plus every free action. PF1e (SRD "Surprise"): "combatants who started the battle aware of their opponents each take a standard or move action during the surprise round. You can also take free actions during the surprise round." The rule text grants neither a swift action nor a full-round action, so swift and full-round kinds are dropped — with the same conventions as ``_staggered_filter``: a ``full_attack`` collapses to a single standard ``attack``, and ``withdraw`` / ``charge`` survive as the standard-action Restricted Withdraw / Restricted Charge (SRD "Full-Round Actions", "Charge": legal "if you are able to take only a standard action on your turn", at single speed). """ kept: list[Action] = [] slot_used = False # the single standard-or-move slot is spent for action in actions: if action.kind == "full_attack": if not slot_used: kept.append(Action(kind="attack", target_id=action.target_id)) slot_used = True continue slot = _effective_slot(action, cast_time, free_draw=free_draw) if slot == "full_round": if action.kind in ("withdraw", "charge") and not slot_used: kept.append(action) slot_used = True continue if slot in ("standard", "move"): if slot_used: continue kept.append(action) slot_used = True elif slot == "free": kept.append(action) return tuple(kept) def _nauseated_filter(actions: tuple[Action, ...]) -> tuple[Action, ...]: """PF1e (CRB) nauseated: a single move action per turn, nothing else. The sequence collapses to its first move-like action; standard actions (attack, cast_spell, maneuver, ...) and full-round actions are dropped. """ for action in actions: if action.kind in _NAUSEA_MOVE_KINDS: return (action,) return () # X4 general per-turn action economy (CRB, "Actions in Combat"): one standard # action + one move action (or one full-round action in place of both), plus # one swift action; free and conditional actions never consume the budget. # A move action may also be taken INSTEAD of the standard one (conversion: # when the move slot is spent and the standard is free, a second move # consumes the standard). Every Action kind is classified into exactly one # slot (exhaustive over the Action.kind Literal, locked by # tests/test_conformance_action_economy.py): # - standard: attack, maneuver, channel_energy, hex, delay, ready, # total_defense, aid_another; cast_spell occupies the slot its spell's # CastTime maps to (_cast_time_slot) — standard by default # - move: move, stand_up, retrieve_weapon (drawing a weapon is a move action), # repair_weapon # - swift: swift, deed, inspire_courage (the engine's swift bonus kinds) # - full-round: full_attack, charge, withdraw, full_round, spell_combat # (spell + full attack in one round), run (4x-speed movement, PF1e # full-round); consumes the standard AND the move slot # - free/conditional (everything else, no budget): wait, free, immediate # (off-turn it consumes the NEXT swift via immediate_used; on your own turn # it consumes THIS turn's swift via swift_used_this_turn — SRD Immediate # Actions), 5foot_step (free step, mutually exclusive with other movement via # moved_this_turn), drop_prone, activate_power_attack and fight_defensively # (mode declarations applied to the attack that follows them, like the # Power Attack toggle), summon is not a kind (summons arrive via cast_spell). _ACTION_ECONOMY_STANDARD_KINDS: frozenset[str] = frozenset({ "attack", "cast_spell", "maneuver", "channel_energy", "hex", "delay", "ready", "total_defense", "aid_another", }) _ACTION_ECONOMY_MOVE_KINDS: frozenset[str] = frozenset({ "move", "stand_up", "retrieve_weapon", "repair_weapon", }) _ACTION_ECONOMY_SWIFT_KINDS: frozenset[str] = frozenset( {"swift", "deed", "inspire_courage"} ) _ACTION_ECONOMY_FULL_ROUND_KINDS: frozenset[str] = frozenset({ "full_attack", "charge", "withdraw", "full_round", "spell_combat", "run", }) _ACTION_ECONOMY_FREE_KINDS: frozenset[str] = frozenset({ "wait", "free", "immediate", "5foot_step", "drop_prone", "activate_power_attack", "fight_defensively", }) _ActionEconomySlot = Literal["standard", "move", "swift", "full_round", "free"] def _action_economy_slot(kind: str) -> _ActionEconomySlot: """The budget slot one action kind consumes (free if none applies).""" if kind in _ACTION_ECONOMY_STANDARD_KINDS: return "standard" if kind in _ACTION_ECONOMY_MOVE_KINDS: return "move" if kind in _ACTION_ECONOMY_SWIFT_KINDS: return "swift" if kind in _ACTION_ECONOMY_FULL_ROUND_KINDS: return "full_round" return "free" def _action_economy_filter( actions: tuple[Action, ...], *, cast_time: CastTimeResolver = _default_cast_time, free_draw: bool = False, ) -> tuple[Action, ...]: """Degrade a policy sequence to the legal PF1e action economy. Greedy in-order walk: each action is kept while its budget slot is free (one standard, one move, one swift; a full-round action is only legal while BOTH the standard and the move slot are still free and consumes them all). A cast_spell's slot is its spell's casting time resolved by ``cast_time`` (long casts are dropped by the engine BEFORE this filter — _cast_time_slot raises for them). PF1e (CRB, "Actions in Combat"): a move action can be taken "instead of" the standard one — when the move slot is already spent and the standard slot is still free, a further move action converts the standard slot (so (move, stand_up) and (move, move) are legal). The kept sequence is therefore the earliest order-stable legal prefix-subsequence; every skipped action is deterministic. ``free_draw`` makes retrieve_weapon a free action (SRD "Move Actions", BAB +1 combined with a move — see _effective_slot). """ kept: list[Action] = [] spent: dict[_ActionEconomySlot, bool] = { "standard": False, "move": False, "swift": False, "full_round": False, "free": False, } for action in actions: slot = _effective_slot(action, cast_time, free_draw=free_draw) if slot == "free": kept.append(action) continue if slot == "full_round": if spent["standard"] or spent["move"]: continue spent["standard"] = True spent["move"] = True kept.append(action) continue if slot == "move" and spent["move"]: slot = "standard" # conversion: a move action consumes the standard slot if spent[slot]: continue spent[slot] = True kept.append(action) return tuple(kept) def _dropped_kinds_multiset( before: tuple[Action, ...], after: tuple[Action, ...] ) -> tuple[str, ...]: """One dropped-kind entry per dropped action instance (in order). Unlike ``_dropped_kinds`` (set membership, which cannot report dropping the second of two same-kind actions), this consumes the kept instances first and reports every surplus — the budget filter regularly drops a duplicate standard/move/swift action. """ remaining: dict[str, int] = {} for action in after: remaining[action.kind] = remaining.get(action.kind, 0) + 1 dropped: list[str] = [] for action in before: count = remaining.get(action.kind, 0) if count > 0: remaining[action.kind] = count - 1 else: dropped.append(action.kind) return tuple(dropped) def _ready_payload(action: Action) -> Action: """Derive the concrete payload of a ready action from its carrier fields. Mirrors ``_execute_immediate``: ``spell_name`` casts, ``maneuver`` performs the maneuver (carrying direction/trick_condition), anything else attacks ``target_id``. Class-ability payloads are out of scope for U4. """ if action.spell_name is not None: return Action( kind="cast_spell", target_id=action.target_id, spell_name=action.spell_name, target_pos=action.target_pos, metamagic=action.metamagic, cast_defensively=action.cast_defensively, ) if action.maneuver is not None: return Action( kind="maneuver", target_id=action.target_id, maneuver=action.maneuver, direction=action.direction, trick_condition=action.trick_condition, ) return Action(kind="attack", target_id=action.target_id) Policy = Callable[[CombatEngine, CombatantState], tuple[Action, ...]] LLMPolicy = Callable[[Observation, CombatantState], tuple[Action, ...]] ReactionPolicy = Callable[ [CombatEngine, CombatantState, ReactionEvent], Action | None ] def default_policy(engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]: """Full-attack the nearest enemy; cast spells; charge, 5ft-step, or approach if out of range. Decision order: 1. Channel energy (oni) if resource available and an ally is hurt or enemies are grouped. 2. Hex (nairda) if resource available and the target is within close range. 3. Heal spell if an ally is below 50% HP and a heal spell is available. 4. Offensive spell if an enemy is in range and spell slots available. 5. Buff spell if no immediate threat and spell slots available. 6. Full-attack if a weapon is usable against the nearest enemy this turn (no move needed). 7. Charge if a straight-line charge path exists (2x speed, +2 attack, -2 AC). 8. 5-foot step then full-attack if a single step brings the target into weapon range. 9. Move toward the target then single attack (standard + move economy). 10. Wait if nothing is possible. Class-ability bonus actions (deed for tammara, inspire_courage for misty) are prepended to the normal action sequence whenever their resource is available. Precedence for replacement (standard) abilities: channel_energy and hex are evaluated first and replace the whole turn when their resource is available and their condition is met; otherwise the normal spell/melee flow runs and the swift/free abilities are combined with it. This keeps each class able to act each turn while reserving standard-action class abilities for their triggers. Never casts a spell if threatened without Concentration check (handled in _cast_spell). """ target = engine.nearest_enemy(state) if target is None: return (Action(kind="wait"),) # Must flee: withdraw from the nearest enemy if engine.has_condition_flag(state, "must_flee"): return (Action(kind="withdraw", target_id=target.combatant.id),) # Channel energy (oni): standard action that heal/harms in a 30ft burst. channel_mode = _emit_channel_energy(engine, state) if channel_mode is not None: return ( Action( kind="channel_energy", target_id=target.combatant.id, channel_mode=channel_mode, ), ) # Hex (nairda): standard action when the target is within close range. if _emit_hex(engine, state, target): return (Action(kind="hex", target_id=target.combatant.id, hex_name="evil_eye"),) normal = _normal_actions(engine, state, target) # Misty spell_combat (main) + inspire_courage (swift): replaces the normal action. spell_combat = _spell_combat_actions(engine, state, target) if spell_combat is not None: return spell_combat # Deed (tammara) and inspire_courage (misty melee fallback): swift/free bonuses. swift_bonus = _swift_bonus_actions(engine, state, target, normal) 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 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. APG hexes carry a close range, which scales with the caster level (SRD, Magic - Range: "Close - 25 ft. + 5 ft. per 2 caster levels"). """ if state.resources_left.get("hexes", 0) <= 0: return False if target is None or not target.active: return False dist_ft = engine.creature_distance(state, target) * _SQUARE_FT return dist_ft <= close_range_ft(state.combatant.level) def _spell_combat_actions( engine: CombatEngine, state: CombatantState, target: CombatantState ) -> tuple[Action, ...] | None: """Misty's spell_combat (main) + inspire_courage (swift), None if not castable. Slot gate (mirror of ``_choose_spell_action``): with no slot left for the chosen spell the cast is rejected (``NO SPELL SLOT``), so the executor would run the action as a bare full attack at -2 - strictly worse than the plain full attack the policy would otherwise choose. Fall through instead. """ 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 if not _has_slot(engine, state, offensive): return None return ( Action(kind="inspire_courage", target_id=target.combatant.id), Action(kind="spell_combat", target_id=target.combatant.id, spell_name=offensive), ) _DEED_ATTACK_KINDS: frozenset[str] = frozenset({"attack", "full_attack"}) def _swift_bonus_actions( engine: CombatEngine, state: CombatantState, target: CombatantState, normal: tuple[Action, ...], ) -> tuple[Action, ...]: """Swift/free class-ability actions (deed, inspire_courage) to prepend to the turn.""" bonus: list[Action] = [] # Deed (tammara): Up Close and Deadly spends 1 grit up front and its +1d6 # rides the FIRST resolved attack, firearm-only within 30 ft (Ultimate # Combat). Emitting it when the normal action resolves no such attack # (melee swing, spell cast) burns grit for nothing, so gate on the weapon # the turn will actually fire and on the sequence containing an attack. weapon = engine.weapon_for(state, target) if ( state.resources_left.get("grit", 0) > 0 and engine.creature_distance(state, target) * _SQUARE_FT <= _UP_CLOSE_RANGE_FT and weapon is not None and weapon.firearm and any(a.kind in _DEED_ATTACK_KINDS for a in normal) ): 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), )