3c3e30634e
- LLMPolicy is now Callable[[Observation], tuple[Action, ...]]: the snapshot is the policy's sole information source; the live CombatantState is no longer passed to new-style policies. - CombatEngine._llm_adapter keeps a compatibility path for pre-D1 two-argument callables: the signature is inspected ONCE at wrap time (_accepts_state) and legacy policies still receive (observation, state). - Observation.to_dict() carries schema_version (OBSERVATION_SCHEMA_VERSION=1) as its first pinned key; bump only on removal/meaning change. - Pinned keyset tests updated (test_observation, test_observation_enriched); new tests: observation-only e2e, legacy 2-arg e2e through the adapter, schema_version pin + JSON-serializability. - README: documents the new entry signature and the schema_version key.
250 lines
8.1 KiB
Python
250 lines
8.1 KiB
Python
"""Explicit action-execution feedback (Package U1).
|
|
|
|
Every Action produces an ActionResult; the previous turn's results are exposed
|
|
on CombatantState.last_action_results and in the snapshot under the additive
|
|
per-combatant key ``last_turn_results`` — so an LLM policy can learn which
|
|
actions were rejected (missing target, unsupported kind) or downgraded by the
|
|
staggered/nauseated filters.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from collections.abc import Callable
|
|
|
|
from pf1e_simulator.combat import (
|
|
Action,
|
|
ActionResult,
|
|
CombatantState,
|
|
CombatEngine,
|
|
LLMPolicy,
|
|
Observation,
|
|
)
|
|
from pf1e_simulator.conditions import CONDITIONS_BY_NAME
|
|
from pf1e_simulator.dice import parse_dice
|
|
from pf1e_simulator.effects import ActiveCondition
|
|
from pf1e_simulator.models import (
|
|
AbilityScores,
|
|
ACProfile,
|
|
AttackSpec,
|
|
Combatant,
|
|
DamageComponent,
|
|
Saves,
|
|
)
|
|
from pf1e_simulator.rng import ScriptedRng
|
|
from tests.test_observation_enriched import _snapshot, make_grid, make_state
|
|
|
|
|
|
def _melee(name: str, attack_bonus: int = 5) -> AttackSpec:
|
|
return AttackSpec(
|
|
id=f"{name}-sword",
|
|
name="short sword",
|
|
kind="melee",
|
|
attack_bonus=attack_bonus,
|
|
damage=[DamageComponent(formula=parse_dice("1d6"), types=["slashing"])],
|
|
)
|
|
|
|
|
|
def _combatant(id_: str, *, ac: int = 10) -> Combatant:
|
|
return Combatant(
|
|
id=id_,
|
|
name=id_,
|
|
level=1,
|
|
abilities=AbilityScores(
|
|
str_score=10, dex_score=10, con_score=10,
|
|
int_score=10, wis_score=10, cha_score=10,
|
|
),
|
|
ac=ACProfile(total=ac, touch=ac, flat_footed=ac),
|
|
bab=1,
|
|
initiative_mod=0,
|
|
speed_land_ft=30,
|
|
hp_max=100,
|
|
saves=Saves(fort=0, ref=0, will=0),
|
|
attacks=[_melee(id_)],
|
|
)
|
|
|
|
|
|
PolicyFn = Callable[[CombatEngine, CombatantState], tuple[Action, ...]]
|
|
|
|
|
|
def _engine(
|
|
rng: list[int],
|
|
policy: PolicyFn | None = None,
|
|
llm_policy: LLMPolicy | None = None,
|
|
) -> CombatEngine:
|
|
hero = make_state(_combatant("hero"), "players", (0, 0))
|
|
foe = make_state(_combatant("foe"), "monsters", (0, 1))
|
|
states = [hero, foe]
|
|
return CombatEngine(
|
|
ScriptedRng(rng), make_grid(), states,
|
|
policy=policy, llm_policy=llm_policy,
|
|
)
|
|
|
|
|
|
def _add_condition(state: CombatantState, name: str, rounds: int = 5) -> None:
|
|
state.active_conditions.append(
|
|
ActiveCondition(
|
|
condition=CONDITIONS_BY_NAME[name],
|
|
rounds_left=rounds,
|
|
source="test",
|
|
)
|
|
)
|
|
|
|
|
|
def _fixed_policy(*actions: Action) -> PolicyFn:
|
|
def policy(_engine_: CombatEngine, _state: CombatantState) -> tuple[Action, ...]:
|
|
return actions
|
|
|
|
return policy
|
|
|
|
|
|
# ── (a) move without a target → missing_target rejection ──────────────────────
|
|
|
|
|
|
def test_move_without_target_is_rejected() -> None:
|
|
eng = _engine([10, 5], policy=_fixed_policy(Action(kind="move")))
|
|
h, _f = eng._states
|
|
eng._roll_initiative()
|
|
eng._take_turn(h)
|
|
assert h.last_action_results == [
|
|
ActionResult(kind="move", ok=False, reason="missing_target")
|
|
]
|
|
assert any("move rejected (missing_target)" in line for line in eng._transcript)
|
|
# The rejection log line counts as activity: the "wait" fallback must not fire.
|
|
assert not any(line.endswith(": wait") for line in eng._transcript)
|
|
|
|
|
|
# ── (b) declared-but-unimplemented kinds → unsupported_kind ──────────────────
|
|
|
|
|
|
def test_swift_is_rejected_as_unsupported() -> None:
|
|
eng = _engine([10, 5], policy=_fixed_policy(Action(kind="swift")))
|
|
h, _f = eng._states
|
|
eng._roll_initiative()
|
|
eng._take_turn(h)
|
|
assert h.last_action_results == [
|
|
ActionResult(kind="swift", ok=False, reason="unsupported_kind")
|
|
]
|
|
assert any("swift rejected (unsupported_kind)" in line for line in eng._transcript)
|
|
|
|
|
|
def test_free_and_full_round_are_rejected_as_unsupported() -> None:
|
|
for action in (Action(kind="free"), Action(kind="full_round")):
|
|
eng = _engine([10, 5], policy=_fixed_policy(action))
|
|
h, _f = eng._states
|
|
eng._roll_initiative()
|
|
eng._take_turn(h)
|
|
assert h.last_action_results == [
|
|
ActionResult(kind=action.kind, ok=False, reason="unsupported_kind")
|
|
]
|
|
expected_log = f"{action.kind} rejected (unsupported_kind)"
|
|
assert any(expected_log in line for line in eng._transcript)
|
|
|
|
|
|
# ── (c) staggered downgrade is recorded AND the executed attack is ok ────────
|
|
|
|
|
|
def test_staggered_full_attack_downgrade_recorded_with_ok_attack() -> None:
|
|
# Rolls: init hero 10, init foe 5, attack d20 10 (10+5 >= AC 10 hits), dmg 3.
|
|
eng = _engine(
|
|
[10, 5, 10, 3],
|
|
policy=_fixed_policy(Action(kind="full_attack", target_id="foe")),
|
|
)
|
|
h, f = eng._states
|
|
_add_condition(h, "staggered")
|
|
eng._roll_initiative()
|
|
eng._take_turn(h)
|
|
results = h.last_action_results
|
|
assert ActionResult(kind="full_attack", ok=False, reason="downgraded:staggered") in results
|
|
assert results[-1] == ActionResult(kind="attack", ok=True, reason="ok")
|
|
assert f.hp == 97
|
|
|
|
|
|
def test_nauseated_full_attack_downgrade_recorded() -> None:
|
|
# Nauseated (B6): only move-like actions survive the filter — full_attack
|
|
# is dropped; only the synthetic downgrade result remains.
|
|
eng = _engine(
|
|
[10, 5],
|
|
policy=_fixed_policy(Action(kind="full_attack", target_id="foe")),
|
|
)
|
|
h, _f = eng._states
|
|
_add_condition(h, "nauseated")
|
|
eng._roll_initiative()
|
|
eng._take_turn(h)
|
|
assert h.last_action_results == [
|
|
ActionResult(kind="full_attack", ok=False, reason="downgraded:nauseated")
|
|
]
|
|
|
|
|
|
# ── (d) valid attack → ok=True, no new transcript noise ──────────────────────
|
|
|
|
|
|
def test_valid_attack_reports_ok() -> None:
|
|
# Rolls: init hero 10, init foe 5, attack d20 10 (hits AC 10), dmg 3.
|
|
eng = _engine(
|
|
[10, 5, 10, 3],
|
|
policy=_fixed_policy(Action(kind="attack", target_id="foe")),
|
|
)
|
|
h, f = eng._states
|
|
eng._roll_initiative()
|
|
eng._take_turn(h)
|
|
assert h.last_action_results == [
|
|
ActionResult(kind="attack", ok=True, reason="ok")
|
|
]
|
|
assert f.hp == 97
|
|
assert not any("rejected" in line for line in eng._transcript)
|
|
|
|
|
|
def test_cant_act_clears_results() -> None:
|
|
eng = _engine([10, 5], policy=_fixed_policy(Action(kind="attack", target_id="foe")))
|
|
h, _f = eng._states
|
|
_add_condition(h, "paralyzed")
|
|
h.last_action_results = [ActionResult(kind="attack", ok=True, reason="ok")]
|
|
eng._roll_initiative()
|
|
eng._take_turn(h)
|
|
assert h.last_action_results == []
|
|
|
|
|
|
# ── (e) the Observation exposes last_turn_results with the right shape ───────
|
|
|
|
|
|
def test_snapshot_exposes_last_turn_results() -> None:
|
|
eng = _engine(
|
|
[10, 5, 10, 3],
|
|
policy=_fixed_policy(Action(kind="attack", target_id="foe")),
|
|
)
|
|
h, _f = eng._states
|
|
eng._roll_initiative()
|
|
eng._take_turn(h)
|
|
obs = _snapshot(eng, h)
|
|
hero_c, foe_c = obs["combatants"][0], obs["combatants"][1]
|
|
assert hero_c["last_turn_results"] == (
|
|
{"kind": "attack", "ok": True, "reason": "ok"},
|
|
)
|
|
assert foe_c["last_turn_results"] == ()
|
|
json.dumps(obs) # JSON-serializable contract
|
|
|
|
|
|
def test_results_visible_in_next_turn_observation() -> None:
|
|
# The Observation is built INSIDE the policy call at the start of a turn:
|
|
# the second invocation must already see the first turn's rejection.
|
|
seen: list[Observation] = []
|
|
|
|
def llm_policy(obs: Observation) -> tuple[Action, ...]:
|
|
seen.append(obs)
|
|
if len(seen) == 1:
|
|
return (Action(kind="move"),)
|
|
return (Action(kind="wait"),)
|
|
|
|
eng = _engine([10, 5], llm_policy=llm_policy)
|
|
h, _f = eng._states
|
|
eng._roll_initiative()
|
|
eng._take_turn(h)
|
|
eng._take_turn(h)
|
|
assert len(seen) == 2
|
|
first_hero = seen[0].combatants[0]
|
|
assert first_hero["last_turn_results"] == ()
|
|
second_hero = seen[1].combatants[0]
|
|
assert second_hero["last_turn_results"] == (
|
|
{"kind": "move", "ok": False, "reason": "missing_target"},
|
|
)
|