feat(conditions): add bleeding and confused rules
This commit is contained in:
@@ -159,6 +159,11 @@ _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
|
||||
_TOTAL_CONCEALMENT_PCT = 50 # PF1e: a blinded attacker's targets have total concealment
|
||||
# PF1e confused d% table: 01-25 act normally, 26-50 babble, 51-75 hit self,
|
||||
# 76-100 attack the nearest creature
|
||||
_CONFUSED_ACT_NORMAL_MAX = 25
|
||||
_CONFUSED_BABBLE_MAX = 50
|
||||
_CONFUSED_SELF_DAMAGE_MAX = 75
|
||||
_INVISIBLE_ATTACK_BONUS = 2 # PF1e: +2 attack rolls for an invisible attacker
|
||||
_HELPLESS_MELEE_BONUS = 4 # PF1e: +4 melee attack rolls (and crit confirms) vs helpless
|
||||
_AID_ANOTHER_DC = 10 # PF1e: aid another is an attack roll against AC 10
|
||||
@@ -424,6 +429,9 @@ class CombatantState:
|
||||
aid_attack_bonus: int = 0
|
||||
aid_ac_bonus: int = 0
|
||||
running: bool = False
|
||||
# Bleeding (V5b, not in the frozen data model): damage taken at the start
|
||||
# of each turn; any effect that restores hit points clears it.
|
||||
bleed_damage: int = 0
|
||||
|
||||
def usable_attacks(self) -> list[AttackSpec]:
|
||||
return [
|
||||
@@ -1042,6 +1050,7 @@ class CombatEngine:
|
||||
"aid_attack_bonus": state.aid_attack_bonus,
|
||||
"aid_ac_bonus": state.aid_ac_bonus,
|
||||
"running": state.running,
|
||||
"bleed_damage": state.bleed_damage,
|
||||
"aoo_remaining": max(
|
||||
0,
|
||||
self._aoo_allowance(state) - self._aoo_used.get(state.combatant.id, 0),
|
||||
@@ -1259,11 +1268,12 @@ class CombatEngine:
|
||||
def _take_turn(self, state: CombatantState) -> bool:
|
||||
"""Execute one combatant's full turn; return True if the battle is over."""
|
||||
self._decrement_durations(state)
|
||||
# Check cant_act flag from conditions
|
||||
if self.has_condition_flag(state, "cant_act"):
|
||||
self._log(f"round {self._current_round} {state.combatant.id}: wait (cannot act)")
|
||||
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 self._winner_side() is not None
|
||||
return gate
|
||||
self._apply_fear_aura(state)
|
||||
state.moved_this_turn = False
|
||||
state.power_attack = False
|
||||
@@ -1384,6 +1394,118 @@ class CombatEngine:
|
||||
if c.rounds_left > 1
|
||||
]
|
||||
|
||||
def _turn_gate(self, state: CombatantState) -> bool | None:
|
||||
"""Start-of-turn effects: bleed tick, confused d% roll, cant_act flag.
|
||||
|
||||
Returns None when the turn proceeds to the policy; otherwise the turn
|
||||
is forfeited and the value is the battle-over flag.
|
||||
"""
|
||||
self._apply_bleed_damage(state)
|
||||
if not state.active:
|
||||
# Bleeding knocked the combatant out (dying or dead); the winner
|
||||
# check covers the death case.
|
||||
return self._winner_side() is not None
|
||||
confused_over = self._apply_confusion(state)
|
||||
if confused_over is not None:
|
||||
return confused_over
|
||||
if self.has_condition_flag(state, "cant_act"):
|
||||
self._log(f"round {self._current_round} {state.combatant.id}: wait (cannot act)")
|
||||
return self._winner_side() is not None
|
||||
return None
|
||||
|
||||
def _apply_bleed_damage(self, state: CombatantState) -> None:
|
||||
"""PF1e bleeding: take ``bleed_damage`` at the beginning of the turn (CRB)."""
|
||||
if state.bleed_damage <= 0:
|
||||
return
|
||||
old_hp = state.hp
|
||||
state.hp -= state.bleed_damage
|
||||
self._log(
|
||||
f"round {self._current_round} {state.combatant.id}: "
|
||||
f"bleed {state.bleed_damage} (hp {old_hp}->{state.hp})"
|
||||
)
|
||||
|
||||
def _apply_confusion(self, state: CombatantState) -> bool | None:
|
||||
"""PF1e confused: roll d% at the start of the turn to act randomly (CRB).
|
||||
|
||||
Returns None when the subject acts normally (01-25, the turn proceeds);
|
||||
otherwise the turn is consumed and the value is the battle-over flag.
|
||||
Simplification: the "if attacked, attack the attacker" clause is
|
||||
reduced to attacking the nearest creature.
|
||||
"""
|
||||
if not self._has_condition_named(state, "confused"):
|
||||
return None
|
||||
pct = self._rng.d100()
|
||||
if pct <= _CONFUSED_ACT_NORMAL_MAX:
|
||||
return None
|
||||
if pct <= _CONFUSED_BABBLE_MAX:
|
||||
self._log(
|
||||
f"round {self._current_round} {state.combatant.id}: confused: babbles"
|
||||
)
|
||||
elif pct <= _CONFUSED_SELF_DAMAGE_MAX:
|
||||
self._confused_self_damage(state)
|
||||
else:
|
||||
self._confused_attack_nearest(state)
|
||||
return self._winner_side() is not None
|
||||
|
||||
def _confused_self_damage(self, state: CombatantState) -> None:
|
||||
"""Confused 51-75: 1d8 + Str modifier damage to self with item in hand."""
|
||||
dmg = self._rng.roll_die(8) + state.combatant.abilities.str_mod
|
||||
old_hp = state.hp
|
||||
state.hp -= dmg
|
||||
self._log(
|
||||
f"round {self._current_round} {state.combatant.id}: "
|
||||
f"confused: hits self for {dmg} (hp {old_hp}->{state.hp})"
|
||||
)
|
||||
|
||||
def _confused_attack_nearest(self, state: CombatantState) -> None:
|
||||
"""Confused 76-100: attack the nearest creature (friend OR foe) once."""
|
||||
target = self._nearest_creature(state)
|
||||
weapon = None
|
||||
if target is not None:
|
||||
weapon = next(
|
||||
(
|
||||
w
|
||||
for w in state.usable_attacks()
|
||||
if w.kind == "melee" and self._melee_weapon_usable(state, target, w)
|
||||
),
|
||||
None,
|
||||
)
|
||||
if target is None or weapon is None:
|
||||
self._log(
|
||||
f"round {self._current_round} {state.combatant.id}: "
|
||||
f"confused: attacks nearest but nothing in reach"
|
||||
)
|
||||
return
|
||||
self._log(
|
||||
f"round {self._current_round} {state.combatant.id}: "
|
||||
f"confused: attacks {target.combatant.id}"
|
||||
)
|
||||
self._single_attack(state, target, weapon)
|
||||
|
||||
def _nearest_creature(self, state: CombatantState) -> CombatantState | None:
|
||||
"""Closest active creature — friend OR foe — by grid distance."""
|
||||
others = [s for s in self._states if s is not state and s.active]
|
||||
if not others:
|
||||
return None
|
||||
return min(
|
||||
others,
|
||||
key=lambda s: (self.creature_distance(state, s), self._states.index(s)),
|
||||
)
|
||||
|
||||
def _stop_bleeding(self, state: CombatantState) -> None:
|
||||
"""PF1e: applying a spell that cures hit point damage stops bleeding (CRB).
|
||||
|
||||
Simplification: the DC 15 Heal check alternative is not modeled
|
||||
(the engine has no Heal skill action).
|
||||
"""
|
||||
state.bleed_damage = 0
|
||||
state.active_conditions[:] = [
|
||||
ac for ac in state.active_conditions if ac.condition.name != "bleeding"
|
||||
]
|
||||
state.conditions[:] = [
|
||||
cond for cond in state.conditions if cond.name != "bleeding"
|
||||
]
|
||||
|
||||
def run(self) -> CombatResult:
|
||||
"""Run the battle and return the final result and transcript."""
|
||||
self._roll_initiative()
|
||||
@@ -3719,6 +3841,7 @@ class CombatEngine:
|
||||
affected_state.hp = min(affected_state.hp + dmg, affected_state.combatant.hp_max)
|
||||
actual = affected_state.hp - old_hp
|
||||
self._stats[state.combatant.id].damage_dealt += actual
|
||||
self._stop_bleeding(affected_state)
|
||||
self._log(
|
||||
f"round {self._current_round} {state.combatant.id}: "
|
||||
f"channel energy heals {affected_state.combatant.id} +{actual} HP"
|
||||
@@ -4383,6 +4506,7 @@ class CombatEngine:
|
||||
old_hp = target.hp
|
||||
target.hp = min(target.hp + heal, target.combatant.hp_max)
|
||||
actual = target.hp - old_hp
|
||||
self._stop_bleeding(target)
|
||||
return f"+{actual} HP"
|
||||
|
||||
def _apply_buff_effect(
|
||||
|
||||
@@ -288,6 +288,22 @@ INVISIBLE = Condition(
|
||||
miss_chance=50,
|
||||
)
|
||||
|
||||
# ── Damage-over-time and mental conditions ───────────────────────────────────
|
||||
# Bleeding: takes the listed bleed damage at the beginning of its turn (CRB).
|
||||
# The damage-over-time is engine-driven (CombatantState.bleed_damage); the
|
||||
# condition itself carries no stat modifiers or behavioral flags.
|
||||
BLEEDING = Condition(
|
||||
name="bleeding",
|
||||
)
|
||||
|
||||
# Confused: at the beginning of each turn the subject rolls d% to act randomly
|
||||
# (CRB). The d% behavior is engine logic in ``_take_turn``; simplification:
|
||||
# the "if attacked, attack the attacker" clause is reduced to "nearest
|
||||
# creature". No stat modifiers or behavioral flags.
|
||||
CONFUSED = Condition(
|
||||
name="confused",
|
||||
)
|
||||
|
||||
# ── Registry ─────────────────────────────────────────────────────────────────
|
||||
CONDITIONS_BY_NAME: dict[str, Condition] = {
|
||||
c.name: c
|
||||
@@ -296,6 +312,6 @@ CONDITIONS_BY_NAME: dict[str, Condition] = {
|
||||
BLINDED, DEAFENED, FLAT_FOOTED, PRONE, STUNNED, PARALYZED,
|
||||
NAUSEATED, DAZED, STAGGERED, FRIGHTENED, PANICKED, COWERING,
|
||||
ASLEEP, GRAPPLED, PINNED, BLURRED, DISPLACED,
|
||||
HELPLESS, FASCINATED, INVISIBLE,
|
||||
HELPLESS, FASCINATED, INVISIBLE, BLEEDING, CONFUSED,
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user