97c0431479
Power Attack is the first active feat: a free-action toggle declared at turn start. When state.power_attack is True and the combatant has the POWER_ATTACK feature, melee attacks apply -X to the attack roll and +2X to damage, where X = BAB//4 + 1 (min 1). Ranged attacks are unaffected. The flag is cleared at the start of each turn via _take_turn, like other per-turn state. - abilities.py: POWER_ATTACK constant (AbilitySpec, no passive effects) - combat.py: _has_feat helper, _power_attack_amt, resolve_attack applies penalty to attack total + crit confirm, bonus to damage - tests/test_power_attack.py: 14 tests (amount at BAB 1/4/8/12, flag false, feat missing, attack penalty, damage bonus, ranged unaffected, flag cleared on turn) - pyproject.toml: add SLF001 + RUF059 to test per-file-ignores (white-box testing accesses private helpers, partial tuple unpacking) - README.md: document Power Attack in abilities.py section, 307 tests
1026 lines
41 KiB
Python
1026 lines
41 KiB
Python
"""Deterministic combat engine: attacks, crits, DR, hp states, initiative, rounds.
|
|
|
|
Phase 0 documented deviations from PF1e (conventions):
|
|
- Action economy: a normal turn grants one standard action + one move action
|
|
(or one full-round action), plus swift/free/immediate. The policy returns an
|
|
ordered sequence of `Action` per turn; the engine executes them in order,
|
|
stopping early if the actor drops or one side is wiped. `default_policy`
|
|
returns `(full_attack,)` when already in range, `(charge,)` when a
|
|
straight-line charge path exists, `(5foot_step, full_attack)` when a single
|
|
step brings the target into range, or `(move, attack)` otherwise (move at
|
|
full speed along the Dijkstra path, then single attack). Immediate actions
|
|
(off-turn, consume next swift) are deferred; flanking, full attack, charge,
|
|
withdraw, and 5-foot step are modeled; attacks of opportunity are resolved
|
|
inline (see below).
|
|
- A weapon with `count` > 1 ("2x Talons") resolves `count` independent attacks
|
|
in one attack action; remaining swings are lost once the target drops
|
|
(down or dead) mid-routine. BAB iterative attacks (full-round action
|
|
``full_attack``): at BAB +6/+11/+16, additional attacks at -5/-10/-15 from
|
|
the weapon's attack bonus; iteratives are only computed for weapons with
|
|
``count == 1`` (natural multi-attacks do not gain iteratives). Multiple
|
|
natural weapons in a single full attack are not modeled.
|
|
- Natural 1 always misses; natural 20 always hits and threatens a crit.
|
|
- A confirmed crit multiplies the total damage (dice + flat bonus) by crit_mult.
|
|
- DR applies once, after crit multiplication; any bypassing type defeats DR.
|
|
- Death when hp < min(-10, -CON); hp <= 0 cannot act.
|
|
- Initiative ties: higher initiative_mod first, then list order (no re-roll).
|
|
- Movement: a move action travels up to the combatant's speed in cells along
|
|
the true shortest path (Dijkstra cost field from the target, 5-10-5
|
|
diagonals), stopping adjacent to the nearest enemy; ties keep delta order.
|
|
- Line of effect gates all attacks: a target fully behind blocking terrain
|
|
cannot be attacked, and the policy moves to gain sight instead.
|
|
- Cover (corner rule) grants +4 AC on hit and crit-confirm rolls; melee and
|
|
ranged reuse the same corner rule. Soft cover: active creatures between
|
|
attacker and target also grant +4 AC on ranged attacks (CRB soft cover);
|
|
melee attacks ignore creatures.
|
|
- Flanking: +2 on melee attack rolls when an active ally with a melee weapon
|
|
threatens the target from the opposite border or corner. Ranged attacks do
|
|
not benefit from flanking; allies without melee weapons do not threaten.
|
|
- Attacks of opportunity: a combatant threatens the 8 adjacent squares if
|
|
active and wielding a melee weapon. Two triggers are modeled: (1) moving
|
|
out of a threatened square — movement is resolved step-by-step, and each
|
|
enemy that threatens the current square gets one AoO before the mover
|
|
leaves it; (2) making a ranged attack while in a threatened square — each
|
|
threatening enemy gets one AoO before each ranged swing. One AoO per
|
|
combatant per round (tracked in ``_aoo_used``, cleared at round start);
|
|
AoOs always strike (PF1e allows declining, the simulator does not). A
|
|
5-foot step never provokes AoOs (it is not a move action); the withdraw
|
|
action protects only the starting square (see below). A 5-foot step and
|
|
any other movement (move, charge, withdraw) are mutually exclusive within
|
|
the same turn (``moved_this_turn`` flag, cleared in ``_take_turn``).
|
|
- Charge: a full-round action that moves in a straight line (Bresenham) up to
|
|
2x speed toward the closest square from which the charger can melee the
|
|
target, then makes a single melee attack at +2. The charger takes -2 AC
|
|
until the start of their next turn (tracked as a StatModifier in
|
|
``effects``, cleared in ``_take_turn``). The straight line must be clear of difficult terrain,
|
|
obstacles, and creatures; minimum 2 cells (10 ft). ``default_policy``
|
|
chooses a charge when the enemy is out of reach but a valid charge path
|
|
exists.
|
|
- Withdraw: a full-round action that moves up to 2x speed away from the
|
|
nearest enemy. The starting square is not threatened — no AoO when leaving
|
|
it. Subsequent squares provoke AoOs normally (resolved step-by-step, as for
|
|
move). The path is a greedy ascent of the Dijkstra cost field from the
|
|
threat. ``default_policy`` does not choose withdraw (it is only available
|
|
via a custom policy).
|
|
- 5-foot step: a free action that moves one square toward the nearest enemy
|
|
without provoking AoOs. Mutually exclusive with any other movement (move,
|
|
charge, withdraw) in the same turn — tracked via ``moved_this_turn``,
|
|
cleared at the start of each turn in ``_take_turn``. ``default_policy``
|
|
uses ``(5foot_step, full_attack)`` when a single step brings the target
|
|
into weapon range.
|
|
- Ranged: cumulative -2 per full range increment beyond the first, up to 10
|
|
range increments; the penalty applies to attack and crit-confirm rolls.
|
|
Thrown weapons (5 increments max) are not distinguished.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from collections.abc import Callable
|
|
from dataclasses import asdict, dataclass, field
|
|
from typing import TYPE_CHECKING
|
|
|
|
from pf1e_simulator.effects import StatModifier, resolve_modifiers
|
|
|
|
if TYPE_CHECKING:
|
|
from typing import Literal
|
|
|
|
from pf1e_simulator.conditions import Condition
|
|
from pf1e_simulator.grid import Grid
|
|
from pf1e_simulator.map import Pos
|
|
from pf1e_simulator.models import AttackSpec, Combatant, DamageReduction
|
|
from pf1e_simulator.rng import Rng
|
|
|
|
from pf1e_simulator.los import has_cover, has_line_of_effect
|
|
|
|
_SQUARE_FT = 5 # Phase 0 maps use 5-ft squares
|
|
_DEATH_FLOOR = -10 # PF1e: dead when hp < -10 or -CON, whichever is lower
|
|
_NATURAL_ONE = 1 # PF1e: natural 1 always misses
|
|
_NATURAL_TWENTY = 20 # PF1e: natural 20 always hits and threatens
|
|
_COVER_AC_BONUS = 4 # PF1e: partial cover grants +4 AC
|
|
_RANGE_INCREMENT_PENALTY = 2 # PF1e: -2 per full range increment beyond the first
|
|
_MAX_RANGE_INCREMENTS = 10 # PF1e: projectile weapons shoot up to 10 increments
|
|
_FLANK_BONUS = 2 # PF1e: +2 melee attack when ally threatens opposite side
|
|
_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)
|
|
_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)
|
|
|
|
_STEP_DELTAS: tuple[Pos, ...] = (
|
|
(-1, -1),
|
|
(-1, 0),
|
|
(-1, 1),
|
|
(0, -1),
|
|
(0, 1),
|
|
(1, -1),
|
|
(1, 0),
|
|
(1, 1),
|
|
)
|
|
|
|
|
|
def _bresenham_line(start: Pos, end: Pos) -> list[Pos]:
|
|
"""Integer Bresenham line from start to end (exclusive of start, inclusive of end)."""
|
|
r0, c0 = start
|
|
r1, c1 = end
|
|
dr = abs(r1 - r0)
|
|
dc = abs(c1 - c0)
|
|
sr = 1 if r1 > r0 else -1
|
|
sc = 1 if c1 > c0 else -1
|
|
err = dr - dc
|
|
path: list[Pos] = []
|
|
r, c = r0, c0
|
|
while (r, c) != (r1, c1):
|
|
e2 = 2 * err
|
|
if e2 > -dc:
|
|
err -= dc
|
|
r += sr
|
|
if e2 < dr:
|
|
err += dr
|
|
c += sc
|
|
path.append((r, c))
|
|
return path
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class CombatantStats:
|
|
"""Aggregated outcomes for one combatant over a battle."""
|
|
|
|
hits: int = 0
|
|
crits: int = 0
|
|
damage_dealt: int = 0
|
|
damage_taken: int = 0
|
|
|
|
|
|
@dataclass
|
|
class CombatantState:
|
|
"""Mutable runtime state of one combatant on the grid."""
|
|
|
|
combatant: Combatant
|
|
side: str
|
|
pos: Pos
|
|
hp: int
|
|
effects: list[StatModifier] = field(default_factory=list)
|
|
conditions: list[Condition] = field(default_factory=list)
|
|
moved_this_turn: bool = False
|
|
power_attack: bool = False
|
|
|
|
@property
|
|
def active(self) -> bool:
|
|
return self.hp > 0
|
|
|
|
@property
|
|
def dead(self) -> bool:
|
|
return self.hp < self.death_threshold
|
|
|
|
@property
|
|
def death_threshold(self) -> int:
|
|
return min(_DEATH_FLOOR, -self.combatant.abilities.con_score)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class AttackResult:
|
|
"""Outcome of one attack roll, including the raw rolls for the log."""
|
|
|
|
hit: bool
|
|
crit: bool
|
|
damage: int
|
|
roll: int
|
|
total: int
|
|
ac: int
|
|
penalty: int = 0
|
|
flank_bonus: int = 0
|
|
base_bonus: int = 0
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class SaveResult:
|
|
"""Outcome of one saving throw roll."""
|
|
|
|
success: bool
|
|
roll: int
|
|
total: int
|
|
dc: int
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Action:
|
|
"""What a policy wants a combatant to do this turn.
|
|
|
|
Kinds map to PF1e action types: ``attack`` is a standard action
|
|
(single attack, highest BAB only — no iteratives), ``move`` is a move
|
|
action, ``full_attack`` is a full-round action (BAB iteratives), ``charge``
|
|
is a special full-round action (2x speed, straight line, +2 attack, -2
|
|
AC), ``5foot_step`` is a free action (one square, no AoO, mutually
|
|
exclusive with move/charge/withdraw), ``swift`` and ``free`` are minor
|
|
actions, ``immediate`` is an off-turn reaction (deferred). ``wait`` is a
|
|
no-op. The engine executes a policy-returned sequence per turn.
|
|
"""
|
|
|
|
kind: Literal[
|
|
"attack", "move", "wait", "swift", "free", "immediate", "full_round",
|
|
"full_attack", "charge", "withdraw", "5foot_step",
|
|
]
|
|
target_id: str | 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
|
|
|
|
|
|
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,
|
|
) -> None:
|
|
self._rng = rng
|
|
self._grid = grid
|
|
self._states = states
|
|
self._round_cap = round_cap
|
|
self._policy = policy if policy is not None else default_policy
|
|
self._transcript: list[str] = []
|
|
self._stats: dict[str, _LiveStats] = {s.combatant.id: _LiveStats() for s in states}
|
|
self._current_round = 0
|
|
self._aoo_used: set[str] = set()
|
|
|
|
def _take_turn(self, state: CombatantState) -> bool:
|
|
"""Execute one combatant's full turn; return True if the battle is over."""
|
|
state.effects.clear()
|
|
state.moved_this_turn = False
|
|
state.power_attack = False
|
|
actions = self._policy(self, state)
|
|
before = len(self._transcript)
|
|
for action in actions:
|
|
if not state.active:
|
|
break
|
|
self._execute(state, action)
|
|
if self._winner_side() is not None:
|
|
break
|
|
if state.active and len(self._transcript) == before:
|
|
self._log(f"round {self._current_round} {state.combatant.id}: wait")
|
|
return self._winner_side() is not None
|
|
|
|
def run(self) -> CombatResult:
|
|
"""Run the battle and return the final result and transcript."""
|
|
order = self._roll_initiative()
|
|
round_no = 1
|
|
while round_no <= self._round_cap:
|
|
self._current_round = round_no
|
|
self._aoo_used.clear()
|
|
for state in order:
|
|
if not state.active:
|
|
continue
|
|
if self._take_turn(state):
|
|
break
|
|
if self._winner_side() is not None:
|
|
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 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._grid.distance(state.pos, s.pos), self._states.index(s)),
|
|
)
|
|
|
|
def weapon_for(self, attacker: CombatantState, target: CombatantState) -> AttackSpec | None:
|
|
"""First weapon usable against the target at this range and with clear LoE."""
|
|
if not has_line_of_effect(self._grid, attacker.pos, target.pos):
|
|
return None
|
|
dist_ft = self._grid.distance(attacker.pos, target.pos) * _SQUARE_FT
|
|
for weapon in attacker.combatant.attacks:
|
|
if weapon.kind in ("melee", "touch") and dist_ft <= weapon.reach_ft:
|
|
return weapon
|
|
if (
|
|
weapon.kind == "ranged"
|
|
and weapon.range_increment_ft is not None
|
|
and dist_ft <= weapon.range_increment_ft * _MAX_RANGE_INCREMENTS
|
|
):
|
|
return weapon
|
|
return 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 != "ranged" 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)."""
|
|
ar, ac = attacker.pos
|
|
tr, tc = target.pos
|
|
opposite = (2 * tr - ar, 2 * tc - ac)
|
|
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 ally.pos != opposite:
|
|
continue
|
|
if any(w.kind == "melee" for w in ally.combatant.attacks):
|
|
return _FLANK_BONUS
|
|
return 0
|
|
|
|
def _stat_modifiers(
|
|
self, state: CombatantState, target: str, *, weapon_name: str | None = None
|
|
) -> list[StatModifier]:
|
|
"""Collect all StatModifiers for ``target`` from effects, conditions, and features.
|
|
|
|
``weapon_name`` filters out modifiers with a ``weapon_filter`` that
|
|
doesn't match (e.g. Weapon Focus (Pistol) only applies to "Pistol").
|
|
Modifiers without a ``weapon_filter`` are always included.
|
|
"""
|
|
mods = [e for e in state.effects if e.target == target]
|
|
for cond in state.conditions:
|
|
mods.extend(m for m in cond.modifiers if m.target == target)
|
|
for feat in state.combatant.features:
|
|
for m in feat.effects:
|
|
if m.target != target:
|
|
continue
|
|
if m.weapon_filter is not None and m.weapon_filter != weapon_name:
|
|
continue
|
|
mods.append(m)
|
|
return mods
|
|
|
|
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 _power_attack_amt(self, attacker: CombatantState) -> int:
|
|
"""Power Attack exchange amount: X = BAB//4 + 1 (min 1). Returns 0 if inactive."""
|
|
if not attacker.power_attack or not self._has_feat(attacker, "Power Attack"):
|
|
return 0
|
|
return max(1, attacker.combatant.bab // 4 + 1)
|
|
|
|
def resolve_attack(
|
|
self,
|
|
attacker: CombatantState,
|
|
target: CombatantState,
|
|
weapon: AttackSpec,
|
|
*,
|
|
bonus_override: int | None = None,
|
|
) -> AttackResult:
|
|
"""Roll one attack (with crit confirm and damage) and apply it."""
|
|
roll = self._rng.d20()
|
|
dist_ft = self._grid.distance(attacker.pos, target.pos) * _SQUARE_FT
|
|
penalty = self.range_penalty(weapon, dist_ft)
|
|
flank = self._flanking_bonus(attacker, target) if weapon.kind == "melee" else 0
|
|
base_bonus = bonus_override if bonus_override is not None else weapon.attack_bonus
|
|
atk_mods = self._stat_modifiers(attacker, "attack", weapon_name=weapon.name)
|
|
attack_mod = resolve_modifiers(atk_mods)
|
|
pa_amt = self._power_attack_amt(attacker) if weapon.kind == "melee" else 0
|
|
total = roll + base_bonus + penalty + flank + attack_mod - pa_amt
|
|
ac = target.combatant.ac.total + resolve_modifiers(self._stat_modifiers(target, "ac"))
|
|
occupied = frozenset(
|
|
s.pos for s in self._states if s.active and s is not attacker and s is not target
|
|
)
|
|
if has_cover(
|
|
self._grid,
|
|
attacker.pos,
|
|
target.pos,
|
|
ranged=weapon.kind == "ranged",
|
|
occupied=occupied,
|
|
):
|
|
ac += _COVER_AC_BONUS
|
|
hit = roll == _NATURAL_TWENTY or (roll != _NATURAL_ONE and total >= ac)
|
|
crit = False
|
|
damage = 0
|
|
if hit:
|
|
if roll != _NATURAL_ONE and roll >= weapon.crit_range:
|
|
confirm = self._rng.d20()
|
|
confirm_total = confirm + base_bonus + penalty + flank + attack_mod - pa_amt
|
|
crit = confirm != _NATURAL_ONE and confirm_total >= ac
|
|
dmg_mods = self._stat_modifiers(attacker, "damage", weapon_name=weapon.name)
|
|
damage = sum(c.formula.roll(self._rng) for c in weapon.damage) + weapon.damage_bonus
|
|
damage += resolve_modifiers(dmg_mods)
|
|
damage += pa_amt * 2 if weapon.kind == "melee" else 0
|
|
if crit:
|
|
damage *= weapon.crit_mult
|
|
damage = self._apply_dr(damage, weapon, target)
|
|
target.hp -= damage
|
|
return AttackResult(
|
|
hit=hit, crit=crit, damage=damage, roll=roll, total=total, ac=ac,
|
|
penalty=penalty, flank_bonus=flank, base_bonus=base_bonus,
|
|
)
|
|
|
|
def _apply_dr(self, damage: int, weapon: AttackSpec, target: CombatantState) -> int:
|
|
dr: DamageReduction | None = target.combatant.dr
|
|
if dr is None:
|
|
return damage
|
|
types = {t for component in weapon.damage for t in component.types}
|
|
if types & dr.bypass:
|
|
return damage
|
|
return max(0, damage - dr.amount)
|
|
|
|
def resolve_save(
|
|
self, state: CombatantState, save_type: Literal["fort", "ref", "will"], dc: int
|
|
) -> 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.
|
|
"""
|
|
roll = self._rng.d20()
|
|
base = getattr(state.combatant.saves, save_type)
|
|
bonus = resolve_modifiers(self._stat_modifiers(state, save_type))
|
|
total = roll + base + bonus
|
|
success = roll != _NATURAL_ONE and (roll == _NATURAL_TWENTY or total >= dc)
|
|
return SaveResult(success=success, roll=roll, total=total, dc=dc)
|
|
|
|
def _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)
|
|
order = sorted(
|
|
self._states,
|
|
key=lambda s: (
|
|
rolls[s.combatant.id][1],
|
|
s.combatant.initiative_mod,
|
|
self._states.index(s),
|
|
),
|
|
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 _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 (8 adjacent for 5 ft reach)."""
|
|
if not state.active:
|
|
return frozenset()
|
|
if not any(w.kind == "melee" for w in state.combatant.attacks):
|
|
return frozenset()
|
|
r, c = state.pos
|
|
return frozenset((r + dr, c + dc) for dr, dc in _STEP_DELTAS)
|
|
|
|
def _enemies_threatening(self, state: CombatantState, pos: Pos) -> list[CombatantState]:
|
|
"""Active enemies whose threatened squares include pos (in self._states order)."""
|
|
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 _resolve_aoo(self, attacker: CombatantState, target: CombatantState) -> bool:
|
|
"""Resolve one attack of opportunity (single melee attack at normal bonus).
|
|
|
|
AoOs are always taken (PF1e allows declining, but the simulator always strikes).
|
|
Returns True if the target dropped.
|
|
"""
|
|
self._aoo_used.add(attacker.combatant.id)
|
|
weapon = next((w for w in attacker.combatant.attacks if w.kind == "melee"), None)
|
|
if weapon is None:
|
|
return False
|
|
return self._resolve_swing(
|
|
attacker, target, weapon, label="AoO"
|
|
)
|
|
|
|
def _check_provoked_aoo(self, attacker: CombatantState, weapon: AttackSpec) -> bool:
|
|
"""Check for ranged-AoO: firing a ranged weapon in a threatened square provokes.
|
|
|
|
Returns True if the attacker dropped (caller must stop attacking).
|
|
"""
|
|
if weapon.kind != "ranged":
|
|
return False
|
|
for enemy in self._enemies_threatening(attacker, attacker.pos):
|
|
if enemy.combatant.id in self._aoo_used:
|
|
continue
|
|
self._resolve_aoo(enemy, attacker)
|
|
if not attacker.active:
|
|
return True
|
|
return False
|
|
|
|
def can_step5_to_attack(self, state: CombatantState, target: CombatantState) -> bool:
|
|
"""True if a 5-foot step toward ``target`` would put ``state`` in weapon range."""
|
|
path = self._move_path(state, target)
|
|
if not path:
|
|
return False
|
|
orig = state.pos
|
|
state.pos = path[0]
|
|
weapon = self.weapon_for(state, target)
|
|
state.pos = orig
|
|
return weapon is not None
|
|
|
|
def can_charge(self, state: CombatantState, target: CombatantState) -> AttackSpec | None:
|
|
"""Return a melee weapon 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.
|
|
"""
|
|
if not has_line_of_effect(self._grid, state.pos, target.pos):
|
|
return None
|
|
speed_cells = state.combatant.speed_land_ft // _SQUARE_FT
|
|
max_cells = 2 * speed_cells
|
|
blocked = frozenset(
|
|
s.pos for s in self._states if s is not state and s is not target and s.active
|
|
)
|
|
for weapon in state.combatant.attacks:
|
|
if weapon.kind != "melee":
|
|
continue
|
|
reach_cells = weapon.reach_ft // _SQUARE_FT
|
|
end = self._charge_end(state, target, reach_cells, max_cells, blocked)
|
|
if end is not None:
|
|
return weapon
|
|
return None
|
|
|
|
def _charge_end(
|
|
self,
|
|
state: CombatantState,
|
|
target: CombatantState,
|
|
reach_cells: int,
|
|
max_cells: int,
|
|
blocked: frozenset[Pos],
|
|
) -> Pos | None:
|
|
"""Find the closest square from which ``state`` can melee ``target`` via a charge.
|
|
|
|
Scans squares 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, all passable).
|
|
Ties break toward lower distance from state.
|
|
"""
|
|
candidates: list[tuple[int, Pos]] = []
|
|
for end in self._charge_candidates(target, reach_cells, blocked, state.pos):
|
|
path = _bresenham_line(state.pos, end)
|
|
if len(path) < _CHARGE_MIN_CELLS or len(path) > max_cells:
|
|
continue
|
|
if not self._charge_path_clear(path, blocked):
|
|
continue
|
|
candidates.append((len(path), end))
|
|
if not candidates:
|
|
return None
|
|
candidates.sort(key=lambda x: (x[0], self._grid.distance(state.pos, x[1])))
|
|
return candidates[0][1]
|
|
|
|
def _charge_candidates(
|
|
self,
|
|
target: CombatantState,
|
|
reach_cells: int,
|
|
blocked: frozenset[Pos],
|
|
start: Pos,
|
|
) -> list[Pos]:
|
|
"""Squares within reach of target, passable, unoccupied, not the start."""
|
|
tr, tc = target.pos
|
|
result: list[Pos] = []
|
|
for dr in range(-reach_cells, reach_cells + 1):
|
|
for dc in range(-reach_cells, reach_cells + 1):
|
|
if abs(dr) > reach_cells or abs(dc) > reach_cells:
|
|
continue
|
|
if dr == 0 and dc == 0:
|
|
continue
|
|
end = (tr + dr, tc + dc)
|
|
if end == start:
|
|
continue
|
|
if not self._grid.in_bounds(end) or not self._grid.passable(end):
|
|
continue
|
|
if end in blocked:
|
|
continue
|
|
if self._grid.distance(end, target.pos) > reach_cells:
|
|
continue
|
|
result.append(end)
|
|
return result
|
|
|
|
def _charge_path_clear(self, path: list[Pos], blocked: frozenset[Pos]) -> bool:
|
|
"""True if every square on the charge path is passable and clear."""
|
|
for pos in path:
|
|
if not self._grid.in_bounds(pos) or not self._grid.passable(pos):
|
|
return False
|
|
if pos in blocked:
|
|
return False
|
|
terrain = self._grid.terrain_at(pos)
|
|
if terrain.move_cost is not None and terrain.move_cost > 1:
|
|
return False
|
|
return True
|
|
|
|
def _charge(
|
|
self, attacker: CombatantState, target: CombatantState, weapon: AttackSpec
|
|
) -> None:
|
|
"""Execute a charge: move in a straight line, then make a single melee attack at +2.
|
|
|
|
Sets a -2 AC penalty on the attacker that lasts until the start of their next turn
|
|
(cleared in ``_take_turn``). Movement during a charge provokes AoOs step-by-step
|
|
as for any move action.
|
|
"""
|
|
reach_cells = weapon.reach_ft // _SQUARE_FT
|
|
speed_cells = attacker.combatant.speed_land_ft // _SQUARE_FT
|
|
max_cells = 2 * speed_cells
|
|
blocked = frozenset(
|
|
s.pos for s in self._states if s is not attacker and s is not target and s.active
|
|
)
|
|
end = self._charge_end(attacker, target, reach_cells, max_cells, blocked)
|
|
if end is None:
|
|
return
|
|
path = _bresenham_line(attacker.pos, end)
|
|
start = attacker.pos
|
|
actual_path: list[Pos] = []
|
|
for step in path:
|
|
for enemy in self._enemies_threatening(attacker, attacker.pos):
|
|
if enemy.combatant.id in self._aoo_used:
|
|
continue
|
|
self._resolve_aoo(enemy, attacker)
|
|
if not attacker.active:
|
|
break
|
|
if not attacker.active:
|
|
break
|
|
attacker.pos = step
|
|
actual_path.append(step)
|
|
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}"
|
|
)
|
|
if not attacker.active:
|
|
return
|
|
attacker.effects.append(StatModifier(target="ac", value=-_CHARGE_AC_PENALTY))
|
|
bonus = weapon.attack_bonus + _CHARGE_ATTACK_BONUS
|
|
self._resolve_swing(
|
|
attacker, target, weapon, label="charge", bonus_override=bonus
|
|
)
|
|
|
|
def _execute(self, state: CombatantState, action: Action) -> None:
|
|
target = next((s for s in self._states if s.combatant.id == action.target_id), None)
|
|
if action.kind in ("attack", "full_attack"):
|
|
self._execute_attack(state, target, action.kind)
|
|
elif action.kind == "charge":
|
|
self._execute_charge(state, target)
|
|
elif action.kind == "withdraw":
|
|
self._withdraw(state, target)
|
|
elif action.kind == "5foot_step":
|
|
if target is None:
|
|
return
|
|
self._step5(state, target)
|
|
elif action.kind == "move":
|
|
if target is None:
|
|
return
|
|
self._move(state, target)
|
|
elif action.kind == "wait":
|
|
self._log(f"round {self._current_round} {state.combatant.id}: wait")
|
|
|
|
def _execute_attack(
|
|
self, state: CombatantState, target: CombatantState | None, kind: str
|
|
) -> None:
|
|
if target is None or not target.active:
|
|
return
|
|
weapon = self.weapon_for(state, target)
|
|
if weapon is None:
|
|
return
|
|
if kind == "attack":
|
|
self._single_attack(state, target, weapon)
|
|
elif kind == "full_attack":
|
|
self._full_attack(state, target, weapon)
|
|
else:
|
|
self._attack(state, target, weapon)
|
|
|
|
def _execute_charge(self, state: CombatantState, target: CombatantState | None) -> None:
|
|
if target is None or not target.active:
|
|
return
|
|
weapon = self.can_charge(state, target)
|
|
if weapon is None:
|
|
return
|
|
self._charge(state, target, weapon)
|
|
|
|
def _resolve_swing(
|
|
self,
|
|
attacker: CombatantState,
|
|
target: CombatantState,
|
|
weapon: AttackSpec,
|
|
*,
|
|
label: str,
|
|
bonus_override: int | None = None,
|
|
) -> bool:
|
|
"""Resolve one swing, log it, update stats. Return True if target dropped."""
|
|
live = self._stats[attacker.combatant.id]
|
|
target_live = self._stats[target.combatant.id]
|
|
hp_before = target.hp
|
|
result = self.resolve_attack(attacker, target, weapon, bonus_override=bonus_override)
|
|
live.hits += int(result.hit)
|
|
live.crits += int(result.crit)
|
|
live.damage_dealt += result.damage
|
|
target_live.damage_taken += result.damage
|
|
outcome = "CRIT" if result.crit else "HIT" if result.hit else "MISS"
|
|
flank_str = f"+{result.flank_bonus}(flank)" if result.flank_bonus else ""
|
|
bonus_str = (
|
|
f"{result.base_bonus}{result.penalty:+d}{flank_str}"
|
|
if result.penalty
|
|
else f"{result.base_bonus}{flank_str}"
|
|
)
|
|
line = (
|
|
f"round {self._current_round} {attacker.combatant.id}: {label} "
|
|
f"vs {target.combatant.id} "
|
|
f"d20={result.roll}+{bonus_str}={result.total} "
|
|
f"AC {result.ac} -> {outcome}"
|
|
)
|
|
if result.hit:
|
|
line += f" {result.damage} damage ({hp_before}->{target.hp})"
|
|
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
|
|
return False
|
|
|
|
def _attack(
|
|
self, attacker: CombatantState, target: CombatantState, weapon: AttackSpec
|
|
) -> None:
|
|
for swing in range(1, weapon.count + 1):
|
|
if not attacker.active:
|
|
return
|
|
if self._check_provoked_aoo(attacker, weapon):
|
|
return
|
|
label = weapon.name if weapon.count == 1 else f"{weapon.name} #{swing}"
|
|
if self._resolve_swing(attacker, target, weapon, label=label):
|
|
return
|
|
|
|
def _single_attack(
|
|
self, attacker: CombatantState, target: CombatantState, weapon: AttackSpec
|
|
) -> None:
|
|
if not attacker.active:
|
|
return
|
|
if self._check_provoked_aoo(attacker, weapon):
|
|
return
|
|
self._resolve_swing(attacker, target, weapon, label=weapon.name)
|
|
|
|
def _full_attack(
|
|
self, attacker: CombatantState, target: CombatantState, weapon: AttackSpec
|
|
) -> None:
|
|
if weapon.count > 1:
|
|
self._attack(attacker, target, weapon)
|
|
return
|
|
n = min(
|
|
_MAX_ITERATIVES,
|
|
1 + max(0, (attacker.combatant.bab - 1) // _ITERATIVE_PENALTY),
|
|
)
|
|
for i in range(n):
|
|
if not attacker.active:
|
|
return
|
|
if self._check_provoked_aoo(attacker, weapon):
|
|
return
|
|
label = weapon.name if n == 1 else f"{weapon.name} #{i + 1}"
|
|
bonus = weapon.attack_bonus - _ITERATIVE_PENALTY * i
|
|
if self._resolve_swing(
|
|
attacker, target, weapon, label=label, bonus_override=bonus
|
|
):
|
|
return
|
|
|
|
def _step5(self, state: CombatantState, target: CombatantState) -> None:
|
|
if state.moved_this_turn:
|
|
return
|
|
path = self._move_path(state, target)
|
|
if not path:
|
|
return
|
|
start = state.pos
|
|
state.pos = path[0]
|
|
state.moved_this_turn = True
|
|
self._log(
|
|
f"round {self._current_round} {state.combatant.id}: "
|
|
f"5ft step ({start[0]},{start[1]})->({path[0][0]},{path[0][1]})"
|
|
)
|
|
|
|
def _move(self, state: CombatantState, target: CombatantState) -> None:
|
|
if state.moved_this_turn:
|
|
return
|
|
path = self._move_path(state, target)
|
|
if not path:
|
|
return
|
|
start = state.pos
|
|
actual_path: list[Pos] = []
|
|
for step in path:
|
|
for enemy in self._enemies_threatening(state, state.pos):
|
|
if enemy.combatant.id in self._aoo_used:
|
|
continue
|
|
self._resolve_aoo(enemy, state)
|
|
if not state.active:
|
|
break
|
|
if not state.active:
|
|
break
|
|
state.pos = step
|
|
actual_path.append(step)
|
|
if actual_path:
|
|
state.moved_this_turn = True
|
|
coords = "->".join(f"({p[0]},{p[1]})" for p in (start, *actual_path))
|
|
self._log(f"round {self._current_round} {state.combatant.id}: move {coords}")
|
|
|
|
def _move_path(self, state: CombatantState, target: CombatantState) -> list[Pos]:
|
|
"""Full movement path toward the target, up to the combatant's speed."""
|
|
speed_cells = state.combatant.speed_land_ft // _SQUARE_FT
|
|
if speed_cells <= 0:
|
|
return []
|
|
blocked = frozenset(s.pos for s in self._states if s is not state and s.active)
|
|
to_target = self._grid.reachable(target.pos, None, blocked)
|
|
if state.pos not in to_target:
|
|
return []
|
|
path: list[Pos] = []
|
|
current = state.pos
|
|
budget = speed_cells
|
|
while budget > 0:
|
|
current_cost = to_target[current]
|
|
best: tuple[int, int, Pos] | None = None
|
|
row, col = current
|
|
for d_row, d_col in _STEP_DELTAS:
|
|
nxt = (row + d_row, col + d_col)
|
|
if nxt in blocked:
|
|
continue
|
|
nxt_cost = to_target.get(nxt)
|
|
if nxt_cost is None or nxt_cost >= current_cost:
|
|
continue
|
|
step = self._grid.step_cost(current, nxt, 0)
|
|
if step > budget:
|
|
continue
|
|
score = step + nxt_cost
|
|
if best is None or score < best[0]:
|
|
best = (score, step, nxt)
|
|
if best is None:
|
|
break
|
|
current = best[2]
|
|
path.append(current)
|
|
budget -= best[1]
|
|
return path
|
|
|
|
def _flee_path(self, state: CombatantState, threat: CombatantState) -> list[Pos]:
|
|
"""Movement path away from ``threat``, up to double speed.
|
|
|
|
Uses the Dijkstra cost field from the threat and greedily picks the
|
|
step that maximizes distance (cost) from the threat each cell.
|
|
"""
|
|
max_cells = 2 * (state.combatant.speed_land_ft // _SQUARE_FT)
|
|
if max_cells <= 0:
|
|
return []
|
|
blocked = frozenset(s.pos for s in self._states if s is not state and s.active)
|
|
from_threat = self._grid.reachable(threat.pos, None, blocked)
|
|
if state.pos not in from_threat:
|
|
return []
|
|
path: list[Pos] = []
|
|
current = state.pos
|
|
budget = max_cells
|
|
while budget > 0:
|
|
best = self._best_flee_step(current, from_threat, blocked, budget)
|
|
if best is None:
|
|
break
|
|
_, step_cost, nxt = best
|
|
current = nxt
|
|
path.append(current)
|
|
budget -= step_cost
|
|
return path
|
|
|
|
def _best_flee_step(
|
|
self,
|
|
current: Pos,
|
|
from_threat: dict[Pos, int],
|
|
blocked: frozenset[Pos],
|
|
budget: int,
|
|
) -> tuple[int, int, Pos] | None:
|
|
"""Pick the adjacent square that maximizes distance from the threat."""
|
|
current_cost = from_threat[current]
|
|
best: tuple[int, int, Pos] | None = None
|
|
row, col = current
|
|
for d_row, d_col in _STEP_DELTAS:
|
|
nxt = (row + d_row, col + d_col)
|
|
if nxt in blocked or not self._grid.passable(nxt):
|
|
continue
|
|
if not self._grid.diagonal_allowed(current, nxt):
|
|
continue
|
|
nxt_cost = from_threat.get(nxt)
|
|
if nxt_cost is None or nxt_cost <= current_cost:
|
|
continue
|
|
step = self._grid.step_cost(current, nxt, 0)
|
|
if step > budget:
|
|
continue
|
|
score = -(nxt_cost * 100) + step
|
|
if best is None or score < best[0]:
|
|
best = (score, step, nxt)
|
|
return best
|
|
|
|
def _withdraw(self, state: CombatantState, threat: CombatantState | None) -> None:
|
|
"""Full-round action: move up to 2x speed away from the nearest enemy.
|
|
|
|
The starting square is not threatened — no AoO when leaving it.
|
|
Subsequent squares provoke AoOs normally (step-by-step, as for move).
|
|
"""
|
|
if threat is None:
|
|
return
|
|
path = self._flee_path(state, threat)
|
|
if not path:
|
|
return
|
|
start = state.pos
|
|
actual_path: list[Pos] = []
|
|
for i, step in enumerate(path):
|
|
if i > 0:
|
|
for enemy in self._enemies_threatening(state, state.pos):
|
|
if enemy.combatant.id in self._aoo_used:
|
|
continue
|
|
self._resolve_aoo(enemy, state)
|
|
if not state.active:
|
|
break
|
|
if not state.active:
|
|
break
|
|
state.pos = step
|
|
actual_path.append(step)
|
|
if actual_path:
|
|
state.moved_this_turn = True
|
|
coords = "->".join(f"({p[0]},{p[1]})" for p in (start, *actual_path))
|
|
self._log(
|
|
f"round {self._current_round} {state.combatant.id}: withdraw {coords}"
|
|
)
|
|
|
|
def _log(self, line: str) -> None:
|
|
self._transcript.append(line)
|
|
|
|
|
|
Policy = Callable[[CombatEngine, CombatantState], tuple[Action, ...]]
|
|
|
|
|
|
def default_policy(engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
|
|
"""Full-attack the nearest enemy; charge, 5ft-step, or approach if out of range.
|
|
|
|
Decision order:
|
|
1. Full-attack if a weapon is usable against the nearest enemy this turn (no move needed).
|
|
2. Charge if a straight-line charge path exists (2x speed, +2 attack, -2 AC).
|
|
3. 5-foot step then full-attack if a single step brings the target into weapon range.
|
|
4. Move toward the target then single attack (standard + move economy).
|
|
5. Wait if nothing is possible.
|
|
"""
|
|
target = engine.nearest_enemy(state)
|
|
if target is None:
|
|
return (Action(kind="wait"),)
|
|
if engine.weapon_for(state, target) is not None:
|
|
return (Action(kind="full_attack", target_id=target.combatant.id),)
|
|
charge_weapon = engine.can_charge(state, target)
|
|
if 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),
|
|
)
|