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