feat(conditions): add bleeding and confused rules

This commit is contained in:
2026-09-10 18:15:35 +02:00
parent b3bff85215
commit 91f70f19ca
4 changed files with 489 additions and 10 deletions
+128 -4
View File
@@ -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(
+17 -1
View File
@@ -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,
]
}