10773 lines
477 KiB
Python
10773 lines
477 KiB
Python
"""Deterministic combat engine: attacks, crits, DR, hp states, initiative, rounds.
|
|
|
|
Phase 0 documented deviations from PF1e (conventions):
|
|
- Action economy: a normal turn grants one standard action + one move action
|
|
(or one full-round action), plus swift/free/immediate. The policy returns an
|
|
ordered sequence of `Action` per turn; the engine enforces the budget on it
|
|
(X4, `_action_economy_filter`): a cast_spell occupies the slot of its spell's
|
|
casting time (long casts are dropped before the budget with
|
|
`downgraded:cast_time`), surplus standards/moves/swifts and a full-round
|
|
action conflicting with an already-spent standard/move are
|
|
dropped in order (earliest legal prefix-subsequence kept) and reported as
|
|
`downgraded:action_economy`; a move action may convert a still-free standard
|
|
slot; the condition-specific filters then apply as
|
|
before. The engine executes the remaining actions in order,
|
|
stopping early if the actor drops or one side is wiped. `default_policy`
|
|
returns `(full_attack,)` when already in range, `(charge,)` when a
|
|
straight-line charge path exists, `(5foot_step, full_attack)` when a single
|
|
step brings the target into range, or `(move, attack)` otherwise (move at
|
|
full speed along the Dijkstra path, then single attack). Immediate actions
|
|
(off-turn, consume next swift) are modeled via an opt-in ``reaction_policy``
|
|
hook: each resolved attack, movement step, spell cast, and damage application
|
|
emits a ``ReactionEvent``; the first combatant whose policy returns an
|
|
``Action(kind="immediate")`` executes it immediately, and its next turn's
|
|
swift action is consumed. A readied action (``Action(kind="ready")``,
|
|
standard) stores a carrier-field payload that fires off-turn through the
|
|
same ``ReactionEvent`` machinery when its ``ready_trigger`` matches an
|
|
enemy event, then is consumed. Flanking, full attack, charge, withdraw, and
|
|
5-foot step are modeled; attacks of opportunity are resolved inline (see
|
|
below).
|
|
- A weapon with `count` > 1 ("2x Talons") resolves `count` independent attacks
|
|
in one attack action; remaining swings are lost once the target drops
|
|
(down or dead) mid-routine. BAB iterative attacks (full-round action
|
|
``full_attack``): at BAB +6/+11/+16, additional attacks at -5/-10/-15 from
|
|
the weapon's attack bonus; iteratives are only computed for weapons with
|
|
``count == 1`` (natural multi-attacks do not gain iteratives). Multiple
|
|
natural weapons in a single full attack are not modeled.
|
|
- Natural 1 always misses; natural 20 always hits and threatens a crit.
|
|
- A confirmed crit multiplies the base damage (physical dice + flat bonus)
|
|
by crit_mult; energy weapon-special-ability dice (flaming etc.) and
|
|
precision dice are NOT multiplied (CRB Critical Hits exception).
|
|
- DR applies once, after crit multiplication, to the PHYSICAL portion of the
|
|
damage only (bludgeoning/piercing/slashing dice, flat bonuses and precision
|
|
dice) — energy damage dealt along with an attack is never reduced by DR
|
|
(CRB "Damage Reduction"); a single bypass type defeats
|
|
DR; a multi-component "and" bypass (e.g. "bludgeoning and magic") requires
|
|
EVERY component (PF1e "DR X/Y and Z" notation).
|
|
- Death when negative hp reaches the Constitution score (hp <= -CON); PF1e
|
|
has no -10 rule. At exactly 0 hp a living creature is disabled: active,
|
|
staggered (one move OR standard action per round) at half speed, and a
|
|
non-movement action costs 1 hp at its end (hp -> -1, dying).
|
|
Optional rule (``monsters_die_at_zero``, CLI ``--simple-monster-death``):
|
|
monster combatants die at hp <= 0 instead — player characters are never
|
|
affected and the default (flag off) is the PF1e rule above, bit-identical.
|
|
- Initiative ties: higher initiative_mod first, then list order (no re-roll).
|
|
- Movement: a move action travels up to the combatant's speed in cells along
|
|
the true shortest path (Dijkstra cost field from the target, 5-10-5
|
|
diagonals), stopping adjacent to the nearest enemy; ties keep delta order.
|
|
The speed budget is the best of land and fly speed (V4). A creature whose
|
|
budget comes from its fly speed (fly >= land) is AIRBORNE: it ignores
|
|
difficult-terrain costs and crosses ground cells impassable to walkers
|
|
(pits, rubble, water), except cells that block line of effect (solid
|
|
barriers, assumed full height) — SRD "Movement, Position, and Distance":
|
|
"Flying and incorporeal creatures are not hampered by difficult terrain" /
|
|
"are able to avoid most obstacles". No fly maneuverability, no 3D flight.
|
|
- 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
|
|
# PF1e (SRD "Acrobatics" - Tumble, KB rules/pathfinder-unchained/
|
|
# acrobatics-dex-armor-check-penalty.md): "Move through an enemy's space:
|
|
# 5 + opponent's CMD" and "The DC increases by 2 for each opponent beyond
|
|
# the first."
|
|
_TUMBLE_SPACE_ENTRY_DC = 5
|
|
_TUMBLE_EXTRA_OPPONENT_DC = 2
|
|
# SRD "Acrobatics" - Tumble: "You move at half speed when you tumble".
|
|
_TUMBLE_SPEED_FACTOR = 0.5
|
|
# PF1e (SRD "Stealth" - Modifiers): size bonus/penalty on Stealth checks.
|
|
_STEALTH_SIZE_MOD: dict[str, int] = {
|
|
"fine": 16, "diminutive": 12, "tiny": 8, "small": 4, "medium": 0,
|
|
"large": -4, "huge": -8, "gargantuan": -12, "colossal": -16,
|
|
}
|
|
# PF1e (SRD "Perception"): notice checks take -1 per 10 feet of distance.
|
|
_STEALTH_DISTANCE_FT = 10
|
|
# SRD "Stealth": moving faster than half speed takes -5 on the Stealth check
|
|
# (engine: a move/withdraw action's budget is the full speed, so its re-check
|
|
# carries the -5; see _stealth_after_move).
|
|
_STEALTH_MOVE_PENALTY = -5
|
|
_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",
|
|
# CRB "Disabled" lists "moving 5 feet or more" as strenuous; hiding
|
|
# in place is a move action that does not move - not strenuous.
|
|
"hide",
|
|
}
|
|
)
|
|
# 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
|
|
when its fly speed is at least its land speed (see ``_is_airborne``). The
|
|
budget itself is land-or-fly; an airborne creature additionally moves over
|
|
ground terrain at cost 1 (``Grid.airborne_view``). 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 _is_airborne(state: CombatantState) -> bool:
|
|
"""True when the creature's movement budget comes from its fly speed.
|
|
|
|
The engine's V4 assumption is that a creature with a fly speed uses it
|
|
(budget = max(land, fly)); the creature is AIRBORNE — flying over the
|
|
ground, so difficult terrain does not hamper it and it avoids the
|
|
obstacles that merely block walkers (SRD "Movement, Position, and
|
|
Distance": "Flying and incorporeal creatures are not hampered by difficult
|
|
terrain"; "Flying and incorporeal creatures are able to avoid most
|
|
obstacles") — exactly when the fly speed is the source of that budget,
|
|
i.e. ``speed_fly_ft >= speed_land_ft``. A creature whose fly speed is
|
|
slower than its land speed walks (the budget is the land speed) and pays
|
|
ground terrain costs normally. There is no explicit altitude flag: the
|
|
model has no 3D position, so "airborne" is derived from the speeds alone
|
|
(documented decision).
|
|
"""
|
|
fly = state.combatant.speed_fly_ft
|
|
return fly is not None and fly >= state.combatant.speed_land_ft
|
|
|
|
|
|
def _bresenham_line(start: Pos, end: Pos) -> list[Pos]:
|
|
"""Integer Bresenham line from start to end (exclusive of start, inclusive of end)."""
|
|
r0, c0 = start
|
|
r1, c1 = end
|
|
dr = abs(r1 - r0)
|
|
dc = abs(c1 - c0)
|
|
sr = 1 if r1 > r0 else -1
|
|
sc = 1 if c1 > c0 else -1
|
|
err = dr - dc
|
|
path: list[Pos] = []
|
|
r, c = r0, c0
|
|
while (r, c) != (r1, c1):
|
|
e2 = 2 * err
|
|
if e2 > -dc:
|
|
err -= dc
|
|
r += sr
|
|
if e2 < dr:
|
|
err += dr
|
|
c += sc
|
|
path.append((r, c))
|
|
return path
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class CombatantStats:
|
|
"""Aggregated outcomes for one combatant over a battle.
|
|
|
|
``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
|
|
# PF1e Stealth (SRD "Stealth": "Creatures that fail to beat your Stealth
|
|
# check are not aware of you and treat you as if you had total
|
|
# concealment", KB rules/ultimate-intrigue/perception-and-stealth.md):
|
|
# the ids of observers that currently cannot perceive this combatant -
|
|
# per-observer invisibility, which the global condition machinery cannot
|
|
# express (a creature can be hidden from one foe and seen by another).
|
|
# Set by the `hide` action's opposed check, re-rolled when the creature
|
|
# hides or moves again, and cleared the moment the creature makes an
|
|
# attack roll (SRD "Breaking Stealth").
|
|
hidden_from: set[str] = field(default_factory=set)
|
|
|
|
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. ``move`` accepts ``tumble``: the
|
|
move happens at half speed and each opponent crossed is passed with one
|
|
Acrobatics check against its CMD (SRD "Acrobatics" - Tumble; see the
|
|
``tumble`` field comment). ``hide`` is a move action: a Stealth check
|
|
opposed by each enemy observer's Perception check (distance -1 per 10
|
|
ft); observers that fail cannot perceive the creature until it attacks
|
|
or moves again (SRD "Stealth" - see ``_execute_hide``).
|
|
"""
|
|
|
|
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", "hide",
|
|
]
|
|
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
|
|
# Acrobatics tumble (SRD "Acrobatics" — Tumble): opt-in on `move` only.
|
|
# The move spends half its speed budget and, against each opponent whose
|
|
# threatened area is crossed, attempts one Acrobatics check (DC = that
|
|
# opponent's CMD, +5 to pass through its own space, +2 per additional
|
|
# opponent already tumbled past this move): success avoids its AoO,
|
|
# failure provokes it AND ends the movement (see _tumble_avoids).
|
|
tumble: 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
|
|
class _TumbleAttempt:
|
|
"""Per-move Acrobatics tumble bookkeeping.
|
|
|
|
``checked`` holds the opponents an Acrobatics check was already attempted
|
|
against during this tumbling move - its size is the "+2 per additional
|
|
opponent" DC escalation and it guarantees one check per opponent.
|
|
"""
|
|
|
|
checked: set[str] = field(default_factory=set)
|
|
|
|
|
|
@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._airborne_grid = grid.airborne_view()
|
|
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,
|
|
# SRD "Stealth": ids of observers that currently cannot perceive
|
|
# this combatant (they failed the opposed Perception check); the
|
|
# policy sees who is hidden from whom (per-observer invisibility).
|
|
"hidden_from": tuple(sorted(state.hidden_from)),
|
|
"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 _movement_grid(self, state: CombatantState) -> Grid:
|
|
"""The terrain grid the mover walks on: the air view when airborne.
|
|
|
|
An airborne creature prices and gates every movement action (move,
|
|
run, charge, withdraw, 5-foot step) on ``Grid.airborne_view``: ground
|
|
difficult terrain costs 1 and impassable non-barrier cells (pits,
|
|
water, rubble) are crossable; ``blocks_los`` cells (solid barriers,
|
|
assumed full height) stay impassable. Line of effect, cover and
|
|
high ground always use the real ``_grid`` — the air view preserves
|
|
their terrain flags, so combat geometry is unaffected by flight.
|
|
"""
|
|
return self._airborne_grid if _is_airborne(state) else self._grid
|
|
|
|
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)
|
|
grid = self._movement_grid(mover)
|
|
return all(
|
|
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)
|
|
grid = self._movement_grid(mover)
|
|
return all(
|
|
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). A
|
|
# creature hidden from its target by a successful Stealth check strikes
|
|
# as an unseen attacker exactly the same way (SRD "Stealth": observers
|
|
# that fail the check "are not aware of you and treat you as if you
|
|
# had total concealment" - CRB "Unseen Attackers and Targets" gives
|
|
# the unseen attacker +2 and denies the defender its Dex bonus).
|
|
attacker_unseen = self._has_condition_named(
|
|
attacker, "invisible"
|
|
) or self._is_hidden_from(attacker, target)
|
|
if attacker_unseen:
|
|
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, and so does one hidden from the
|
|
# target by Stealth (attacker_unseen above).
|
|
denies_dex = (
|
|
self.has_condition_flag(target, "denies_dex_to_ac")
|
|
or self._running_denies_dex(target)
|
|
or attacker_unseen
|
|
)
|
|
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
|
|
# SRD "Stealth" - Breaking Stealth: "Your Stealth immediately ends
|
|
# after you make an attack roll, whether or not the attack is
|
|
# successful" (KB rules/ultimate-intrigue/perception-and-stealth.md
|
|
# awareness states: attacking makes the observers aware of you) -
|
|
# the roll above already benefited from the unseen-attacker treatment
|
|
# and the target's failed check, so the hidden state is spent now.
|
|
attacker.hidden_from.clear()
|
|
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_vs(attacker, 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 _is_hidden_from(self, subject: CombatantState, observer: CombatantState) -> bool:
|
|
"""True when ``subject`` is hidden from ``observer`` (failed Stealth)."""
|
|
return observer.combatant.id in subject.hidden_from
|
|
|
|
def _concealment_chance_vs(
|
|
self, observer: CombatantState, target: CombatantState
|
|
) -> int:
|
|
"""Concealment the ``target`` has against attacks from ``observer``.
|
|
|
|
PF1e (SRD "Stealth", KB rules/ultimate-intrigue/perception-and-stealth.md
|
|
quoting the CRB: creatures that fail the opposed Perception check "are
|
|
not aware of the sneaking character"): a hidden creature is treated as
|
|
if it had TOTAL concealment (50% miss chance, CRB "Concealment") -
|
|
but only relative to the observers that failed the check, which the
|
|
global condition machinery cannot express, hence the per-observer
|
|
``hidden_from`` set folded in here.
|
|
"""
|
|
chance = self._concealment_chance(target)
|
|
if self._is_hidden_from(target, observer):
|
|
return max(chance, _TOTAL_CONCEALMENT_PCT)
|
|
return chance
|
|
|
|
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. An airborne mover prices the field on the air
|
|
view (``_movement_grid``). ``budget`` is in feet; None = full field.
|
|
"""
|
|
blocked = self._blocked(state)
|
|
grid = self._movement_grid(state)
|
|
if budget is None:
|
|
field = grid.reachable(state.pos, None, blocked)
|
|
else:
|
|
budget_cells = budget // _SQUARE_FT
|
|
if budget_cells < 1:
|
|
return {}
|
|
field = 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. An airborne ``state`` prices the field on the air
|
|
view. Read-only: never mutates state nor consumes RNG.
|
|
"""
|
|
blocked = self._blocked(state, target)
|
|
field = self._movement_grid(state).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_vs(attacker, 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 _tumble_avoids(
|
|
self,
|
|
mover: CombatantState,
|
|
enemy: CombatantState,
|
|
attempt: _TumbleAttempt,
|
|
*,
|
|
space_entry: bool,
|
|
) -> bool:
|
|
"""One Acrobatics check of a tumbling move against ``enemy``.
|
|
|
|
PF1e (SRD "Acrobatics" - Tumble, KB rules/pathfinder-unchained/
|
|
acrobatics-dex-armor-check-penalty.md): "You can attempt an Acrobatics
|
|
check to avoid provoking an attack of opportunity when moving through a
|
|
threatened square. [...] If you try to tumble past multiple opponents
|
|
in the same round, attempt an Acrobatics check against each of them.
|
|
The DC increases by 2 for each opponent beyond the first." DC table:
|
|
"Move through a threatened area - Opponent's CMD; Move through an
|
|
enemy's space - 5 + Opponent's CMD" - the +5 is the ``space_entry``
|
|
case (entering a hostile creature's own square, only possible against
|
|
a much larger occupant). One check per opponent per tumble; True =
|
|
avoided (no AoO), False = failed (the caller provokes the AoO and ends
|
|
the movement - "If you fail, your movement ends and you provoke an
|
|
attack of opportunity from the threatening opponent").
|
|
"""
|
|
extra = _TUMBLE_EXTRA_OPPONENT_DC * (
|
|
len(attempt.checked) - (1 if enemy.combatant.id in attempt.checked else 0)
|
|
)
|
|
dc = (
|
|
self._maneuver_cmd(enemy)
|
|
+ (_TUMBLE_SPACE_ENTRY_DC if space_entry else 0)
|
|
+ extra
|
|
)
|
|
attempt.checked.add(enemy.combatant.id)
|
|
check = self.skill_check(mover, "acrobatics")
|
|
success = check.total >= dc
|
|
self._log(
|
|
f"round {self._current_round} {mover.combatant.id}: tumble vs "
|
|
f"{enemy.combatant.id} acrobatics d20={check.roll}"
|
|
f"+{check.total - check.roll}={check.total} DC {dc} "
|
|
f"-> {'avoided' if success else 'FAILED (AoO, movement ends)'}"
|
|
)
|
|
return success
|
|
|
|
def _tumble_trained(self, mover: CombatantState) -> bool:
|
|
"""True when the mover may attempt an Acrobatics check at all.
|
|
|
|
PF1e (SRD "Moving Through a Square", KB rules/prpg-core-rulebook/
|
|
moving-through-a-square.md): "A trained character can attempt to use
|
|
Acrobatics to move through a square occupied by an opponent" - the
|
|
through-an-enemy's-space tumble requires at least one rank; an
|
|
untrained mover simply provokes the entry AoO normally.
|
|
"""
|
|
skill = mover.combatant.skills.get("acrobatics")
|
|
return skill is not None and skill.rank > 0
|
|
|
|
def _check_move_aoo(
|
|
self,
|
|
mover: CombatantState,
|
|
*,
|
|
movement: bool = True,
|
|
tumble: _TumbleAttempt | None = None,
|
|
) -> 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. With a ``tumble`` context
|
|
(SRD "Acrobatics" - Tumble) each provoking opponent instead faces one
|
|
Acrobatics check: a passed check spends the opportunity without a
|
|
strike, a failed one provokes the strike AND ends the movement.
|
|
Returns True if the mover dropped or the tumble check failed.
|
|
"""
|
|
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)
|
|
if tumble is not None:
|
|
avoided = self._tumble_avoids(
|
|
mover, enemy, tumble, space_entry=False
|
|
)
|
|
if avoided:
|
|
continue
|
|
self._resolve_aoo(enemy, mover)
|
|
return True
|
|
self._resolve_aoo(enemy, mover)
|
|
if not mover.active:
|
|
return True
|
|
return False
|
|
|
|
def _fresh_entry_occupants(
|
|
self, mover: CombatantState, next_pos: Pos
|
|
) -> list[CombatantState]:
|
|
"""Hostile occupants whose square the mover would ENTER at ``next_pos``.
|
|
|
|
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) never
|
|
provoke.
|
|
"""
|
|
before = mover.occupied_cells()
|
|
after = self._footprint_at(next_pos, mover)
|
|
occupants: list[CombatantState] = []
|
|
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) or before & cells:
|
|
continue
|
|
occupants.append(s)
|
|
return occupants
|
|
|
|
def _check_enter_aoo(
|
|
self,
|
|
mover: CombatantState,
|
|
next_pos: Pos,
|
|
*,
|
|
tumble: _TumbleAttempt | None = None,
|
|
) -> bool:
|
|
"""Resolve AoOs provoked by ENTERING a hostile creature's square.
|
|
|
|
Capped by the per-round AoO allowance; no per-opponent dedupe
|
|
(``_move_aoo_provoked`` dedupes only leaving threatened cells). With
|
|
a ``tumble`` context a trained mover (SRD "Moving Through a Square":
|
|
"A TRAINED character can attempt to use Acrobatics to move through a
|
|
square occupied by an opponent") attempts the Acrobatics check at
|
|
DC 5 + the occupant's CMD; a pass avoids the strike, a failure
|
|
provokes it and ends the movement. Returns True if the mover dropped
|
|
or the tumble check failed.
|
|
"""
|
|
for s in self._fresh_entry_occupants(mover, next_pos):
|
|
if self._aoo_used.get(s.combatant.id, 0) >= self._aoo_allowance(s):
|
|
continue
|
|
if tumble is not None and self._tumble_trained(mover):
|
|
if self._tumble_avoids(mover, s, tumble, space_entry=True):
|
|
continue
|
|
self._resolve_aoo(s, mover)
|
|
return True
|
|
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], grid: Grid) -> int:
|
|
"""Total 5-10-5 movement cost of a path on ``grid`` (parity threaded)."""
|
|
total = 0
|
|
diagonals_used = 0
|
|
for frm, to in pairwise(path):
|
|
total += 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()
|
|
grid = self._movement_grid(state)
|
|
candidates: list[tuple[int, Pos]] = []
|
|
for end in self._charge_candidates(
|
|
target, reach_cells, blocked, state.pos, grid
|
|
):
|
|
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, grid) > 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,
|
|
grid: Grid,
|
|
) -> list[Pos]:
|
|
"""Anchors within reach of some target cell, passable, unoccupied, not the start.
|
|
|
|
``grid`` is the charger's movement grid (the air view when airborne:
|
|
an airborne charger ends its charge over cells walkers cannot enter).
|
|
"""
|
|
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 grid.in_bounds(end) or not 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). An AIRBORNE charger is
|
|
not hampered by difficult terrain and avoids ground obstacles, so it
|
|
walks these gates on the air view (SRD "Movement, Position, and
|
|
Distance": "Flying and incorporeal creatures are not hampered by
|
|
difficult terrain"); solid barriers still stop the charge.
|
|
"""
|
|
grid = self._movement_grid(state)
|
|
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(state, footprint):
|
|
return False
|
|
if (
|
|
prev[0] != pos[0]
|
|
and prev[1] != pos[1]
|
|
and not 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
|
|
# SRD "Stealth": "It's impossible to use Stealth while attacking,
|
|
# running, or charging" - charging forfeits the hidden state outright.
|
|
self._break_stealth(attacker, "charging")
|
|
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", "hide"):
|
|
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)
|
|
if kind == "hide":
|
|
return self._execute_hide(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"
|
|
# SRD "Stealth": "It's impossible to use Stealth while attacking,
|
|
# running, or charging" - running forfeits the hidden state outright.
|
|
self._break_stealth(state, "running")
|
|
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, tumble=action.tumble
|
|
)
|
|
if target is None:
|
|
return None
|
|
self._move(state, target, tumble=action.tumble)
|
|
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 _stealth_size_mod(self, state: CombatantState) -> int:
|
|
"""The size bonus/penalty the state's Stealth checks carry.
|
|
|
|
PF1e (SRD "Stealth" - Modifiers, d20pfsrd Stealth (CRB): "Creatures
|
|
gain a bonus or penalty on Stealth checks based on their size: Fine
|
|
+16, Diminutive +12, Tiny +8, Small +4, Medium +0, Large -4, Huge -8,
|
|
Gargantuan -12, Colossal -16"). The data model bakes size into AC and
|
|
attack JSON values but not into skill ranks, so the table rides the
|
|
check here.
|
|
"""
|
|
return _STEALTH_SIZE_MOD.get(state.combatant.size.lower(), 0)
|
|
|
|
def _stealth_check(
|
|
self, state: CombatantState, *, penalty: int = 0
|
|
) -> SkillCheckResult:
|
|
check = self.skill_check(state, "stealth")
|
|
return SkillCheckResult(
|
|
roll=check.roll, total=check.total + self._stealth_size_mod(state) + penalty
|
|
)
|
|
|
|
def _notice_check(
|
|
self, observer: CombatantState, target: CombatantState
|
|
) -> SkillCheckResult:
|
|
"""One observer's Perception check to notice ``target``.
|
|
|
|
PF1e (SRD "Perception", KB rules/pathfinder-unchained/perception-wis.md
|
|
notice-modifier table: "Distance to the source, object, or creature:
|
|
+1 per 10 feet" DC modifier = -1 per 10 feet on the check). The
|
|
table's "distracted +5" modifier is NOT applied: the SRD grants it to
|
|
a specifically distracted creature and defines no automatic
|
|
in-combat = distracted rule, and the model has no distracted state
|
|
(documented out of scope).
|
|
"""
|
|
dist_ft = self.creature_distance(observer, target) * _SQUARE_FT
|
|
penalty = -(dist_ft // _STEALTH_DISTANCE_FT)
|
|
mods: tuple[StatModifier, ...] = ()
|
|
if penalty:
|
|
mods = (StatModifier(target="perception", value=penalty),)
|
|
return self.skill_check(observer, "perception", extra_mods=mods)
|
|
|
|
def _directly_observed(
|
|
self, hider: CombatantState, observer: CombatantState
|
|
) -> bool:
|
|
"""True when the observer directly sees the hider (Stealth impossible).
|
|
|
|
PF1e (SRD "Stealth" - Being Observed, KB rules/ultimate-intrigue/
|
|
perception-and-stealth.md: "characters can't use Stealth while being
|
|
observed... finding cover or concealment allows you to use Stealth"):
|
|
line of effect, no terrain cover, no concealment on the hider and a
|
|
sighted observer means the hider IS observed.
|
|
"""
|
|
if self._has_condition_named(observer, "blinded"):
|
|
return False
|
|
if not has_line_of_effect_cells(
|
|
self._grid, observer.occupied_cells(), hider.occupied_cells()
|
|
):
|
|
return False
|
|
if self._concealment_chance(hider) > 0:
|
|
return False
|
|
return (
|
|
cover_bonus_cells(
|
|
self._grid,
|
|
observer.occupied_cells(),
|
|
hider.occupied_cells(),
|
|
ranged=True,
|
|
occupied=frozenset(),
|
|
)
|
|
== 0
|
|
)
|
|
|
|
def _execute_hide(self, state: CombatantState) -> ActionResult:
|
|
"""Execute the hide action (PF1e, SRD "Stealth"): a move action.
|
|
|
|
One Stealth check (d20 + ranks + Dex + trained class skill + armor
|
|
check penalty + size) opposed by every enemy observer's Perception
|
|
check (distance -1 per 10 ft). Observers that fail to BEAT the
|
|
Stealth total cannot perceive the creature until it attacks or moves
|
|
(SRD "Stealth": "Creatures that fail to beat your Stealth check are
|
|
not aware of you and treat you as if you had total concealment";
|
|
opposed-check resolution per CRB "Skill Checks"). An observer that
|
|
directly sees the hider is never beaten - Stealth against it is
|
|
impossible (SRD "Being Observed").
|
|
"""
|
|
kind = "hide"
|
|
stealth: SkillCheckResult | None = None
|
|
hidden: set[str] = set()
|
|
for observer in self._states:
|
|
if (
|
|
observer is state
|
|
or not observer.active
|
|
or observer.side == state.side
|
|
):
|
|
continue
|
|
if self._directly_observed(state, observer):
|
|
self._log(
|
|
f"round {self._current_round} {state.combatant.id}: "
|
|
f"hide vs {observer.combatant.id} -> observed "
|
|
f"(Stealth impossible)"
|
|
)
|
|
continue
|
|
if stealth is None:
|
|
stealth = self._stealth_check(state)
|
|
perc = self._notice_check(observer, state)
|
|
if stealth.total > perc.total:
|
|
hidden.add(observer.combatant.id)
|
|
verdict = "hidden"
|
|
else:
|
|
verdict = "noticed"
|
|
self._log(
|
|
f"round {self._current_round} {state.combatant.id}: "
|
|
f"hide stealth d20={stealth.roll}+{stealth.total - stealth.roll}"
|
|
f"={stealth.total} vs {observer.combatant.id} perception "
|
|
f"d20={perc.roll}+{perc.total - perc.roll}={perc.total} "
|
|
f"-> {verdict}"
|
|
)
|
|
state.hidden_from = hidden
|
|
return ActionResult(kind=kind, ok=True, reason=_REASON_OK)
|
|
|
|
def _stealth_after_move(self, state: CombatantState, *, penalty: int) -> None:
|
|
"""Re-run the opposed check when a hidden creature moves.
|
|
|
|
PF1e (SRD "Stealth" - Breaking Stealth, d20pfsrd Stealth (CRB): "you
|
|
can leave cover or concealment and remain unobserved as long as you
|
|
succeed at a Stealth check and end your turn in cover or
|
|
concealment"; "You can move up to half your normal speed and use
|
|
Stealth at no penalty. When moving at a speed greater than half...
|
|
you take a -5 penalty"). Engine simplification (documented): the
|
|
half-speed option is not separately expressible for a move action
|
|
(its budget is the full speed), so a move/withdraw re-check carries
|
|
the -5; a 5-foot step (always within half speed) carries 0. An
|
|
observer that ends the move directly observing the creature is
|
|
aware of it with no roll.
|
|
"""
|
|
if not state.hidden_from:
|
|
return
|
|
stealth: SkillCheckResult | None = None
|
|
for observer_id in sorted(state.hidden_from):
|
|
observer = next(
|
|
s for s in self._states if s.combatant.id == observer_id
|
|
)
|
|
if not observer.active:
|
|
continue
|
|
if self._directly_observed(state, observer):
|
|
state.hidden_from.discard(observer_id)
|
|
self._log(
|
|
f"round {self._current_round} {state.combatant.id}: "
|
|
f"no longer hidden from {observer_id} (observed)"
|
|
)
|
|
continue
|
|
if stealth is None:
|
|
stealth = self._stealth_check(state, penalty=penalty)
|
|
perc = self._notice_check(observer, state)
|
|
self._log(
|
|
f"round {self._current_round} {state.combatant.id}: "
|
|
f"hide stealth d20={stealth.roll}+{stealth.total - stealth.roll}"
|
|
f"={stealth.total} vs {observer_id} perception "
|
|
f"d20={perc.roll}+{perc.total - perc.roll}={perc.total} "
|
|
f"-> {'hidden' if stealth.total > perc.total else 'noticed'}"
|
|
)
|
|
if perc.total >= stealth.total:
|
|
state.hidden_from.discard(observer_id)
|
|
|
|
def _break_stealth(self, state: CombatantState, reason: str) -> None:
|
|
"""End a hidden state explicitly (SRD "Stealth": impossible while
|
|
running or charging - the action itself forfeits the check)."""
|
|
if state.hidden_from:
|
|
state.hidden_from.clear()
|
|
self._log(
|
|
f"round {self._current_round} {state.combatant.id}: "
|
|
f"stealth broken ({reason})"
|
|
)
|
|
|
|
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")
|
|
or self._is_hidden_from(caster, target)
|
|
)
|
|
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")
|
|
or self._is_hidden_from(caster, target)
|
|
)
|
|
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). A hidden
|
|
caster delivers the touch as an unseen attacker (Dex denial in the
|
|
caller) and this attack roll ends its Stealth (SRD "Breaking
|
|
Stealth")."""
|
|
caster.hidden_from.clear()
|
|
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_vs(caster, 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).
|
|
# An airborne creature's movement is not hampered by difficult
|
|
# terrain (SRD "Movement, Position, and Distance"), so the gate
|
|
# passes for it.
|
|
if self._footprint_touches_difficult(state, 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(state, 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]})"
|
|
)
|
|
# A 5-foot step is always within half speed: Stealth re-check at 0.
|
|
self._stealth_after_move(state, penalty=0)
|
|
|
|
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)
|
|
grid = self._movement_grid(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 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);
|
|
# flight is not hampered by ground terrain, so the gate passes for an
|
|
# airborne creature.
|
|
step_clear = not (
|
|
self._footprint_touches_difficult(state, self._footprint_at(state.pos, state))
|
|
or self._footprint_touches_difficult(state, 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]})"
|
|
)
|
|
# A 5-foot step is always within half speed: Stealth re-check at 0.
|
|
self._stealth_after_move(state, penalty=0)
|
|
return ActionResult(kind=kind, ok=True, reason=_REASON_OK)
|
|
|
|
def _move(
|
|
self, state: CombatantState, target: CombatantState, *, tumble: bool = False
|
|
) -> 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 "Acrobatics" - Tumble: "You move at half speed when you tumble".
|
|
factor = _TUMBLE_SPEED_FACTOR if tumble else 1.0
|
|
path = self._move_path(
|
|
state, target, friendly_passable=True, speed_factor=factor
|
|
)
|
|
start = state.pos
|
|
actual_path = self._walk_steps(state, path, tumble=tumble)
|
|
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}")
|
|
self._stealth_after_move(state, penalty=_STEALTH_MOVE_PENALTY)
|
|
|
|
def _move_to(
|
|
self, state: CombatantState, destination: Pos, *, tumble: bool = False
|
|
) -> 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. With ``tumble`` the budget is half
|
|
speed (SRD "Acrobatics" - Tumble) and the reachability verdict uses
|
|
the same halved budget as the walked path.
|
|
"""
|
|
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)
|
|
factor = _TUMBLE_SPEED_FACTOR if tumble else 1.0
|
|
if not self._destination_reachable(
|
|
state, destination, friendly_passable=True, speed_factor=factor
|
|
):
|
|
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, speed_factor=factor
|
|
)
|
|
self._walk_steps(state, path, tumble=tumble)
|
|
state.moved_this_turn = True
|
|
self._log(
|
|
f"round {self._current_round} {state.combatant.id}: "
|
|
f"move -> ({destination[0]},{destination[1]})"
|
|
)
|
|
self._stealth_after_move(state, penalty=_STEALTH_MOVE_PENALTY)
|
|
return ActionResult(kind=kind, ok=True, reason=_REASON_OK)
|
|
|
|
def _destination_reachable(
|
|
self,
|
|
state: CombatantState,
|
|
destination: Pos,
|
|
*,
|
|
speed_factor: float = 1.0,
|
|
friendly_passable: bool = False,
|
|
avoid_difficult: bool = False,
|
|
) -> bool:
|
|
"""True when destination is passable, unoccupied and within this speed.
|
|
|
|
``speed_factor`` scales the budget (run: 4x speed, U5e; tumble: 0.5x).
|
|
``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(
|
|
state, self._footprint_at(destination, state)
|
|
):
|
|
return False
|
|
blocked = self._blocked_hostile(state) if friendly_passable else self._blocked(state)
|
|
grid = self._movement_grid(state)
|
|
if avoid_difficult:
|
|
blocked = blocked | self._difficult_cells(grid)
|
|
field = grid.reachable(state.pos, speed_cells, blocked)
|
|
return destination in field
|
|
|
|
def _difficult_cells(self, grid: Grid) -> frozenset[Pos]:
|
|
"""Every passable cell of ``grid`` whose terrain costs more than one."""
|
|
return frozenset(
|
|
(row, col)
|
|
for row in range(grid.height)
|
|
for col in range(grid.width)
|
|
if self._is_difficult_cell(grid, (row, col))
|
|
)
|
|
|
|
def _walk_steps(
|
|
self, state: CombatantState, path: list[Pos], *, tumble: bool = False
|
|
) -> list[Pos]:
|
|
"""Walk a precomputed path cell by cell, resolving AoOs and reactions.
|
|
|
|
With ``tumble`` (SRD "Acrobatics" - Tumble) every movement-AoO
|
|
provocation is met with one Acrobatics check per opponent instead of
|
|
the strike on a success, and a failed check ends the walk.
|
|
"""
|
|
actual_path: list[Pos] = []
|
|
attempt = _TumbleAttempt() if tumble else None
|
|
for step in path:
|
|
if self._check_move_aoo(state, tumble=attempt):
|
|
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, tumble=attempt):
|
|
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,
|
|
speed_factor: float = 1.0,
|
|
) -> 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,
|
|
speed_factor=speed_factor,
|
|
)
|
|
|
|
def _descend_path(
|
|
self,
|
|
state: CombatantState,
|
|
anchor: Pos,
|
|
*,
|
|
speed_factor: float = 1.0,
|
|
avoid_difficult: bool = False,
|
|
friendly_passable: bool = False,
|
|
) -> list[Pos]:
|
|
"""Greedy walk of the anchor over the cost field from ``anchor``.
|
|
|
|
``speed_factor`` scales the movement budget (run: 4x speed, U5e;
|
|
tumbling move: 0.5x, SRD "Acrobatics" - Tumble).
|
|
``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._movement_grid(state).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``. An AIRBORNE runner prices
|
|
the line on the air view: ground difficult terrain and impassable
|
|
non-barrier cells do not stop it (SRD "Movement, Position, and
|
|
Distance": "Flying and incorporeal creatures are not hampered by
|
|
difficult terrain").
|
|
"""
|
|
speed_mult = self._get_speed_mult(state)
|
|
budget = int(
|
|
_speed_ft(state) // _SQUARE_FT * speed_mult * _RUN_SPEED_FACTOR
|
|
)
|
|
if budget <= 0:
|
|
return []
|
|
grid = self._movement_grid(state)
|
|
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 grid.diagonal_allowed(current, nxt)
|
|
):
|
|
break
|
|
footprint = self._footprint_at(nxt, state)
|
|
if self._footprint_touches_difficult(state, footprint):
|
|
break
|
|
if not self._cells_walkable(footprint, state):
|
|
break
|
|
step = 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
|
|
grid = self._movement_grid(state)
|
|
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 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(state, 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, state: CombatantState, footprint: frozenset[Pos]
|
|
) -> bool:
|
|
"""True if any footprint cell is difficult terrain (move cost > 1).
|
|
|
|
Priced on the mover's movement grid: an airborne creature is not
|
|
hampered by difficult terrain (SRD "Movement, Position, and
|
|
Distance"), so no cell is difficult for it.
|
|
"""
|
|
grid = self._movement_grid(state)
|
|
return any(self._is_difficult_cell(grid, 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.
|
|
The pricing grid is the mover's movement grid: every cell costs 1 to
|
|
an airborne creature (SRD "Movement, Position, and Distance": flying
|
|
creatures are not hampered by difficult terrain).
|
|
"""
|
|
grid = self._movement_grid(state)
|
|
footprint = self._footprint_at(to, state)
|
|
hardest = 1
|
|
for cell in footprint:
|
|
if not grid.in_bounds(cell):
|
|
continue
|
|
cost = grid.terrain_at(cell).move_cost
|
|
if cost is not None and cost > hardest:
|
|
hardest = cost
|
|
return grid.step_cost_with_terrain(frm, to, diagonals_used, hardest)
|
|
|
|
def _is_difficult_cell(self, grid: Grid, pos: Pos) -> bool:
|
|
"""True for a passable cell of ``grid`` whose terrain costs more than one."""
|
|
if not grid.in_bounds(pos):
|
|
return False
|
|
terrain = 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._movement_grid(state).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
|
|
grid = self._movement_grid(state)
|
|
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 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}"
|
|
)
|
|
# Withdraw moves at double speed: the Stealth re-check carries -5.
|
|
self._stealth_after_move(state, penalty=_STEALTH_MOVE_PENALTY)
|
|
|
|
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", "hide",
|
|
})
|
|
_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),
|
|
)
|