068b49794e
PF1e (APG Steal): "Items held in the hands (such as wielded weapons or wands) also cannot be taken with the steal maneuver—you must use the disarm combat maneuver instead." The model carries no non-held items, so a successful steal now takes nothing and logs "nothing stealable — wielded weapons require disarm". README updated; the retrieve_weapon tests keep locking the restoration mechanism directly. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
162 lines
5.8 KiB
Python
162 lines
5.8 KiB
Python
"""Tests for the ``retrieve_weapon`` action (recovering a stolen weapon).
|
|
|
|
L7 of the ``llm-policy-contract`` plan: ``Action`` gains a
|
|
``retrieve_weapon`` kind and a ``weapon_id`` field so a policy can restore a
|
|
weapon rendered unusable in ``state.weapon_stolen``. The weapon is removed
|
|
from ``state.weapon_stolen`` and becomes usable again via ``usable_attacks()``.
|
|
(The steal maneuver no longer fills this set: APG Steal forbids taking wielded
|
|
weapons.)
|
|
|
|
No default policy emits ``retrieve_weapon``; it is only accepted by
|
|
``_execute`` (usage via custom policy / LLM).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
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 make_combatant(cid: str, *, attack_bonus: int = 2) -> Combatant:
|
|
attack = AttackSpec(
|
|
id=f"{cid}-w",
|
|
name="sword",
|
|
kind="melee",
|
|
attack_bonus=attack_bonus,
|
|
damage=[DamageComponent(formula="1d4", types=["slashing"])],
|
|
)
|
|
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=6,
|
|
ac=ACProfile(total=13, touch=13, flat_footed=13),
|
|
bab=attack_bonus,
|
|
initiative_mod=0,
|
|
speed_land_ft=30,
|
|
saves=Saves(fort=0, ref=0, will=0),
|
|
attacks=[attack],
|
|
)
|
|
|
|
|
|
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 _weapon_ids(state: CombatantState) -> list[str]:
|
|
return [w.id for w in state.usable_attacks()]
|
|
|
|
|
|
# --- Test 1: retrieve restores a stolen weapon -------------------------------
|
|
|
|
def test_retrieve_restores_stolen_weapon() -> None:
|
|
a_spec = make_combatant("a", attack_bonus=2)
|
|
b_spec = make_combatant("b")
|
|
a = make_state(a_spec, "players", (1, 1))
|
|
b = make_state(b_spec, "monsters", (1, 2))
|
|
# The steal maneuver no longer takes wielded weapons (APG Steal), so the
|
|
# stolen state is set directly — retrieve_weapon is the mechanism under
|
|
# test here.
|
|
b.weapon_stolen.add("b-w")
|
|
engine = make_engine([], [a, b])
|
|
assert "b-w" not in _weapon_ids(b)
|
|
engine._execute(b, Action(kind="retrieve_weapon", weapon_id="b-w"))
|
|
assert not b.weapon_stolen
|
|
assert "b-w" in _weapon_ids(b)
|
|
|
|
|
|
# --- Test 2: weapon_id=None -> nothing to retrieve, state unchanged ----------
|
|
|
|
def test_retrieve_none_logs_nothing_to_retrieve() -> None:
|
|
a_spec = make_combatant("a", attack_bonus=2)
|
|
b_spec = make_combatant("b")
|
|
a = make_state(a_spec, "players", (1, 1))
|
|
b = make_state(b_spec, "monsters", (1, 2))
|
|
b.weapon_stolen.add("b-w")
|
|
engine = make_engine([], [a, b])
|
|
engine._execute(b, Action(kind="retrieve_weapon"))
|
|
assert "nothing to retrieve" in engine._transcript[-1]
|
|
assert b.weapon_stolen == {"b-w"}
|
|
|
|
|
|
# --- Test 3: unknown id (never stolen) -> log, state unchanged ---------------
|
|
|
|
def test_retrieve_unknown_id_logs_nothing_to_retrieve() -> None:
|
|
a_spec = make_combatant("a", attack_bonus=2)
|
|
b_spec = make_combatant("b")
|
|
a = make_state(a_spec, "players", (1, 1))
|
|
b = make_state(b_spec, "monsters", (1, 2))
|
|
b.weapon_stolen.add("b-w")
|
|
engine = make_engine([], [a, b])
|
|
engine._execute(b, Action(kind="retrieve_weapon", weapon_id="ghost"))
|
|
assert "nothing to retrieve" in engine._transcript[-1]
|
|
assert b.weapon_stolen == {"b-w"}
|
|
|
|
|
|
# --- Test 4: steal leaves wielded weapons; retrieve restores a stolen id -----
|
|
|
|
def test_steal_then_retrieve_restores_weapon() -> None:
|
|
# APG Steal: a wielded weapon cannot be taken — the maneuver provokes,
|
|
# succeeds, and steals nothing; the owner keeps swinging. A weapon that IS
|
|
# stolen (model state set directly) is restored by retrieve_weapon.
|
|
a_spec = make_combatant("a", attack_bonus=2)
|
|
b_spec = make_combatant("b")
|
|
a = make_state(a_spec, "players", (1, 1))
|
|
b = make_state(b_spec, "monsters", (1, 2))
|
|
engine = make_engine([1, 12], [a, b])
|
|
engine._execute(a, Action(kind="maneuver", target_id="b", maneuver="steal"))
|
|
assert not b.weapon_stolen
|
|
assert engine.weapon_for(b, a) is not None
|
|
b.weapon_stolen.add("b-w")
|
|
engine._execute(b, Action(kind="retrieve_weapon", weapon_id="b-w"))
|
|
assert not b.weapon_stolen
|
|
assert engine.weapon_for(b, a) is not None
|
|
assert "retrieve b-w (restored)" in engine._transcript[-1]
|
|
|
|
|
|
# --- Test 5: determinism -----------------------------------------------------
|
|
|
|
def _run_with_retrieve() -> tuple[str, ...]:
|
|
a_spec = make_combatant("a", attack_bonus=5)
|
|
b_spec = make_combatant("b")
|
|
a = make_state(a_spec, "players", (1, 1))
|
|
b = make_state(b_spec, "monsters", (1, 2))
|
|
b.weapon_stolen.add("b-w")
|
|
|
|
def policy(engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
|
|
if state.combatant.id == "b":
|
|
return (Action(kind="retrieve_weapon", weapon_id="b-w"),)
|
|
return (Action(kind="wait"),)
|
|
|
|
engine = CombatEngine(SeededRng(42), make_grid(), [a, b], policy=policy)
|
|
return engine.run().transcript
|
|
|
|
|
|
def test_determinism_same_seed_same_transcript() -> None:
|
|
assert _run_with_retrieve() == _run_with_retrieve()
|