Files
pf1e-simulator/tests/test_bleeding_confused.py
T

323 lines
10 KiB
Python

"""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"] == []