From 91f70f19ca1b33538959738a23141b554aa0cd1c Mon Sep 17 00:00:00 2001 From: ctan Date: Thu, 10 Sep 2026 18:15:35 +0200 Subject: [PATCH] feat(conditions): add bleeding and confused rules --- README.md | 27 ++- src/pf1e_simulator/combat.py | 132 ++++++++++++- src/pf1e_simulator/conditions.py | 18 +- tests/test_bleeding_confused.py | 322 +++++++++++++++++++++++++++++++ 4 files changed, 489 insertions(+), 10 deletions(-) create mode 100644 tests/test_bleeding_confused.py diff --git a/README.md b/README.md index 411e029..201e321 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index 2a4e964..f6a1d0e 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -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( diff --git a/src/pf1e_simulator/conditions.py b/src/pf1e_simulator/conditions.py index f92c165..d7b54e1 100644 --- a/src/pf1e_simulator/conditions.py +++ b/src/pf1e_simulator/conditions.py @@ -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, ] } diff --git a/tests/test_bleeding_confused.py b/tests/test_bleeding_confused.py new file mode 100644 index 0000000..22fbda4 --- /dev/null +++ b/tests/test_bleeding_confused.py @@ -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"] == []