From 6e08df6eb6af27dcdf5c33546249d68f35227f67 Mon Sep 17 00:00:00 2001 From: ctan Date: Fri, 4 Sep 2026 22:56:02 +0200 Subject: [PATCH] feat(combat): class abilities from default_policy --- src/pf1e_simulator/combat.py | 123 +++++++++++++++++-- tests/test_class_abilities.py | 223 ++++++++++++++++++++++++++++++++++ 2 files changed, 336 insertions(+), 10 deletions(-) create mode 100644 tests/test_class_abilities.py diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index 00b31ca..6d5f837 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -2490,14 +2490,25 @@ def default_policy(engine: CombatEngine, state: CombatantState) -> tuple[Action, """Full-attack the nearest enemy; cast spells; charge, 5ft-step, or approach if out of range. Decision order: - 1. Heal spell if an ally is below 50% HP and a heal spell is available. - 2. Offensive spell if an enemy is in range and spell slots available. - 3. Buff spell if no immediate threat and spell slots available. - 4. Full-attack if a weapon is usable against the nearest enemy this turn (no move needed). - 5. Charge if a straight-line charge path exists (2x speed, +2 attack, -2 AC). - 5. 5-foot step then full-attack if a single step brings the target into weapon range. - 6. Move toward the target then single attack (standard + move economy). - 7. Wait if nothing is possible. + 1. Channel energy (oni) if resource available and an ally is hurt or enemies are grouped. + 2. Hex (nairda) if resource available and the target is within close range. + 3. Heal spell if an ally is below 50% HP and a heal spell is available. + 4. Offensive spell if an enemy is in range and spell slots available. + 5. Buff spell if no immediate threat and spell slots available. + 6. Full-attack if a weapon is usable against the nearest enemy this turn (no move needed). + 7. Charge if a straight-line charge path exists (2x speed, +2 attack, -2 AC). + 8. 5-foot step then full-attack if a single step brings the target into weapon range. + 9. Move toward the target then single attack (standard + move economy). + 10. Wait if nothing is possible. + + Class-ability bonus actions (deed for tammara, inspire_courage for misty) are + prepended to the normal action sequence whenever their resource is available. + + Precedence for replacement (standard) abilities: channel_energy and hex are + evaluated first and replace the whole turn when their resource is available + and their condition is met; otherwise the normal spell/melee flow runs and the + swift/free abilities are combined with it. This keeps each class able to act + each turn while reserving standard-action class abilities for their triggers. Never casts a spell if threatened without Concentration check (handled in _cast_spell). """ @@ -2508,15 +2519,107 @@ def default_policy(engine: CombatEngine, state: CombatantState) -> tuple[Action, if engine.has_condition_flag(state, "must_flee"): return (Action(kind="withdraw", target_id=target.combatant.id),) - # Try spellcasting if caster has spells + # Channel energy (oni): standard action that heal/harms in a 30ft burst. + if _emit_channel_energy(engine, state): + return (Action(kind="channel_energy", target_id=target.combatant.id),) + + # Hex (nairda): standard action when the target is within close range. + if _emit_hex(engine, state, target): + return (Action(kind="hex", target_id=target.combatant.id, hex_name="evil_eye"),) + + normal = _normal_actions(engine, state, target) + + # Misty spell_combat (main) + inspire_courage (swift): replaces the normal action. + spell_combat = _spell_combat_actions(engine, state, target) + if spell_combat is not None: + return spell_combat + + # Deed (tammara) and inspire_courage (misty melee fallback): swift/free bonuses. + swift_bonus = _swift_bonus_actions(state, target) + return (*swift_bonus, *normal) if swift_bonus else normal + + +def _normal_actions( + engine: CombatEngine, state: CombatantState, target: CombatantState +) -> tuple[Action, ...]: + """The spell/melee action sequence (without swift/free class-ability add-ons).""" if state.combatant.spells: spell_action = _choose_spell_action(engine, state, target) if spell_action is not None: return spell_action - return _melee_action(engine, state, target) +_GROUPED_ENEMY_COUNT = 2 +_HEX_RANGE_FT = 25 + + +def _emit_channel_energy(engine: CombatEngine, state: CombatantState) -> bool: + """True when oni should channel energy: resource available and a trigger is met.""" + if state.resources_left.get("channel_energy", 0) <= 0: + return False + if _find_ally_needing_heal(engine, state) is not None: + return True + radius_cells = 30 // _SQUARE_FT + grouped_enemies = [ + s + for s in engine.states + if s.side != state.side + and s.active + and engine.grid.distance(state.pos, s.pos) <= radius_cells + ] + return len(grouped_enemies) >= _GROUPED_ENEMY_COUNT + + +def _emit_hex( + engine: CombatEngine, state: CombatantState, target: CombatantState | None +) -> bool: + """True when nairda should hex: resource available and target within close range.""" + if state.resources_left.get("hexes", 0) <= 0: + return False + if target is None or not target.active: + return False + return engine.grid.distance(state.pos, target.pos) * _SQUARE_FT <= _HEX_RANGE_FT + + +def _spell_combat_actions( + engine: CombatEngine, state: CombatantState, target: CombatantState +) -> tuple[Action, ...] | None: + """Misty's spell_combat (main) + inspire_courage (swift), or None if no offensive spell.""" + if state.resources_left.get("bardic_rounds", 0) <= 0: + return None + offensive = _find_offensive_spell(engine, state.combatant.spells, state, target) + if offensive is None: + return None + return ( + Action(kind="inspire_courage", target_id=target.combatant.id), + Action(kind="spell_combat", target_id=target.combatant.id, spell_name=offensive), + ) + + +def _swift_bonus_actions( + state: CombatantState, target: CombatantState +) -> tuple[Action, ...]: + """Swift/free class-ability actions (deed, inspire_courage) to prepend to the turn.""" + bonus: list[Action] = [] + + # Deed (tammara): swift/free action, emitted alongside the normal action. + if state.resources_left.get("grit", 0) > 0: + bonus.append( + Action( + kind="deed", + target_id=target.combatant.id, + deed_name="up_close_and_deadly", + ) + ) + + # Inspire courage (misty melee fallback): combined with the normal action. + if state.resources_left.get("bardic_rounds", 0) > 0: + bonus.append(Action(kind="inspire_courage", target_id=target.combatant.id)) + + return tuple(bonus) + + def _melee_action( engine: CombatEngine, state: CombatantState, target: CombatantState ) -> tuple[Action, ...]: diff --git a/tests/test_class_abilities.py b/tests/test_class_abilities.py new file mode 100644 index 0000000..6fc376d --- /dev/null +++ b/tests/test_class_abilities.py @@ -0,0 +1,223 @@ +"""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 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, +) -> 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 [], + ) + + +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 + + +# --- 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) + + +# --- 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)