322 lines
10 KiB
Python
322 lines
10 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.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, SpellSpec, load_spell_registry
|
|
|
|
|
|
def _combatant(
|
|
cid: str,
|
|
*,
|
|
resources: dict[str, int] | None = None,
|
|
spells: list[str] | None = None,
|
|
hp_max: int = 20,
|
|
creature_type: Literal["living", "undead", "construct"] = "living",
|
|
) -> Combatant:
|
|
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 [],
|
|
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_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."""
|
|
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])
|
|
|
|
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)
|
|
|
|
|
|
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_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
|
|
|
|
|
|
# --- tammara: deeds ---
|
|
|
|
|
|
def test_tammara_emits_deed_alongside_attack() -> None:
|
|
tammara = _combatant("tammara", resources={"grit": 3})
|
|
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_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
|
|
|
|
|
|
# --- misty: spell_combat + inspire_courage ---
|
|
|
|
|
|
def test_misty_emits_inspire_courage_and_spell_combat() -> None:
|
|
misty = _combatant(
|
|
"misty", resources={"bardic_rounds": 12}, 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 any(a.kind == "inspire_courage" for a in actions)
|
|
assert any(a.kind == "spell_combat" for a in actions)
|
|
|
|
|
|
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)
|