feat(combat): resolve all natural melee weapons in full_attack

This commit is contained in:
2026-09-06 10:22:58 +02:00
parent fdcd103e8a
commit 9c49f85af8
2 changed files with 247 additions and 4 deletions
+42 -4
View File
@@ -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
+205
View File
@@ -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()