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
|
||||
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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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