feat(combat): resolve all natural melee weapons in full_attack
This commit is contained in:
@@ -2295,13 +2295,13 @@ class CombatEngine:
|
||||
return
|
||||
if target is None:
|
||||
return
|
||||
if kind == "attack":
|
||||
weapon = self.weapon_for(state, target)
|
||||
if weapon is None:
|
||||
return
|
||||
if kind == "attack":
|
||||
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
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user