fix(rules): correct P2/P3 PF1e conformance bugs (flanking, touch, blinded, disabled, death, maneuvers, DR, sneak)
This commit is contained in:
+213
-53
@@ -28,8 +28,13 @@ Phase 0 documented deviations from PF1e (conventions):
|
||||
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.
|
||||
- DR applies once, after crit multiplication; 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).
|
||||
- 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
|
||||
@@ -42,8 +47,11 @@ Phase 0 documented deviations from PF1e (conventions):
|
||||
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
|
||||
- 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
|
||||
@@ -130,7 +138,6 @@ from pf1e_simulator.los import (
|
||||
)
|
||||
|
||||
_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
|
||||
_RANGE_INCREMENT_PENALTY = 2 # PF1e: -2 per full range increment beyond the first
|
||||
@@ -174,6 +181,39 @@ _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.
|
||||
_DISABLED_MOVE_KINDS: frozenset[str] = frozenset(
|
||||
{"move", "5foot_step", "stand_up", "drop_prone", "wait"}
|
||||
)
|
||||
# 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: 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
|
||||
@@ -481,15 +521,19 @@ class CombatantState:
|
||||
|
||||
@property
|
||||
def active(self) -> bool:
|
||||
return self.hp > 0
|
||||
"""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."""
|
||||
return self.hp >= 0
|
||||
|
||||
@property
|
||||
def dead(self) -> bool:
|
||||
return self.hp < self.death_threshold
|
||||
return self.hp <= self.death_threshold
|
||||
|
||||
@property
|
||||
def death_threshold(self) -> int:
|
||||
return min(_DEATH_FLOOR, -self.combatant.abilities.con_score)
|
||||
"""PF1e (CRB): death when negative hp reaches the CON score (no -10 rule)."""
|
||||
return -self.combatant.abilities.con_score
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -1291,36 +1335,25 @@ class CombatEngine:
|
||||
state.aid_ac_bonus = 0
|
||||
state.running = False
|
||||
original = self._policy(self, state)
|
||||
actions = original
|
||||
results: list[ActionResult] = []
|
||||
if state.immediate_used:
|
||||
kept = _without_swift_bonus(actions)
|
||||
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._has_condition_named(state, "staggered"):
|
||||
kept = _staggered_filter(actions)
|
||||
results.extend(
|
||||
ActionResult(kind=kind, ok=False, reason=_REASON_DOWNGRADED_STAGGERED)
|
||||
for kind in _dropped_kinds(actions, kept)
|
||||
)
|
||||
actions = kept
|
||||
elif 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)
|
||||
)
|
||||
actions = kept
|
||||
actions = self._filter_turn_actions(state, original, results)
|
||||
before = len(self._transcript)
|
||||
was_delayed = state.delayed_this_round
|
||||
disabled = self._is_disabled(state)
|
||||
for action in actions:
|
||||
if not state.active:
|
||||
break
|
||||
results.append(self._execute(state, action))
|
||||
if disabled and state.hp == 0 and action.kind not in _DISABLED_MOVE_KINDS:
|
||||
# PF1e (CRB, Disabled): completing a standard (or otherwise
|
||||
# strenuous) action costs 1 hp — the creature slips to -1 and
|
||||
# is dying. Simplification: if the action itself healed the
|
||||
# creature above 0 hp the cost is not applied.
|
||||
state.hp -= 1
|
||||
self._log(
|
||||
f"round {self._current_round} {state.combatant.id}: "
|
||||
f"disabled: strenuous action costs 1 hp (hp 0 -> -1, dying)"
|
||||
)
|
||||
if state.delayed_this_round and not was_delayed:
|
||||
# A fresh delay forfeits the rest of the turn.
|
||||
break
|
||||
@@ -1334,6 +1367,43 @@ class CombatEngine:
|
||||
state.last_action_results = results
|
||||
return self._winner_side() is not None
|
||||
|
||||
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.
|
||||
|
||||
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; nauseated: single move only.
|
||||
Dropped kinds are reported into ``results``.
|
||||
"""
|
||||
if state.immediate_used:
|
||||
kept = _without_swift_bonus(actions)
|
||||
results.extend(
|
||||
ActionResult(kind=kind, ok=False, reason=_REASON_DOWNGRADED_IMMEDIATE_SWIFT)
|
||||
for kind in _dropped_kinds(actions, kept)
|
||||
)
|
||||
actions = kept
|
||||
state.immediate_used = False
|
||||
if self._is_disabled(state) or self._has_condition_named(state, "staggered"):
|
||||
kept = _staggered_filter(actions)
|
||||
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:
|
||||
@@ -1349,6 +1419,11 @@ class CombatEngine:
|
||||
or any(cond.name == name for cond in state.conditions)
|
||||
)
|
||||
|
||||
def _is_disabled(self, state: CombatantState) -> bool:
|
||||
"""PF1e (CRB, Effects of Death): a living creature at exactly 0 hp is
|
||||
disabled — staggered, half speed, and a strenuous action costs 1 hp."""
|
||||
return state.hp == 0
|
||||
|
||||
def _is_helpless(self, state: CombatantState) -> bool:
|
||||
"""PF1e (CRB): helpless = paralyzed, held, bound, sleeping, unconscious.
|
||||
|
||||
@@ -1389,8 +1464,12 @@ class CombatEngine:
|
||||
return False
|
||||
|
||||
def _get_speed_mult(self, state: CombatantState) -> float:
|
||||
"""Get the minimum speed multiplier from all active conditions."""
|
||||
speed_mult = 1.0
|
||||
"""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)
|
||||
@@ -1750,6 +1829,15 @@ class CombatEngine:
|
||||
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:
|
||||
@@ -1763,7 +1851,11 @@ class CombatEngine:
|
||||
"""+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.
|
||||
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)
|
||||
@@ -1775,9 +1867,11 @@ class CombatEngine:
|
||||
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 any(w.kind == "melee" for w in ally.combatant.attacks):
|
||||
if self.threatened_squares(ally) & target.occupied_cells():
|
||||
return _FLANK_BONUS
|
||||
return 0
|
||||
|
||||
@@ -1952,10 +2046,12 @@ class CombatEngine:
|
||||
roll = self._rng.d20()
|
||||
dist_ft = self.creature_distance(attacker, target) * _SQUARE_FT
|
||||
penalty = self.range_penalty(weapon, dist_ft)
|
||||
flank = self._flanking_bonus(attacker, target) if weapon.kind == "melee" else 0
|
||||
# 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 weapon.kind == "melee"
|
||||
if self._is_melee_attack(weapon)
|
||||
and self._elevation(attacker) > self._elevation(target)
|
||||
else 0
|
||||
)
|
||||
@@ -1979,10 +2075,10 @@ class CombatEngine:
|
||||
# attack and crit confirmation, target loses its Dex bonus to AC).
|
||||
if self._has_condition_named(attacker, "invisible"):
|
||||
atk_mods.append(StatModifier(target="attack", value=_INVISIBLE_ATTACK_BONUS))
|
||||
# PF1e (CRB): melee attacks against a helpless target gain +4; the
|
||||
# helpless state includes paralyzed, asleep and unconscious (stunned
|
||||
# is NOT helpless). Ranged attacks gain no bonus.
|
||||
if weapon.kind == "melee" and self._is_helpless(target):
|
||||
# PF1e (CRB): melee attacks (melee touch included) against a helpless
|
||||
# target gain +4; the helpless state includes paralyzed, asleep and
|
||||
# unconscious (stunned is NOT helpless). Ranged attacks gain no bonus.
|
||||
if self._is_melee_attack(weapon) and self._is_helpless(target):
|
||||
atk_mods.append(StatModifier(target="attack", value=_HELPLESS_MELEE_BONUS))
|
||||
attack_mod = resolve_modifiers(atk_mods)
|
||||
pa_amt = self._power_attack_amt(attacker) if weapon.kind == "melee" else 0
|
||||
@@ -2100,7 +2196,11 @@ class CombatEngine:
|
||||
+ attack_mod - pa_amt + fim_penalty + attacker.aid_attack_bonus
|
||||
+ (_FIGHT_DEFENSIVELY_ATTACK_PENALTY if attacker.fight_defensively else 0)
|
||||
)
|
||||
crit = confirm != _NATURAL_ONE and confirm_total >= ac
|
||||
# PF1e (CRB): the confirmation roll is an attack roll — a natural
|
||||
# 20 on it is an automatic hit (auto-confirm), a natural 1 miss.
|
||||
crit = confirm == _NATURAL_TWENTY or (
|
||||
confirm != _NATURAL_ONE and confirm_total >= ac
|
||||
)
|
||||
concealment = self._concealment_chance(target)
|
||||
# PF1e: a blinded character's opponents count as having total
|
||||
# concealment against it — the blinded attacker itself misses 50%.
|
||||
@@ -2166,14 +2266,19 @@ class CombatEngine:
|
||||
) -> 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) or
|
||||
denied its Dex bonus to AC (melee, or ranged within 30 ft); dice =
|
||||
(level + 1) // 2; concealment denies the sneak attack entirely.
|
||||
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; concealment denies the sneak attack
|
||||
entirely. PF1e (CRB): undead and constructs are immune to precision
|
||||
damage — ooze/plant immunity is not modeled (no such creature types
|
||||
exist in the data model).
|
||||
"""
|
||||
if concealment > 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 = weapon.kind == "melee"
|
||||
is_melee = self._is_melee_attack(weapon)
|
||||
if is_melee and self._flanking_bonus(attacker, target) > 0:
|
||||
return dice
|
||||
if denies_dex and (
|
||||
@@ -2249,12 +2354,26 @@ class CombatEngine:
|
||||
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 = {t for component in weapon.damage for t in component.types}
|
||||
if types & dr.bypass:
|
||||
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)
|
||||
|
||||
@@ -2264,7 +2383,7 @@ class CombatEngine:
|
||||
dr: DamageReduction | None = target.combatant.dr
|
||||
if dr is None:
|
||||
return damage
|
||||
if damage_types & dr.bypass:
|
||||
if self._dr_bypassed(dr, damage_types):
|
||||
return damage
|
||||
return max(0, damage - dr.amount)
|
||||
|
||||
@@ -2375,8 +2494,14 @@ class CombatEngine:
|
||||
ability_name = skill_spec.ability
|
||||
ability_score = getattr(state.combatant.abilities, f"{ability_name}_score")
|
||||
ability_mod = (ability_score - 10) // 2
|
||||
# Effect/condition modifiers
|
||||
mod_bonus = resolve_modifiers(self._stat_modifiers(state, skill))
|
||||
# Effect/condition modifiers. PF1e: modifiers targeting "dex_skills"
|
||||
# (grappled/pinned/blinded -4) apply to the Dexterity-based skills
|
||||
# listed in _DEX_BASED_SKILLS — they are stored under that aggregate
|
||||
# target, not the concrete skill name, so collect them explicitly.
|
||||
mods = self._stat_modifiers(state, skill)
|
||||
if skill in _DEX_BASED_SKILLS:
|
||||
mods.extend(self._stat_modifiers(state, "dex_skills"))
|
||||
mod_bonus = resolve_modifiers(mods)
|
||||
return rank + class_bonus + ability_mod + armor_penalty + mod_bonus
|
||||
|
||||
def _roll_initiative(self) -> list[CombatantState]:
|
||||
@@ -2807,8 +2932,11 @@ class CombatEngine:
|
||||
|
||||
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.
|
||||
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
|
||||
reach_cells = _effective_reach_ft(attacker, weapon) // _SQUARE_FT
|
||||
min_reach_cells = weapon.reach_ft // _SQUARE_FT if weapon.reach_ft > _SQUARE_FT else 0
|
||||
speed_cells = _speed_ft(attacker) // _SQUARE_FT
|
||||
@@ -3441,13 +3569,26 @@ class CombatEngine:
|
||||
):
|
||||
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. Being
|
||||
# knocked below 0 hp by that AoO forfeits the maneuver.
|
||||
if not self._has_feat(state, _IMPROVED_MANEUVER_FEATS[maneuver]):
|
||||
self._maneuver_provoked_aoo(state, target)
|
||||
if not state.active:
|
||||
return
|
||||
|
||||
attacker_cmb = self._maneuver_cmb(state)
|
||||
target_cmd = self._maneuver_cmd(target)
|
||||
roll = self._rng.d20()
|
||||
total = roll + attacker_cmb
|
||||
m = _ManeuverRoll(
|
||||
roll=roll, attacker_cmb=attacker_cmb, total=total,
|
||||
target_cmd=target_cmd, success=roll != 1 and total >= target_cmd,
|
||||
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]] = {
|
||||
@@ -3466,6 +3607,21 @@ class CombatEngine:
|
||||
}
|
||||
handlers[maneuver]()
|
||||
|
||||
def _maneuver_provoked_aoo(
|
||||
self, state: CombatantState, target: CombatantState
|
||||
) -> None:
|
||||
"""Resolve the single AoO a combat maneuver provokes from its target.
|
||||
|
||||
The target strikes only if it threatens the attacker, is able to
|
||||
make AoOs (``_resolve_aoo`` honours ``cant_aoo``) and has per-round
|
||||
AoO allowance left — same budget as movement/ranged provocation.
|
||||
"""
|
||||
if not self.threatened_squares(target) & state.occupied_cells():
|
||||
return
|
||||
if self._aoo_used.get(target.combatant.id, 0) >= self._aoo_allowance(target):
|
||||
return
|
||||
self._resolve_aoo(target, state)
|
||||
|
||||
def _retrieve_weapon(self, state: CombatantState, weapon_id: str | None) -> None:
|
||||
"""Restore a stolen weapon to its owner (remove it from ``weapon_stolen``).
|
||||
|
||||
@@ -5207,7 +5363,11 @@ class CombatEngine:
|
||||
|
||||
The starting square is not threatened — no AoO when leaving it.
|
||||
Subsequent squares provoke AoOs normally (step-by-step, as for move).
|
||||
Like every other movement it is mutually exclusive with movement
|
||||
already spent this turn (``moved_this_turn``).
|
||||
"""
|
||||
if state.moved_this_turn:
|
||||
return
|
||||
if threat is None:
|
||||
return
|
||||
path = self._flee_path(state, threat)
|
||||
|
||||
@@ -143,12 +143,15 @@ DAZZLED = Condition(
|
||||
),
|
||||
)
|
||||
|
||||
# Blinded: -2 AC, loses Dex, -4 STR/DEX skill checks (CRB)
|
||||
# Blinded: -2 AC, loses Dex, -4 Dexterity skill checks; NO flat attack
|
||||
# penalty (the -2 was a non-RAW double penalty on top of the engine's
|
||||
# modeled 50% total-concealment miss). The Str-skill half of the -4 is not
|
||||
# modeled (no str_skills target). (CRB)
|
||||
BLINDED = Condition(
|
||||
name="blinded",
|
||||
modifiers=(
|
||||
StatModifier(target="ac", value=-2),
|
||||
StatModifier(target="attack", value=-2),
|
||||
StatModifier(target="dex_skills", value=-4),
|
||||
),
|
||||
denies_dex_to_ac=True,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user