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 - 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` leur conjurateur via `_build_round_order`) puis expirent après leur `summon_duration`
(`_expire_summons` décrémente puis retire). (`_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, 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 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 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. contre lui (voir « Cible inerme »). Fasciné (`fascinated`) : ne peut pas agir.
Invisible (`invisible`) : concealment total subi +2 attaquant / perte de Dex Invisible (`invisible`) : concealment total subi +2 attaquant / perte de Dex
de la cible (voir « Attaquant invisible »). 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, - Manœuvres de combat : trip, disarm, bull_rush, sunder, grapple, escape,
overrun, dirty trick, steal, reposition et drag sont modélisées, chacune overrun, dirty trick, steal, reposition et drag sont modélisées, chacune
comme une tentative unique (1d20 + CMB contre CMD) : 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 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 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 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, - Par paire combattant actif × cible : target_index, distance_ft, weapon_for,
range_penalty, can_charge, can_step5_to_attack, has_line_of_effect, range_penalty, can_charge, can_step5_to_attack, has_line_of_effect,
flanking, movement_cost_ft, target_threatens_me, enemies_threatening_me, flanking, movement_cost_ft, target_threatens_me, enemies_threatening_me,
@@ -781,7 +797,7 @@ Règles modélisées :
case de leur conjurateur), case de leur conjurateur),
terrain_semantics (un dict par type de terrain : type, move_cost, terrain_semantics (un dict par type de terrain : type, move_cost,
blocks_los, cover, cover_bonus), condition_definitions (C2 : tuple trié de (nom, 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, conditions de CONDITIONS_BY_NAME pour que la LLM interprète les drapeaux,
les modificateurs et les chances de rater (concealment)). les modificateurs et les chances de rater (concealment)).
Les helpers publics du moteur (L5) sont désormais embarqués EN DONNÉES dans 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 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. mêmes règles. Fondation pour sorts, conditions, dons et capacités de classe.
- `conditions.py` — conditions PF1e : `Condition` (dataclass avec - `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, (shaken, sickened, fatigued, exhausted, entangled, dazzled, blinded,
deafened, flat-footed, prone, stunned, paralyzed, nauseated, dazed, 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 Les modificateurs sont intégrés
à `resolve_attack` (attaque, dégâts, CA) et `resolve_save` (jets de à `resolve_attack` (attaque, dégâts, CA) et `resolve_save` (jets de
sauvegarde) via `_stat_modifiers` qui collecte effets + conditions ; les 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_AC_BONUS = 2 # PF1e: fighting defensively grants +2 dodge AC
_FIGHT_DEFENSIVELY_ATTACK_PENALTY = -4 # PF1e: and -4 on attacks / crit confirms _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 _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 _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 _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 _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_attack_bonus: int = 0
aid_ac_bonus: int = 0 aid_ac_bonus: int = 0
running: bool = False 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]: def usable_attacks(self) -> list[AttackSpec]:
return [ return [
@@ -1042,6 +1050,7 @@ class CombatEngine:
"aid_attack_bonus": state.aid_attack_bonus, "aid_attack_bonus": state.aid_attack_bonus,
"aid_ac_bonus": state.aid_ac_bonus, "aid_ac_bonus": state.aid_ac_bonus,
"running": state.running, "running": state.running,
"bleed_damage": state.bleed_damage,
"aoo_remaining": max( "aoo_remaining": max(
0, 0,
self._aoo_allowance(state) - self._aoo_used.get(state.combatant.id, 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: def _take_turn(self, state: CombatantState) -> bool:
"""Execute one combatant's full turn; return True if the battle is over.""" """Execute one combatant's full turn; return True if the battle is over."""
self._decrement_durations(state) self._decrement_durations(state)
# Check cant_act flag from conditions gate = self._turn_gate(state)
if self.has_condition_flag(state, "cant_act"): if gate is not None:
self._log(f"round {self._current_round} {state.combatant.id}: wait (cannot act)") # A start-of-turn effect (bleed, confusion, cant_act) forfeited
# the turn; the value is the battle-over flag.
state.last_action_results = [] state.last_action_results = []
return self._winner_side() is not None return gate
self._apply_fear_aura(state) self._apply_fear_aura(state)
state.moved_this_turn = False state.moved_this_turn = False
state.power_attack = False state.power_attack = False
@@ -1384,6 +1394,118 @@ class CombatEngine:
if c.rounds_left > 1 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: def run(self) -> CombatResult:
"""Run the battle and return the final result and transcript.""" """Run the battle and return the final result and transcript."""
self._roll_initiative() self._roll_initiative()
@@ -3719,6 +3841,7 @@ class CombatEngine:
affected_state.hp = min(affected_state.hp + dmg, affected_state.combatant.hp_max) affected_state.hp = min(affected_state.hp + dmg, affected_state.combatant.hp_max)
actual = affected_state.hp - old_hp actual = affected_state.hp - old_hp
self._stats[state.combatant.id].damage_dealt += actual self._stats[state.combatant.id].damage_dealt += actual
self._stop_bleeding(affected_state)
self._log( self._log(
f"round {self._current_round} {state.combatant.id}: " f"round {self._current_round} {state.combatant.id}: "
f"channel energy heals {affected_state.combatant.id} +{actual} HP" f"channel energy heals {affected_state.combatant.id} +{actual} HP"
@@ -4383,6 +4506,7 @@ class CombatEngine:
old_hp = target.hp old_hp = target.hp
target.hp = min(target.hp + heal, target.combatant.hp_max) target.hp = min(target.hp + heal, target.combatant.hp_max)
actual = target.hp - old_hp actual = target.hp - old_hp
self._stop_bleeding(target)
return f"+{actual} HP" return f"+{actual} HP"
def _apply_buff_effect( def _apply_buff_effect(
+17 -1
View File
@@ -288,6 +288,22 @@ INVISIBLE = Condition(
miss_chance=50, 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 ───────────────────────────────────────────────────────────────── # ── Registry ─────────────────────────────────────────────────────────────────
CONDITIONS_BY_NAME: dict[str, Condition] = { CONDITIONS_BY_NAME: dict[str, Condition] = {
c.name: c c.name: c
@@ -296,6 +312,6 @@ CONDITIONS_BY_NAME: dict[str, Condition] = {
BLINDED, DEAFENED, FLAT_FOOTED, PRONE, STUNNED, PARALYZED, BLINDED, DEAFENED, FLAT_FOOTED, PRONE, STUNNED, PARALYZED,
NAUSEATED, DAZED, STAGGERED, FRIGHTENED, PANICKED, COWERING, NAUSEATED, DAZED, STAGGERED, FRIGHTENED, PANICKED, COWERING,
ASLEEP, GRAPPLED, PINNED, BLURRED, DISPLACED, 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"] == []