Files
pf1e-simulator/tests/test_multi_natural_weapons.py

206 lines
6.8 KiB
Python

"""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()