b84238792c
PF1e (SRD spells/cure-light-wounds.md): 'cures 1d8 points of damage +1 point per caster level (maximum +5)'; (spells/cure-moderate-wounds.md): 'cures 2d8 points of damage +1 point per caster level (maximum +10)'. A HealEffect now carries the same DamageFlatScaling spec as damage effects (S1b) and resolves it through effective_formula(caster_level) at cast time, so the flat bonus grows with the caster and stops at the stated cap. The engine passes the caster's level into _apply_heal_effect (required keyword: no silent level-0 under-heal); Maximize Spell maximizes the scaled formula. Cure Light/Moderate Wounds ship the SRD values; inflict_light_wounds already carried its scaling_flat. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
324 lines
10 KiB
Python
324 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, caster_level=1,
|
|
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"] == []
|