810 lines
29 KiB
Python
810 lines
29 KiB
Python
"""Tests for class abilities emitted by default_policy, gated on resources.
|
|
|
|
Covers the 4 PC resource pools: oni (channel_energy), nairda (hex),
|
|
tammara (deeds), misty (spell_combat + inspire_courage). Each class has an
|
|
"emit when available" and a "silent when exhausted" test.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Literal
|
|
|
|
from pf1e_simulator.combat import Action, CombatantState, CombatEngine, default_policy
|
|
from pf1e_simulator.dice import parse_dice
|
|
from pf1e_simulator.effects import ActiveBuff, StatModifier, resolve_modifiers
|
|
from pf1e_simulator.grid import Grid
|
|
from pf1e_simulator.map import MapSpec, TerrainType
|
|
from pf1e_simulator.models import (
|
|
AbilityScores,
|
|
ACProfile,
|
|
AttackSpec,
|
|
Combatant,
|
|
DamageComponent,
|
|
Saves,
|
|
)
|
|
from pf1e_simulator.rng import ScriptedRng
|
|
from pf1e_simulator.spells import SPELLS_DIR, HealEffect, SpellSpec, load_spell_registry
|
|
|
|
|
|
def _combatant(
|
|
cid: str,
|
|
*,
|
|
resources: dict[str, int] | None = None,
|
|
spells: list[str] | None = None,
|
|
spell_slots: dict[str, int] | None = None,
|
|
hp_max: int = 20,
|
|
creature_type: Literal["living", "undead", "construct"] = "living",
|
|
firearm: bool = False,
|
|
) -> Combatant:
|
|
if firearm:
|
|
# Tammara-style: a firearm (pistol) as her ONLY weapon.
|
|
attack = AttackSpec(
|
|
id=f"{cid}-w",
|
|
name="pistol",
|
|
kind="touch",
|
|
attack_bonus=3,
|
|
damage=[DamageComponent(formula=parse_dice("1d6"), types=["piercing"])],
|
|
range_increment_ft=20,
|
|
firearm=True,
|
|
)
|
|
else:
|
|
attack = AttackSpec(
|
|
id=f"{cid}-w",
|
|
name="sword",
|
|
kind="melee",
|
|
attack_bonus=3,
|
|
damage=[DamageComponent(formula=parse_dice("1d6"), types=["slashing"])],
|
|
)
|
|
return Combatant(
|
|
id=cid,
|
|
name=cid,
|
|
level=5,
|
|
size="Medium",
|
|
abilities=AbilityScores(
|
|
str_score=14, dex_score=14, con_score=12,
|
|
int_score=14, wis_score=12, cha_score=14,
|
|
),
|
|
hp_max=hp_max,
|
|
ac=ACProfile(total=15, touch=12, flat_footed=14),
|
|
bab=3,
|
|
initiative_mod=2,
|
|
speed_land_ft=30,
|
|
saves=Saves(fort=4, ref=3, will=4),
|
|
attacks=[attack],
|
|
resources=resources or {},
|
|
spells=spells or [],
|
|
spell_slots=spell_slots or {},
|
|
creature_type=creature_type,
|
|
)
|
|
|
|
|
|
def _state(
|
|
combatant: Combatant, side: str, pos: tuple[int, int], hp: int | None = None
|
|
) -> CombatantState:
|
|
state = CombatantState(
|
|
combatant=combatant, side=side, pos=pos, hp=hp if hp is not None else combatant.hp_max
|
|
)
|
|
state.resources_left = dict(combatant.resources)
|
|
return state
|
|
|
|
|
|
def _grid() -> Grid:
|
|
legend = {".": TerrainType(type="floor", move_cost=1)}
|
|
spec = MapSpec(name="test", terrain=tuple(["." * 12] * 12), legend=legend)
|
|
return Grid.from_spec(spec)
|
|
|
|
|
|
def _engine(
|
|
combatant: Combatant,
|
|
enemies: list[Combatant],
|
|
*,
|
|
spell_registry: dict[str, SpellSpec] | None = None,
|
|
rng_queue: list[int] | None = None,
|
|
) -> tuple[CombatEngine, CombatantState, list[CombatantState]]:
|
|
me = _state(combatant, "players", (5, 5))
|
|
enemy_states = [
|
|
_state(e, "monsters", (5, 5 + i + 1)) for i, e in enumerate(enemies)
|
|
]
|
|
states = [me, *enemy_states]
|
|
engine = CombatEngine(
|
|
ScriptedRng(rng_queue or []), _grid(), states, spell_registry=spell_registry
|
|
)
|
|
return engine, me, enemy_states
|
|
|
|
|
|
# --- oni: channel_energy ---
|
|
|
|
|
|
def test_oni_emits_channel_energy_when_ally_hurt() -> None:
|
|
oni = _combatant("oni", resources={"channel_energy": 6}, hp_max=30)
|
|
goblin = _combatant("goblin", hp_max=6)
|
|
engine, me, _ = _engine(oni, [goblin])
|
|
hurt_ally = _state(_combatant("ally", hp_max=20), "players", (5, 5), hp=5)
|
|
engine.states.append(hurt_ally)
|
|
|
|
actions = default_policy(engine, me)
|
|
|
|
assert any(a.kind == "channel_energy" for a in actions)
|
|
|
|
|
|
def test_oni_channel_heal_triggers_below_half_of_odd_hp_max() -> None:
|
|
"""Strictly-below-half threshold: hp_max 5 at hp 2 satisfies 2*2 < 5 but
|
|
failed the floored old check hp < hp_max // 2 (2 < 2), leaving a 40%-HP
|
|
ally untreated."""
|
|
oni = _combatant("oni", resources={"channel_energy": 6}, hp_max=30)
|
|
goblin = _combatant("goblin", hp_max=6)
|
|
engine, me, _ = _engine(oni, [goblin])
|
|
hurt_ally = _state(_combatant("halfling", hp_max=5), "players", (5, 5), hp=2)
|
|
engine.states.append(hurt_ally)
|
|
|
|
actions = default_policy(engine, me)
|
|
|
|
assert any(
|
|
a.kind == "channel_energy" and a.channel_mode == "heal" for a in actions
|
|
)
|
|
|
|
|
|
def test_oni_silent_when_channel_energy_exhausted() -> None:
|
|
oni = _combatant("oni", resources={"channel_energy": 0}, hp_max=30)
|
|
goblin = _combatant("goblin", hp_max=6)
|
|
engine, me, _ = _engine(oni, [goblin])
|
|
hurt_ally = _state(_combatant("ally", hp_max=20), "players", (5, 5), hp=5)
|
|
engine.states.append(hurt_ally)
|
|
|
|
actions = default_policy(engine, me)
|
|
|
|
assert not any(a.kind == "channel_energy" for a in actions)
|
|
|
|
|
|
def test_oni_channel_energy_spends_resource() -> None:
|
|
oni = _combatant("oni", resources={"channel_energy": 6}, hp_max=30)
|
|
goblin = _combatant("goblin", hp_max=6)
|
|
engine, me, _ = _engine(oni, [goblin], rng_queue=[5])
|
|
hurt_ally = _state(_combatant("ally", hp_max=20), "players", (5, 5), hp=5)
|
|
engine.states.append(hurt_ally)
|
|
|
|
action = Action(
|
|
kind="channel_energy", target_id=engine.nearest_enemy(me).combatant.id
|
|
)
|
|
engine._execute(me, action)
|
|
|
|
assert me.resources_left["channel_energy"] == 5
|
|
|
|
|
|
def test_oni_channel_harm_does_not_damage_living_enemy() -> None:
|
|
"""Regression: positive energy must not damage living creatures (reported bug:
|
|
oni harmed a Giant Belostomatid — living vermin). Harm mode only hits undead."""
|
|
oni = _combatant("oni", resources={"channel_energy": 6}, hp_max=30)
|
|
vermin = _combatant("belostomatid", hp_max=30)
|
|
engine, me, enemy = _engine(oni, [vermin], rng_queue=[11])
|
|
|
|
action = Action(
|
|
kind="channel_energy", target_id=vermin.id, channel_mode="harm"
|
|
)
|
|
engine._execute(me, action)
|
|
|
|
assert me.hp == 30 # living caster unaffected by harm mode
|
|
assert enemy[0].hp == 30 # living vermin unaffected by harm mode
|
|
|
|
|
|
def test_oni_channel_harm_damages_undead() -> None:
|
|
"""Harm mode damages undead creatures in the burst regardless of side;
|
|
a failed Will save takes full damage (CRB Channel Energy)."""
|
|
oni = _combatant("oni", resources={"channel_energy": 6}, hp_max=30)
|
|
lich = _combatant("lich", hp_max=100, creature_type="undead")
|
|
engine, me, enemy = _engine(oni, [lich], rng_queue=[11, 5])
|
|
|
|
action = Action(kind="channel_energy", target_id=lich.id, channel_mode="harm")
|
|
engine._execute(me, action)
|
|
|
|
assert enemy[0].hp == 100 - (1 + 11) # 2d6 = 1 + roll_die(11)
|
|
# Will save fails: d20 5 + will 4 = 9 < DC 14 (10 + lvl//2 2 + cha 2)
|
|
assert any("Will DC14" in line and "FAILED full" in line for line in engine._transcript)
|
|
|
|
|
|
def test_oni_channel_harm_will_save_halves_damage() -> None:
|
|
"""CRB Channel Energy: an undead that passes the Will save takes half
|
|
damage (floor)."""
|
|
oni = _combatant("oni", resources={"channel_energy": 6}, hp_max=30)
|
|
lich = _combatant("lich", hp_max=100, creature_type="undead")
|
|
engine, me, enemy = _engine(oni, [lich], rng_queue=[11, 20])
|
|
|
|
action = Action(kind="channel_energy", target_id=lich.id, channel_mode="harm")
|
|
engine._execute(me, action)
|
|
|
|
assert enemy[0].hp == 100 - (1 + 11) // 2 # 2d6 = 12, saved for half = 6
|
|
assert any("Will DC14" in line and "SAVED half" in line for line in engine._transcript)
|
|
|
|
|
|
def test_oni_channel_heal_heals_living_enemies_in_range() -> None:
|
|
"""Heal mode heals all living creatures in the burst — including enemies
|
|
(Selective Channeling, which would exclude them, is not modeled)."""
|
|
oni = _combatant("oni", resources={"channel_energy": 6}, hp_max=30)
|
|
goblin = _combatant("goblin", hp_max=6)
|
|
engine, me, enemy = _engine(oni, [goblin], rng_queue=[5])
|
|
enemy[0].hp = 2
|
|
|
|
action = Action(
|
|
kind="channel_energy", target_id=me.combatant.id, channel_mode="heal"
|
|
)
|
|
engine._execute(me, action)
|
|
|
|
assert enemy[0].hp == min(2 + (1 + 5), 6) # healed, capped at hp_max
|
|
|
|
|
|
def test_oni_channel_heal_is_not_counted_as_damage_dealt() -> None:
|
|
"""Attrition counts damage only (README "dégâts infligés/subis"): channel
|
|
healing restores hp but must leave damage_dealt at 0, while the harm mode
|
|
of the same feature still records its damage."""
|
|
oni = _combatant("oni", resources={"channel_energy": 6}, hp_max=30)
|
|
goblin = _combatant("goblin", hp_max=6)
|
|
engine, me, enemy = _engine(oni, [goblin], rng_queue=[5])
|
|
enemy[0].hp = 2
|
|
|
|
engine._execute(
|
|
me,
|
|
Action(kind="channel_energy", target_id=me.combatant.id, channel_mode="heal"),
|
|
)
|
|
|
|
assert enemy[0].hp == 6 # the heal itself still lands
|
|
assert engine._stats["oni"].damage_dealt == 0
|
|
assert engine._stats["oni"].damage_taken == 0
|
|
assert engine._stats["goblin"].damage_taken == 0
|
|
|
|
|
|
def test_oni_channel_harm_is_counted_as_damage_dealt() -> None:
|
|
"""The damage mode of the same feature still records damage_dealt and
|
|
damage_taken (a failed Will save takes full 2d6)."""
|
|
oni = _combatant("oni", resources={"channel_energy": 6}, hp_max=30)
|
|
lich = _combatant("lich", hp_max=100, creature_type="undead")
|
|
engine, me, enemy = _engine(oni, [lich], rng_queue=[11, 5])
|
|
|
|
engine._execute(
|
|
me, Action(kind="channel_energy", target_id=lich.id, channel_mode="harm")
|
|
)
|
|
|
|
dealt = 1 + 11 # 2d6 scripted, save failed
|
|
assert enemy[0].hp == 100 - dealt
|
|
assert engine._stats["oni"].damage_dealt == dealt
|
|
assert engine._stats["lich"].damage_taken == dealt
|
|
|
|
|
|
def test_oni_emits_channel_harm_only_for_grouped_undead() -> None:
|
|
"""Channel harm is emitted only when at least 2 undead enemies are grouped at 30ft
|
|
— living enemies are never harmed by positive energy."""
|
|
oni = _combatant("oni", resources={"channel_energy": 6}, hp_max=30)
|
|
lich1 = _combatant("lich1", hp_max=100, creature_type="undead")
|
|
|
|
engine, me, _ = _engine(oni, [lich1])
|
|
assert not any(a.kind == "channel_energy" for a in default_policy(engine, me))
|
|
|
|
lich2 = _combatant("lich2", hp_max=100, creature_type="undead")
|
|
engine, me, _ = _engine(oni, [lich1, lich2])
|
|
actions = default_policy(engine, me)
|
|
|
|
assert any(
|
|
a.kind == "channel_energy" and a.channel_mode == "harm"
|
|
for a in actions
|
|
)
|
|
|
|
|
|
# --- nairda: hex ---
|
|
|
|
|
|
def test_nairda_emits_hex_when_available() -> None:
|
|
nairda = _combatant("nairda", resources={"hexes": 5})
|
|
goblin = _combatant("goblin", hp_max=6)
|
|
engine, me, _ = _engine(nairda, [goblin])
|
|
|
|
actions = default_policy(engine, me)
|
|
|
|
assert any(a.kind == "hex" for a in actions)
|
|
|
|
|
|
def test_nairda_silent_when_hexes_exhausted() -> None:
|
|
nairda = _combatant("nairda", resources={"hexes": 0})
|
|
goblin = _combatant("goblin", hp_max=6)
|
|
engine, me, _ = _engine(nairda, [goblin])
|
|
|
|
actions = default_policy(engine, me)
|
|
|
|
assert not any(a.kind == "hex" for a in actions)
|
|
|
|
|
|
def test_nairda_hex_spends_resource() -> None:
|
|
nairda = _combatant("nairda", resources={"hexes": 5})
|
|
goblin = _combatant("goblin", hp_max=6)
|
|
engine, me, enemy = _engine(nairda, [goblin], rng_queue=[20])
|
|
|
|
action = Action(kind="hex", target_id=enemy[0].combatant.id, hex_name="evil_eye")
|
|
engine._execute(me, action)
|
|
|
|
assert me.resources_left["hexes"] == 4
|
|
|
|
|
|
def test_evil_eye_failed_save_deals_minus_two_attack_three_rounds() -> None:
|
|
"""APG Evil Eye: -2 on one chosen option, 3 rounds; Will does not negate.
|
|
Given: a target that fails the Will save (d20 2 +4 < DC 14)
|
|
When: the witch hexes it
|
|
Then: a -2 attack modifier is applied for 3 rounds (no shaken condition)."""
|
|
nairda = _combatant("nairda", resources={"hexes": 5})
|
|
goblin = _combatant("goblin", hp_max=6)
|
|
engine, me, enemy = _engine(nairda, [goblin], rng_queue=[2])
|
|
|
|
engine._execute(
|
|
me, Action(kind="hex", target_id=enemy[0].combatant.id, hex_name="evil_eye")
|
|
)
|
|
|
|
hex_buffs = [b for b in enemy[0].active_buffs if b.source.startswith("hex: evil_eye")]
|
|
assert len(hex_buffs) == 1
|
|
assert hex_buffs[0].rounds_left == 3
|
|
assert hex_buffs[0].modifiers == (StatModifier(target="attack", value=-2),)
|
|
assert not any(ac.condition.name == "shaken" for ac in enemy[0].active_conditions)
|
|
|
|
|
|
def test_evil_eye_saved_deals_minus_two_attack_one_round() -> None:
|
|
"""APG Evil Eye: "A Will save reduces this to just 1 round" — the penalty
|
|
still lands. Given: a target that saves (natural 20)
|
|
When: the witch hexes it
|
|
Then: the -2 attack modifier is applied for exactly 1 round."""
|
|
nairda = _combatant("nairda", resources={"hexes": 5})
|
|
goblin = _combatant("goblin", hp_max=6)
|
|
engine, me, enemy = _engine(nairda, [goblin], rng_queue=[20])
|
|
|
|
engine._execute(
|
|
me, Action(kind="hex", target_id=enemy[0].combatant.id, hex_name="evil_eye")
|
|
)
|
|
|
|
hex_buffs = [b for b in enemy[0].active_buffs if b.source.startswith("hex: evil_eye")]
|
|
assert len(hex_buffs) == 1
|
|
assert hex_buffs[0].rounds_left == 1
|
|
assert hex_buffs[0].modifiers == (StatModifier(target="attack", value=-2),)
|
|
|
|
|
|
def test_slumber_failed_save_puts_target_asleep_for_witch_level() -> None:
|
|
"""APG Slumber: the target "remains asleep for a number of rounds equal to
|
|
the witch's level". Given: a level-5 witch and a target failing the Will
|
|
save (d20 2 +4 < DC 14)
|
|
When: the hex is cast
|
|
Then: the asleep condition carries rounds_left = 5."""
|
|
nairda = _combatant("nairda", resources={"hexes": 5})
|
|
goblin = _combatant("goblin", hp_max=6)
|
|
engine, me, enemy = _engine(nairda, [goblin], rng_queue=[2])
|
|
|
|
engine._execute(
|
|
me, Action(kind="hex", target_id=enemy[0].combatant.id, hex_name="slumber")
|
|
)
|
|
|
|
asleep = [
|
|
ac for ac in enemy[0].active_conditions if ac.condition.name == "asleep"
|
|
]
|
|
assert len(asleep) == 1
|
|
assert asleep[0].rounds_left == 5
|
|
|
|
|
|
def test_slumber_saved_leaves_target_awake() -> None:
|
|
"""APG Slumber: Will negates. Given: a target saving (natural 20)
|
|
When: the hex is cast
|
|
Then: no asleep condition is applied."""
|
|
nairda = _combatant("nairda", resources={"hexes": 5})
|
|
goblin = _combatant("goblin", hp_max=6)
|
|
engine, me, enemy = _engine(nairda, [goblin], rng_queue=[20])
|
|
|
|
engine._execute(
|
|
me, Action(kind="hex", target_id=enemy[0].combatant.id, hex_name="slumber")
|
|
)
|
|
|
|
assert not any(
|
|
ac.condition.name == "asleep" for ac in enemy[0].active_conditions
|
|
)
|
|
|
|
|
|
# --- tammara: deeds ---
|
|
|
|
|
|
def test_tammara_emits_deed_alongside_attack() -> None:
|
|
tammara = _combatant("tammara", resources={"grit": 3}, firearm=True)
|
|
goblin = _combatant("goblin", hp_max=6)
|
|
engine, me, _ = _engine(tammara, [goblin])
|
|
|
|
actions = default_policy(engine, me)
|
|
|
|
assert any(a.kind == "deed" for a in actions)
|
|
assert any(a.kind in {"full_attack", "attack"} for a in actions)
|
|
|
|
|
|
def test_tammara_no_deed_when_followup_attack_is_melee() -> None:
|
|
"""Regression: Up Close and Deadly spends 1 grit at execution and its
|
|
+1d6 rides only a firearm attack within 30 ft; emitted alongside a melee
|
|
full attack the bonus is lost and the grit burned. The policy must skip
|
|
the deed when the weapon the turn will fire is not a firearm."""
|
|
tammara = _combatant("tammara", resources={"grit": 3}) # sword only
|
|
goblin = _combatant("goblin", hp_max=6)
|
|
engine, me, _ = _engine(tammara, [goblin])
|
|
|
|
actions = default_policy(engine, me)
|
|
|
|
assert not any(a.kind == "deed" for a in actions)
|
|
assert any(a.kind == "full_attack" for a in actions)
|
|
|
|
|
|
def test_tammara_no_deed_when_turn_casts_instead_of_attacking() -> None:
|
|
"""Even with a firearm selected, a turn whose normal action is a spell
|
|
cast resolves no weapon attack: the deed flag would burn grit unused."""
|
|
tammara = _combatant(
|
|
"tammara", resources={"grit": 3}, firearm=True,
|
|
spells=["Divine Favor"], spell_slots={1: 1},
|
|
)
|
|
goblin = _combatant("goblin", hp_max=6)
|
|
engine, me, _ = _engine(
|
|
tammara, [goblin], spell_registry=load_spell_registry(SPELLS_DIR)
|
|
)
|
|
|
|
actions = default_policy(engine, me)
|
|
|
|
assert any(a.kind == "cast_spell" for a in actions)
|
|
assert not any(a.kind == "deed" for a in actions)
|
|
|
|
|
|
def test_tammara_silent_when_grit_exhausted() -> None:
|
|
tammara = _combatant("tammara", resources={"grit": 0})
|
|
goblin = _combatant("goblin", hp_max=6)
|
|
engine, me, _ = _engine(tammara, [goblin])
|
|
|
|
actions = default_policy(engine, me)
|
|
|
|
assert not any(a.kind == "deed" for a in actions)
|
|
|
|
|
|
def test_tammara_silent_when_target_out_of_deed_range() -> None:
|
|
tammara = _combatant("tammara", resources={"grit": 3})
|
|
goblin = _combatant("goblin", hp_max=6)
|
|
engine, me, _ = _engine(tammara, [])
|
|
far_goblin = _state(goblin, "monsters", (11, 11), hp=6)
|
|
engine.states.append(far_goblin)
|
|
|
|
actions = default_policy(engine, me)
|
|
|
|
assert not any(a.kind == "deed" for a in actions)
|
|
|
|
|
|
def test_tammara_deed_out_of_range_spends_no_grit() -> None:
|
|
tammara = _combatant("tammara", resources={"grit": 3})
|
|
goblin = _combatant("goblin", hp_max=6)
|
|
engine, me, _ = _engine(tammara, [])
|
|
far_goblin = _state(goblin, "monsters", (11, 11), hp=6)
|
|
engine.states.append(far_goblin)
|
|
|
|
engine._execute(
|
|
me,
|
|
Action(
|
|
kind="deed",
|
|
target_id=far_goblin.combatant.id,
|
|
deed_name="up_close_and_deadly",
|
|
),
|
|
)
|
|
|
|
assert me.resources_left["grit"] == 3
|
|
|
|
|
|
# --- policy heal branch: range gate ---
|
|
|
|
|
|
def _healer(hp_max: int = 20) -> Combatant:
|
|
return _combatant(
|
|
"cleric", spells=["Cure Light Wounds"], spell_slots={1: 1}, hp_max=hp_max
|
|
)
|
|
|
|
|
|
def test_heal_cast_when_wounded_ally_in_touch_range() -> None:
|
|
"""A touch-range Cure is proposed only when the ally is within the spell's
|
|
range: ally at 5 ft (1 cell) -> cast_spell on the ally."""
|
|
cleric = _healer()
|
|
engine, me, _ = _engine(
|
|
cleric, [_combatant("goblin", hp_max=6)],
|
|
spell_registry=load_spell_registry(SPELLS_DIR),
|
|
)
|
|
hurt_ally = _state(_combatant("ally", hp_max=20), "players", (5, 4), hp=5)
|
|
engine.states.append(hurt_ally)
|
|
|
|
actions = default_policy(engine, me)
|
|
|
|
assert actions[0].kind == "cast_spell"
|
|
assert actions[0].target_id == "ally"
|
|
assert actions[0].spell_name == "Cure Light Wounds"
|
|
|
|
|
|
def test_no_heal_cast_when_wounded_ally_out_of_range() -> None:
|
|
"""Regression: a touch-range Cure on an ally 20 ft away is rejected by
|
|
_cast_spell (OUT OF RANGE), wasting the caster's whole turn. The policy
|
|
must skip the cast and fall through to the attack path."""
|
|
cleric = _healer()
|
|
engine, me, _ = _engine(
|
|
cleric, [_combatant("goblin", hp_max=6)],
|
|
spell_registry=load_spell_registry(SPELLS_DIR),
|
|
)
|
|
hurt_ally = _state(_combatant("ally", hp_max=20), "players", (5, 9), hp=5)
|
|
engine.states.append(hurt_ally)
|
|
|
|
actions = default_policy(engine, me)
|
|
|
|
assert not any(a.kind == "cast_spell" for a in actions)
|
|
assert any(a.kind == "full_attack" for a in actions)
|
|
|
|
|
|
def test_no_heal_cast_when_wounded_ally_blocked_by_wall() -> None:
|
|
"""Line-of-effect gate (distinct from range): a close-range Cure reaches
|
|
the ally by distance (15 ft < 35 ft at level 5) but a solid barrier
|
|
cancels line of effect, so the cast would be rejected NO LINE OF EFFECT
|
|
and the policy must fall through."""
|
|
cleric = _combatant(
|
|
"cleric", spells=["Wall Blocked Cure"], spell_slots={1: 1}, hp_max=20
|
|
)
|
|
cure = SpellSpec(
|
|
name="Wall Blocked Cure", level=1, school="conjuration", range="close",
|
|
effects=(HealEffect(formula=parse_dice("1d8")),),
|
|
)
|
|
legend = {
|
|
".": TerrainType(type="floor", move_cost=1),
|
|
"#": TerrainType(type="wall", move_cost=None, blocks_los=True),
|
|
}
|
|
rows = (
|
|
"............",
|
|
"............",
|
|
"............",
|
|
"............",
|
|
"............",
|
|
".....#.#####",
|
|
"............",
|
|
"............",
|
|
"............",
|
|
"............",
|
|
"............",
|
|
"............",
|
|
)
|
|
spec = MapSpec(name="walled", terrain=rows, legend=legend)
|
|
me = _state(cleric, "players", (5, 5))
|
|
enemy = _state(_combatant("goblin", hp_max=6), "monsters", (6, 6))
|
|
hurt_ally = _state(_combatant("ally", hp_max=20), "players", (5, 8), hp=5)
|
|
engine = CombatEngine(
|
|
ScriptedRng([]), Grid.from_spec(spec), [me, enemy, hurt_ally],
|
|
spell_registry={"Wall Blocked Cure": cure},
|
|
)
|
|
|
|
actions = default_policy(engine, me)
|
|
|
|
assert not any(a.kind == "cast_spell" for a in actions)
|
|
|
|
|
|
# --- misty: spell_combat + inspire_courage ---
|
|
|
|
|
|
def test_misty_emits_inspire_courage_and_spell_combat() -> None:
|
|
# Shocking Grasp (not Burning Hands): the offensive-spell scan reads
|
|
# top-level damage effects, and Burning Hands is a cone area spell now.
|
|
misty = _combatant(
|
|
"misty", resources={"bardic_rounds": 12}, spells=["Shocking Grasp"],
|
|
spell_slots={1: 1},
|
|
)
|
|
goblin = _combatant("goblin", hp_max=6)
|
|
reg = load_spell_registry(SPELLS_DIR)
|
|
engine, me, _ = _engine(misty, [goblin], spell_registry=reg)
|
|
|
|
actions = default_policy(engine, me)
|
|
|
|
assert any(a.kind == "inspire_courage" for a in actions)
|
|
assert any(a.kind == "spell_combat" for a in actions)
|
|
|
|
|
|
def test_misty_no_spell_combat_when_slots_exhausted() -> None:
|
|
"""Slot gate: with no slot left the cast would be rejected (NO SPELL SLOT)
|
|
and spell_combat would resolve as a bare full attack at -2 - strictly worse
|
|
than the plain full attack. The policy must fall through to full_attack."""
|
|
misty = _combatant(
|
|
"misty", resources={"bardic_rounds": 12}, spells=["Shocking Grasp"],
|
|
spell_slots={1: 0},
|
|
)
|
|
goblin = _combatant("goblin", hp_max=6)
|
|
reg = load_spell_registry(SPELLS_DIR)
|
|
engine, me, _ = _engine(misty, [goblin], spell_registry=reg)
|
|
|
|
actions = default_policy(engine, me)
|
|
|
|
assert not any(a.kind == "spell_combat" for a in actions)
|
|
assert any(a.kind == "full_attack" for a in actions)
|
|
|
|
|
|
def test_spell_combat_without_slot_costs_no_minus_two_in_execution() -> None:
|
|
"""Executor-side evidence for the policy gate: a spell_combat with no slot
|
|
leaves the caster without the -2 spell AND with the full attack at -2
|
|
(d20 10 + bonus 3 - 2 = 11, misses AC 15) - the wasted-turn trap the
|
|
policy must avoid proposing."""
|
|
misty = _combatant(
|
|
"misty", resources={"bardic_rounds": 12}, spells=["Shocking Grasp"],
|
|
spell_slots={1: 0},
|
|
)
|
|
goblin = _combatant("goblin", hp_max=6)
|
|
reg = load_spell_registry(SPELLS_DIR)
|
|
# Queue: the rejected cast consumes no die (the slot gate precedes the
|
|
# casting AoO), so the first roll is misty's swing d20=10.
|
|
engine, me, enemy = _engine(
|
|
misty, [goblin], spell_registry=reg, rng_queue=[10]
|
|
)
|
|
|
|
engine._execute(
|
|
me,
|
|
Action(
|
|
kind="spell_combat", target_id=enemy[0].combatant.id,
|
|
spell_name="Shocking Grasp",
|
|
),
|
|
)
|
|
|
|
assert any("NO SPELL SLOT" in line for line in engine._transcript)
|
|
assert any(
|
|
"sword vs goblin d20=10+1=11" in line for line in engine._transcript
|
|
)
|
|
|
|
|
|
def test_misty_silent_when_bardic_rounds_exhausted() -> None:
|
|
misty = _combatant(
|
|
"misty", resources={"bardic_rounds": 0}, spells=["Burning Hands"]
|
|
)
|
|
goblin = _combatant("goblin", hp_max=6)
|
|
reg = load_spell_registry(SPELLS_DIR)
|
|
engine, me, _ = _engine(misty, [goblin], spell_registry=reg)
|
|
|
|
actions = default_policy(engine, me)
|
|
|
|
assert not any(a.kind == "inspire_courage" for a in actions)
|
|
assert not any(a.kind == "spell_combat" for a in actions)
|
|
|
|
|
|
def test_spell_combat_melee_attacks_take_minus_two() -> None:
|
|
"""CRB Magus Spell Combat: the full attack that accompanies the cast is
|
|
made at -2 on every attack roll (vs the same swing in a normal full attack)."""
|
|
misty = _combatant(
|
|
"misty", resources={"bardic_rounds": 12}, spells=["Create Alcool"]
|
|
)
|
|
goblin = _combatant("goblin", hp_max=6)
|
|
reg = load_spell_registry(SPELLS_DIR)
|
|
# Queue: goblin AoO d20 (natural 1, auto-miss), then misty's swing d20=10.
|
|
engine, me, enemy = _engine(misty, [goblin], spell_registry=reg, rng_queue=[1, 10])
|
|
|
|
engine._execute(
|
|
me,
|
|
Action(
|
|
kind="spell_combat", target_id=enemy[0].combatant.id,
|
|
spell_name="Create Alcool",
|
|
),
|
|
)
|
|
|
|
# Sword attack_bonus 3 + Spell Combat -2 = +1: d20 10 -> total 11 (misses AC 15).
|
|
assert any(
|
|
"sword vs goblin d20=10+1=11" in line for line in engine._transcript
|
|
)
|
|
|
|
|
|
def test_normal_full_attack_has_no_spell_combat_penalty() -> None:
|
|
"""Control: the same scripted swing in a plain full attack keeps its +3."""
|
|
misty = _combatant("misty", resources={"bardic_rounds": 12})
|
|
goblin = _combatant("goblin", hp_max=6)
|
|
reg = load_spell_registry(SPELLS_DIR)
|
|
engine, me, enemy = _engine(misty, [goblin], spell_registry=reg, rng_queue=[10])
|
|
|
|
engine._execute(me, Action(kind="full_attack", target_id=enemy[0].combatant.id))
|
|
|
|
assert any(
|
|
"sword vs goblin d20=10+3=13" in line for line in engine._transcript
|
|
)
|
|
|
|
|
|
def test_spell_combat_penalty_applies_to_spell_touch_attack() -> None:
|
|
"""CRB Magus Spell Combat: "any attack roll made as part of this spell
|
|
also takes this penalty" — Shocking Grasp's melee touch attack is at -2
|
|
(bab 3 + str 2 - 2 = +3): d20 8 -> 11 misses touch AC 12, so the spell
|
|
is wasted. Without the penalty the same roll (8+5=13) would have hit."""
|
|
misty = _combatant(
|
|
"misty", resources={"bardic_rounds": 12}, spells=["Shocking Grasp"],
|
|
spell_slots={1: 1},
|
|
)
|
|
goblin = _combatant("goblin", hp_max=6)
|
|
reg = load_spell_registry(SPELLS_DIR)
|
|
# Queue: goblin AoO on the cast (nat 1 miss), touch d20=8, swing d20=10.
|
|
engine, me, enemy = _engine(
|
|
misty, [goblin], spell_registry=reg, rng_queue=[1, 8, 10]
|
|
)
|
|
engine._execute(
|
|
me,
|
|
Action(
|
|
kind="spell_combat", target_id=enemy[0].combatant.id,
|
|
spell_name="Shocking Grasp",
|
|
),
|
|
)
|
|
assert any(
|
|
"touch attack vs goblin d20=8+3=11 AC 12 -> MISS" in line
|
|
for line in engine._transcript
|
|
)
|
|
assert enemy[0].hp == 6 # the missed touch delivers no electricity
|
|
|
|
|
|
def test_plain_cast_touch_attack_has_no_spell_combat_penalty() -> None:
|
|
"""Control: the same Shocking Grasp cast through cast_spell keeps +5
|
|
(bab 3 + str 2): d20 8 -> 13 hits touch AC 12 and deals damage."""
|
|
misty = _combatant(
|
|
"misty", spells=["Shocking Grasp"], spell_slots={1: 1}
|
|
)
|
|
goblin = _combatant("goblin", hp_max=60)
|
|
reg = load_spell_registry(SPELLS_DIR)
|
|
# Queue: goblin AoO (nat 1 miss), touch d20=8, damage 5d6=4 each
|
|
# (SRD spells/shocking-grasp.md: 1d6 per caster level, max 5d6; lvl 5).
|
|
engine, me, enemy = _engine(
|
|
misty, [goblin], spell_registry=reg, rng_queue=[1, 8, 4, 4, 4, 4, 4]
|
|
)
|
|
engine._execute(
|
|
me, Action(kind="cast_spell", target_id=enemy[0].combatant.id,
|
|
spell_name="Shocking Grasp")
|
|
)
|
|
assert any(
|
|
"touch attack vs goblin d20=8+5=13 AC 12 -> HIT" in line
|
|
for line in engine._transcript
|
|
)
|
|
assert enemy[0].hp == 60 - 20
|
|
|
|
|
|
def _inspired_ally() -> tuple[CombatEngine, CombatantState, CombatantState]:
|
|
"""Misty + one ally in 30ft, inspire courage executed on the ally."""
|
|
misty = _combatant("misty", resources={"bardic_rounds": 12})
|
|
ally = _combatant("ally")
|
|
engine, me, _ = _engine(misty, [_combatant("goblin", hp_max=6)])
|
|
ally_state = _state(ally, "players", (5, 7))
|
|
engine.states.append(ally_state)
|
|
engine._execute(
|
|
me, Action(kind="inspire_courage", target_id=ally_state.combatant.id)
|
|
)
|
|
return engine, me, ally_state
|
|
|
|
|
|
def test_inspire_courage_grants_competence_attack_damage() -> None:
|
|
"""CRB Inspire Courage: the attack/damage bonus is COMPETENCE (not morale)."""
|
|
_, _, ally = _inspired_ally()
|
|
mods = {
|
|
(m.target, m.bonus_type): m.value
|
|
for buff in ally.active_buffs
|
|
for m in buff.modifiers
|
|
}
|
|
assert mods[("attack", "competence")] == 1
|
|
assert mods[("damage", "competence")] == 1
|
|
|
|
|
|
def test_inspire_courage_stacks_with_heroism_morale() -> None:
|
|
"""CRB stacking: +1 competence (inspire courage) and +2 morale (heroism)
|
|
are different bonus types — both apply to the attack roll."""
|
|
engine, _, ally = _inspired_ally()
|
|
ally.active_buffs.append(
|
|
ActiveBuff(
|
|
modifiers=(StatModifier(target="attack", value=2, bonus_type="morale"),),
|
|
rounds_left=10,
|
|
source="heroism",
|
|
)
|
|
)
|
|
total = resolve_modifiers(engine._stat_modifiers(ally, "attack"))
|
|
assert total == 3 # competence +1 + morale +2
|
|
|
|
|
|
def test_inspire_courage_morale_bonus_on_charm_and_fear_saves() -> None:
|
|
"""CRB Inspire Courage: +1 morale on saves vs charm (mind_affecting) and
|
|
fear; morale never stacks with itself, so both descriptors total +1."""
|
|
engine, _, ally = _inspired_ally()
|
|
|
|
def will_bonus(*descriptors: str) -> int:
|
|
return resolve_modifiers(
|
|
engine._stat_modifiers(
|
|
ally, "will", save_descriptors=frozenset(descriptors)
|
|
)
|
|
)
|
|
|
|
assert will_bonus("fear") == 1
|
|
assert will_bonus("mind_affecting") == 1
|
|
assert will_bonus("fear", "mind_affecting") == 1
|
|
assert will_bonus() == 0
|