feat(conditions): add bleeding and confused rules
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"] == []
|
||||||
Reference in New Issue
Block a user