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
+22 -5
View File
@@ -636,7 +636,7 @@ Règles modélisées :
- Invocation : les créatures invoquées agissent (calées sur la case d'initiative de
leur conjurateur via `_build_round_order`) puis expirent après leur `summon_duration`
(`_expire_summons` décrémente puis retire).
- Conditions effectives : 26 conditions prédéfinies (`conditions.py`). Leurs drapeaux
- Conditions effectives : 28 conditions prédéfinies (`conditions.py`). Leurs drapeaux
comportementaux sont appliqués par le moteur : `cant_act` (étourdi, paralysé, dazed,
endormi, inerme, fasciné) annule le tour ; `cant_attack` (nauséeux) n'autorise qu'une unique action
simple et bloque toute attaque / manœuvre ; `staggered` restreint à une action
@@ -650,6 +650,20 @@ Règles modélisées :
contre lui (voir « Cible inerme »). Fasciné (`fascinated`) : ne peut pas agir.
Invisible (`invisible`) : concealment total subi +2 attaquant / perte de Dex
de la cible (voir « Attaquant invisible »).
Saignement (`bleeding`, V5b) : une créature qui saigne subit `bleed_damage`
points de dégâts au **début de son tour** (avant politique et flags) ; tout
effet restaurant des points de vie (sort de soin, channel energy) stoppe le
saignement (`bleed_damage = 0`) et retire la condition. Simplification
documentée : le jet de compétence Soin DD 15 n'est pas modélisé (le moteur
n'a pas d'action Soin).
Confus (`confused`, V5b) : au début de chaque tour, jet d% — 01-25 agit
normalement (le tour se poursuit) ; 26-50 bave incohéremment (tour perdu) ;
51-75 se inflige 1d8 + modificateur de Force avec l'objet tenu ; 76-100
attaque d'une seule attaque de mêlée la créature la plus proche (allié OU
ennemi, une seule si en allonge ; sinon le tour est perdu, journalisé).
Simplification documentée : la clause « si elle est blessée, riposte
l'agresseur » est réduite à « attaque la créature la plus proche » ; le coup
de grâce n'est pas modélisé.
- Manœuvres de combat : trip, disarm, bull_rush, sunder, grapple, escape,
overrun, dirty trick, steal, reposition et drag sont modélisées, chacune
comme une tentative unique (1d20 + CMB contre CMD) :
@@ -753,7 +767,9 @@ Règles modélisées :
début de son tour s'il est inutilisé), aid_ac_bonus (int — bonus d'aide
autrui (+2 par aide réussie, cumulatif) ajouté à sa CA, expiré au début de
son tour), running (bool — course en cours jusqu'au prochain tour du
combattant : il perd son bonus de Dextérité à la CA).
combattant : il perd son bonus de Dextérité à la CA), bleed_damage (int —
dégâts de saignement subis au début du tour du combattant, 0 = ne saigne
pas ; V5b ; `bleeding`/`confused` sont visibles via `conditions`).
- Par paire combattant actif × cible : target_index, distance_ft, weapon_for,
range_penalty, can_charge, can_step5_to_attack, has_line_of_effect,
flanking, movement_cost_ft, target_threatens_me, enemies_threatening_me,
@@ -781,7 +797,7 @@ Règles modélisées :
case de leur conjurateur),
terrain_semantics (un dict par type de terrain : type, move_cost,
blocks_los, cover, cover_bonus), condition_definitions (C2 : tuple trié de (nom,
{mods, flags, speed_mult, miss_chance}) — définitions complètes des 26
{mods, flags, speed_mult, miss_chance}) — définitions complètes des 28
conditions de CONDITIONS_BY_NAME pour que la LLM interprète les drapeaux,
les modificateurs et les chances de rater (concealment)).
Les helpers publics du moteur (L5) sont désormais embarqués EN DONNÉES dans
@@ -841,10 +857,11 @@ la lecture mais non appliquée dans la résolution.
enhancement…) gardent la valeur la plus élevée. Les pénalités suivent les
mêmes règles. Fondation pour sorts, conditions, dons et capacités de classe.
- `conditions.py` — conditions PF1e : `Condition` (dataclass avec
modificateurs + flags comportementaux + `miss_chance`) et 26 conditions prédéfinies
modificateurs + flags comportementaux + `miss_chance`) et 28 conditions prédéfinies
(shaken, sickened, fatigued, exhausted, entangled, dazzled, blinded,
deafened, flat-footed, prone, stunned, paralyzed, nauseated, dazed,
staggered, frightened, panicked, cowering, helpless, fascinated, invisible).
staggered, frightened, panicked, cowering, helpless, fascinated, invisible,
bleeding, confused).
Les modificateurs sont intégrés
à `resolve_attack` (attaque, dégâts, CA) et `resolve_save` (jets de
sauvegarde) via `_stat_modifiers` qui collecte effets + conditions ; les
+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,
]
}
+322
View File
@@ -0,0 +1,322 @@
"""V5b conditions: bleeding damage-over-time and confused random action.
Canonical PF1e (Core Rulebook, Conditions): a bleeding creature takes the
listed bleed damage at the beginning of its turn, stopped by any spell that
cures hit point damage (the DC 15 Heal check is not modeled — no Heal
action). A confused subject rolls d% at the beginning of each turn: 01-25
act normally, 26-50 babble incoherently, 51-75 deal 1d8 + Str modifier to
self, 76-100 attack the nearest creature (simplification: the "attack the
attacker" clause is reduced to "nearest creature").
"""
from __future__ import annotations
from pf1e_simulator.conditions import (
BLEEDING,
CONDITIONS_BY_NAME,
CONFUSED,
)
from pf1e_simulator.dice import parse_dice
from pf1e_simulator.models import (
AbilityScores,
ACProfile,
Combatant,
Saves,
)
from pf1e_simulator.spells import HealEffect
from tests.test_accuracy_rules import (
_add_condition,
_combatant,
_engine,
_melee,
)
from tests.test_observation_enriched import _snapshot
# ── Bleeding ─────────────────────────────────────────────────────────────────
def test_bleed_damage_applied_at_turn_start() -> None:
eng = _engine(
[_combatant("a"), _combatant("b")],
[(0, 0), (0, 1)],
["players", "monsters"],
[15, 4],
)
a, b = eng._states
a.bleed_damage = 3
eng._take_turn(a)
# Bleed lands before the policy's full attack (d20 15+5 hits AC 10, 1d6=4).
assert a.hp == 97
assert b.hp == 96
assert any("a: bleed 3 (hp 100->97)" in line for line in eng._transcript)
bleed_idx = next(i for i, ln in enumerate(eng._transcript) if "bleed" in ln)
attack_idx = next(i for i, ln in enumerate(eng._transcript) if "vs b" in ln)
assert bleed_idx < attack_idx
def test_bleed_applies_even_when_cannot_act() -> None:
eng = _engine(
[_combatant("a"), _combatant("b")],
[(0, 0), (0, 1)],
["players", "monsters"],
[],
)
a, _b = eng._states
_add_condition(a, "stunned")
a.bleed_damage = 2
over = eng._take_turn(a)
# Empty RNG queue: bleed and the cant_act forfeit consume no dice.
assert a.hp == 98
assert over is False
assert any("a: bleed 2 (hp 100->98)" in line for line in eng._transcript)
assert any("a: wait (cannot act)" in line for line in eng._transcript)
def test_bleed_to_death_ends_battle() -> None:
eng = _engine(
[_combatant("a"), _combatant("b")],
[(0, 0), (0, 1)],
["players", "monsters"],
[],
)
a, _b = eng._states
a.hp = -8 # dying, con 10 -> death threshold min(-10, -10) = -10
a.bleed_damage = 5
over = eng._take_turn(a)
assert a.hp == -13
assert a.dead is True
assert over is True # b is the only active side
assert any("a: bleed 5 (hp -8->-13)" in line for line in eng._transcript)
def test_heal_spell_clears_bleed() -> None:
eng = _engine(
[_combatant("a"), _combatant("b")],
[(0, 0), (0, 7)],
["players", "monsters"],
[5],
)
a, _b = eng._states
a.hp = 50
a.bleed_damage = 4
_add_condition(a, "bleeding")
a.conditions.append(BLEEDING)
result = eng._apply_heal_effect(
HealEffect(formula=parse_dice("1d8")), a, empower=False, maximize=False
)
assert result == "+5 HP"
assert a.hp == 55
assert a.bleed_damage == 0
assert not any(ac.condition.name == "bleeding" for ac in a.active_conditions)
assert not any(cond.name == "bleeding" for cond in a.conditions)
def test_channel_energy_clears_bleed() -> None:
eng = _engine(
[_combatant("a"), _combatant("b")],
[(0, 0), (0, 1)],
["players", "monsters"],
[],
)
caster, target = eng._states
target.hp = 40
target.bleed_damage = 6
_add_condition(target, "bleeding")
eng._channel_heal(caster, target, 10)
assert target.hp == 50
assert target.bleed_damage == 0
assert not any(ac.condition.name == "bleeding" for ac in target.active_conditions)
# ── Confused ─────────────────────────────────────────────────────────────────
def _strong_combatant(cid: str) -> Combatant:
"""A combatant with Str 16 (+3 modifier) for the confused self-harm roll."""
return Combatant(
id=cid,
name=cid,
level=1,
size="Medium",
abilities=AbilityScores(
str_score=16, dex_score=10, con_score=10,
int_score=10, wis_score=10, cha_score=10,
),
hp_max=100,
ac=ACProfile(total=10, touch=10, flat_footed=10),
bab=1,
initiative_mod=0,
speed_land_ft=30,
saves=Saves(fort=0, ref=0, will=0),
attacks=[_melee(cid)],
)
def test_confused_acts_normally_on_01_25() -> None:
eng = _engine(
[_combatant("a"), _combatant("b")],
[(0, 0), (0, 1)],
["players", "monsters"],
[10, 15, 4],
)
a, b = eng._states
_add_condition(a, "confused")
eng._take_turn(a)
# d100 10 -> act normally: the policy's full attack runs (15+5 hits, 4 dmg).
assert b.hp == 96
assert a.hp == 100
assert any("vs b" in line and "HIT" in line for line in eng._transcript)
assert not any("confused:" in line for line in eng._transcript)
def test_confused_babbles_on_26_50() -> None:
eng = _engine(
[_combatant("a"), _combatant("b")],
[(0, 0), (0, 1)],
["players", "monsters"],
[30],
)
a, b = eng._states
_add_condition(a, "confused")
over = eng._take_turn(a)
# Only the d100 consumed: no attack roll, no damage anywhere.
assert over is False
assert a.hp == 100
assert b.hp == 100
assert any("a: confused: babbles" in line for line in eng._transcript)
assert not any("vs" in line for line in eng._transcript)
def test_confused_hits_self_on_51_75() -> None:
eng = _engine(
[_strong_combatant("a"), _combatant("b")],
[(0, 0), (0, 1)],
["players", "monsters"],
[60, 5],
)
a, b = eng._states
_add_condition(a, "confused")
over = eng._take_turn(a)
# 1d8 (5) + Str mod (+3) = 8 damage to self, no attack roll consumed.
assert over is False
assert a.hp == 92
assert b.hp == 100
assert any(
"a: confused: hits self for 8 (hp 100->92)" in line
for line in eng._transcript
)
def test_confused_self_damage_knocks_out_and_ends_battle() -> None:
eng = _engine(
[_strong_combatant("a"), _combatant("b")],
[(0, 0), (0, 1)],
["players", "monsters"],
[60, 8],
)
a, _b = eng._states
a.hp = 2
_add_condition(a, "confused")
over = eng._take_turn(a)
# 1d8 (8) + Str mod (+3) = 11 -> hp -9: dying (threshold -10), not dead;
# only b's side is active, so the winner check ends the battle cleanly.
assert a.hp == -9
assert a.dead is False
assert a.active is False
assert over is True
def test_confused_attacks_nearest_creature_friend_or_foe_on_76_100() -> None:
eng = _engine(
[_combatant("a"), _combatant("b"), _combatant("c")],
[(0, 0), (0, 3), (0, 1)],
["players", "monsters", "players"],
[90, 15, 4],
)
a, b, c = eng._states
_add_condition(a, "confused")
over = eng._take_turn(a)
# Nearest creature is the ALLY c (1 cell) not the enemy b (3 cells):
# d20 15+5 hits AC 10 for 4 damage.
assert over is False
assert c.hp == 96
assert b.hp == 100
assert a.hp == 100
assert any("a: confused: attacks c" in line for line in eng._transcript)
assert any("vs c" in line and "HIT" in line for line in eng._transcript)
assert not any("vs b" in line for line in eng._transcript)
def test_confused_attack_nearest_nothing_in_reach() -> None:
eng = _engine(
[_combatant("a"), _combatant("b")],
[(0, 0), (0, 7)],
["players", "monsters"],
[90],
)
a, b = eng._states
_add_condition(a, "confused")
over = eng._take_turn(a)
# Only the d100 consumed: the nearest creature is out of melee reach.
assert over is False
assert a.hp == 100
assert b.hp == 100
assert any(
"a: confused: attacks nearest but nothing in reach" in line
for line in eng._transcript
)
assert not any("vs" in line for line in eng._transcript)
def test_confused_ignored_without_condition() -> None:
eng = _engine(
[_combatant("a"), _combatant("b")],
[(0, 0), (0, 1)],
["players", "monsters"],
[15, 4],
)
a, b = eng._states
eng._take_turn(a)
# No confused condition: no d% roll is consumed (queue starts at the d20).
assert b.hp == 96
assert not any("confused" in line for line in eng._transcript)
# ── Registration + snapshot exposure ─────────────────────────────────────────
def test_new_conditions_registered() -> None:
assert CONDITIONS_BY_NAME["bleeding"] is BLEEDING
assert CONDITIONS_BY_NAME["confused"] is CONFUSED
assert BLEEDING.modifiers == ()
assert BLEEDING.cant_act is False
assert BLEEDING.denies_dex_to_ac is False
assert BLEEDING.miss_chance == 0
assert CONFUSED.modifiers == ()
assert CONFUSED.cant_act is False
assert CONFUSED.miss_chance == 0
def test_snapshot_exposes_bleed_damage() -> None:
eng = _engine(
[_combatant("a"), _combatant("b")],
[(0, 0), (0, 3)],
["players", "monsters"],
[],
)
a, _b = eng._states
snap = _snapshot(eng, a)
mine = next(c for c in snap["combatants"] if c["id"] == "a")
assert mine["bleed_damage"] == 0
a.bleed_damage = 7
_add_condition(a, "bleeding")
_add_condition(a, "confused")
snap = _snapshot(eng, a)
mine = next(c for c in snap["combatants"] if c["id"] == "a")
assert mine["bleed_damage"] == 7
names = {name for name, _rounds in mine["conditions"]}
assert {"bleeding", "confused"} <= names
defs = dict(snap["condition_definitions"])
assert len(defs) == len(CONDITIONS_BY_NAME)
assert defs["bleeding"]["flags"] == []
assert defs["confused"]["flags"] == []