From 9c49f85af816aa33d37cf64593aa80800cd637b9 Mon Sep 17 00:00:00 2001 From: ctan Date: Sun, 6 Sep 2026 10:22:58 +0200 Subject: [PATCH] feat(combat): resolve all natural melee weapons in full_attack --- src/pf1e_simulator/combat.py | 46 ++++++- tests/test_multi_natural_weapons.py | 205 ++++++++++++++++++++++++++++ 2 files changed, 247 insertions(+), 4 deletions(-) create mode 100644 tests/test_multi_natural_weapons.py diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index 09cbe75..b9cf540 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -2295,13 +2295,13 @@ class CombatEngine: return if target is None: return - weapon = self.weapon_for(state, target) - if weapon is None: - return if kind == "attack": + weapon = self.weapon_for(state, target) + if weapon is None: + return self._single_attack(state, target, weapon) elif kind == "full_attack": - self._full_attack(state, target, weapon) + self._full_attack(state, target) def _execute_charge(self, state: CombatantState, target: CombatantState | None) -> None: if target is None or not target.active: return @@ -2819,8 +2819,30 @@ class CombatEngine: self._resolve_swing(attacker, target, weapon, label=weapon.name) def _full_attack( + self, attacker: CombatantState, target: CombatantState + ) -> None: + melee_weapons = [ + w + for w in attacker.usable_attacks() + if w.kind == "melee" and self._melee_weapon_usable(attacker, target, w) + ] + if not melee_weapons: + # No melee weapon reaches: fall back to weapon_for (ranged/touch), + # preserving the pre-L3 single-weapon behavior. + weapon = self.weapon_for(attacker, target) + if weapon is None: + return + self._full_attack_weapon(attacker, target, weapon) + return + for weapon in melee_weapons: + if not attacker.active or not target.active: + return + self._full_attack_weapon(attacker, target, weapon) + + def _full_attack_weapon( self, attacker: CombatantState, target: CombatantState, weapon: AttackSpec ) -> None: + """Resolve one weapon's full-attack swings (count swings or BAB iteratives).""" if weapon.count > 1: self._attack(attacker, target, weapon) return @@ -2831,6 +2853,8 @@ class CombatEngine: for i in range(n): if not attacker.active: return + if not target.active: + return if self._check_provoked_aoo(attacker, weapon): return label = weapon.name if n == 1 else f"{weapon.name} #{i + 1}" @@ -2840,6 +2864,20 @@ class CombatEngine: ): return + def _melee_weapon_usable( + self, attacker: CombatantState, target: CombatantState, weapon: AttackSpec + ) -> bool: + """True if ``weapon`` (a melee weapon) can reach ``target`` with clear LoE.""" + if not has_line_of_effect_cells( + self._grid, attacker.occupied_cells(), target.occupied_cells() + ): + return False + dist_ft = self.creature_distance(attacker, target) * _SQUARE_FT + effective_reach_ft = _effective_reach_ft(attacker, weapon) + if weapon.reach_ft > _SQUARE_FT: + return _SQUARE_FT < dist_ft <= effective_reach_ft + return dist_ft <= effective_reach_ft + def _step5(self, state: CombatantState, target: CombatantState) -> None: if state.moved_this_turn: return diff --git a/tests/test_multi_natural_weapons.py b/tests/test_multi_natural_weapons.py new file mode 100644 index 0000000..a983847 --- /dev/null +++ b/tests/test_multi_natural_weapons.py @@ -0,0 +1,205 @@ +"""Tests for L3 of the ``llm-policy-contract`` plan: multiple natural weapons +in a single full_attack. + +Previously ``_full_attack`` only struck with the single weapon returned by +``weapon_for`` (the first usable one). A combatant with several natural +weapons (e.g. "bite" + "2x Talons") only struck with the first in a +full_attack. This module locks in the new behaviour: ``_full_attack`` iterates +over every melee weapon in ``usable_attacks()``, resolves each one's swings, +and excludes ranged/touch weapons from the loop. +""" + +from __future__ import annotations + +from typing import Literal + +from pf1e_simulator.combat import Action, CombatantState, CombatEngine +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, SeededRng + + +def _attack( + cid: str, name: str, *, kind: Literal["melee", "ranged", "touch"] = "melee", + count: int = 1, attack_bonus: int = 2, +) -> AttackSpec: + return AttackSpec( + id=f"{cid}-{name.replace(' ', '-')}", + name=name, + kind=kind, + attack_bonus=attack_bonus, + damage=[DamageComponent(formula="1d4", types=["slashing"])], + count=count, + ) + + +def make_combatant( + cid: str, attacks: list[AttackSpec], *, bab: int = 1, hp_max: int = 20, +) -> Combatant: + return Combatant( + id=cid, + name=cid, + level=1, + size="Medium", + abilities=AbilityScores( + str_score=10, dex_score=10, con_score=10, + int_score=10, wis_score=10, cha_score=10, + ), + hp_max=hp_max, + ac=ACProfile(total=13, touch=13, flat_footed=13), + bab=bab, + initiative_mod=0, + speed_land_ft=30, + saves=Saves(fort=0, ref=0, will=0), + attacks=attacks, + ) + + +def make_state(combatant: Combatant, side: str, pos: tuple[int, int]) -> CombatantState: + return CombatantState(combatant=combatant, side=side, pos=pos, hp=combatant.hp_max) + + +def make_grid() -> Grid: + legend = {".": TerrainType(type="floor", move_cost=1)} + spec = MapSpec(name="test", terrain=tuple(["." * 8] * 8), legend=legend) + return Grid.from_spec(spec) + + +def make_engine(queue: list[int], states: list[CombatantState]) -> CombatEngine: + return CombatEngine(ScriptedRng(queue), make_grid(), states) + + +def _full_attack_transcript( + attacker: CombatantState, target: CombatantState, queue: list[int], +) -> list[str]: + engine = make_engine(queue, [attacker, target]) + engine._execute(attacker, Action(kind="full_attack", target_id=target.combatant.id)) + return list(engine._transcript) + + +def _labels(transcript: list[str]) -> list[str]: + """Extract the swing labels (e.g. "bite", "claw #1") from swing log lines.""" + return [ + line.split(": ", 1)[1].split(" vs ", 1)[0] + for line in transcript + if " vs " in line and " d20=" in line + ] + + +# --- Test 1: multiple melee weapons all resolve in one full_attack ---------- + +def test_full_attack_resolves_all_melee_weapons() -> None: + a_spec = make_combatant( + "a", + [ + _attack("a", "bite", count=1), + _attack("a", "claw", count=2), + ], + bab=1, + hp_max=20, + ) + b_spec = make_combatant("b", [_attack("b", "sword")], hp_max=20) + a = make_state(a_spec, "players", (1, 1)) + b = make_state(b_spec, "monsters", (1, 2)) + # bite (1 swing) + claw (2 swings): each swing = attack roll + damage roll. + transcript = _full_attack_transcript(a, b, [15, 2, 15, 2, 15, 2]) + labels = _labels(transcript) + assert labels == ["bite", "claw #1", "claw #2"] + + +# --- Test 2: ranged weapons are excluded from the full_attack loop ---------- + +def test_full_attack_excludes_ranged_weapon() -> None: + a_spec = make_combatant( + "a", + [ + _attack("a", "sword", count=1), + _attack("a", "short bow", kind="ranged", attack_bonus=4), + ], + bab=1, + hp_max=20, + ) + b_spec = make_combatant("b", [_attack("b", "sword")], hp_max=20) + a = make_state(a_spec, "players", (1, 1)) + b = make_state(b_spec, "monsters", (1, 2)) + transcript = _full_attack_transcript(a, b, [15, 2]) + labels = _labels(transcript) + assert labels == ["sword"] + assert not any("short bow" in line for line in transcript) + + +# --- Test 3: mono-weapon full_attack keeps its labels and swing count ------- + +def test_full_attack_mono_weapon_labels_and_count() -> None: + # BAB 6 -> n = 2 iteratives, labels "sword #1" / "sword #2". + a_spec = make_combatant("a", [_attack("a", "sword", count=1)], bab=6, hp_max=20) + b_spec = make_combatant("b", [_attack("b", "sword")], hp_max=20) + a = make_state(a_spec, "players", (1, 1)) + b = make_state(b_spec, "monsters", (1, 2)) + transcript = _full_attack_transcript(a, b, [15, 2, 15, 2]) + assert _labels(transcript) == ["sword #1", "sword #2"] + + # BAB 1 -> n = 1, single label "sword" (no "#1"). + a_spec = make_combatant("a", [_attack("a", "sword", count=1)], bab=1, hp_max=20) + a = make_state(a_spec, "players", (1, 1)) + transcript = _full_attack_transcript(a, b, [15, 2]) + assert _labels(transcript) == ["sword"] + + +# --- Test 4: remaining swings/weapons are lost when the target drops -------- + +def test_full_attack_stops_when_target_drops() -> None: + a_spec = make_combatant( + "a", + [ + _attack("a", "bite", count=1), + _attack("a", "claw", count=2), + ], + bab=1, + hp_max=20, + ) + b_spec = make_combatant("b", [_attack("b", "sword")], hp_max=1) + a = make_state(a_spec, "players", (1, 1)) + b = make_state(b_spec, "monsters", (1, 2)) + # bite hits for 2 -> target (1 hp) drops; claw swings are lost. + transcript = _full_attack_transcript(a, b, [15, 2]) + labels = _labels(transcript) + assert labels == ["bite"] + assert not any("claw" in line for line in transcript) + + +# --- Test 5: determinism ---------------------------------------------------- + +def _run_full_attack() -> tuple[str, ...]: + a_spec = make_combatant( + "a", + [ + _attack("a", "bite", count=1), + _attack("a", "claw", count=2), + ], + bab=1, + hp_max=20, + ) + b_spec = make_combatant("b", [_attack("b", "sword")], hp_max=20) + a = make_state(a_spec, "players", (1, 1)) + b = make_state(b_spec, "monsters", (1, 2)) + + def policy(engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]: + if state.combatant.id == "a": + return (Action(kind="full_attack", target_id="b"),) + return (Action(kind="wait"),) + + engine = CombatEngine(SeededRng(42), make_grid(), [a, b], policy=policy) + return engine.run().transcript + + +def test_full_attack_determinism_same_seed_same_transcript() -> None: + assert _run_full_attack() == _run_full_attack()