docs: document enriched observation snapshot and add LLM demo test
This commit is contained in:
@@ -21,7 +21,7 @@ import json
|
||||
from pathlib import Path
|
||||
|
||||
from pf1e_simulator.abilities import AbilitySpec
|
||||
from pf1e_simulator.combat import CombatantState, CombatEngine
|
||||
from pf1e_simulator.combat import Action, CombatantState, CombatEngine, Observation
|
||||
from pf1e_simulator.dice import dice_to_str, parse_dice
|
||||
from pf1e_simulator.grid import Grid
|
||||
from pf1e_simulator.map import MapSpec, TerrainType
|
||||
@@ -34,7 +34,7 @@ from pf1e_simulator.models import (
|
||||
Saves,
|
||||
)
|
||||
from pf1e_simulator.rng import ScriptedRng
|
||||
from pf1e_simulator.spells import SpellSpec, load_spell
|
||||
from pf1e_simulator.spells import DamageEffect, SpellSpec, load_spell
|
||||
|
||||
|
||||
def make_combatant(
|
||||
@@ -1014,3 +1014,155 @@ def test_observation_enriched_e5_existing_keys_preserved() -> None:
|
||||
# combatants / pairs untouched by E5
|
||||
assert len(obs["combatants"]) == 2
|
||||
assert len(obs["pairs"]) == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 14. E6: end-to-end LLM policy reading ONLY the enriched Observation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _castable_spell_action(obs: Observation) -> Action | None:
|
||||
"""Return a cast_spell Action for the first in-range offensive spell with
|
||||
an available slot, or None."""
|
||||
acting = obs.combatants[obs.acting_combatant]
|
||||
for spell in acting["spells"]:
|
||||
if spell["level"] is None:
|
||||
continue
|
||||
slot_key = f"spell_slot_{spell['level']}"
|
||||
if acting["resources_left"].get(slot_key, 0) <= 0:
|
||||
continue
|
||||
for pair in obs.pairs:
|
||||
target = obs.combatants[pair["target_index"]]
|
||||
if (
|
||||
target["side"] != acting["side"]
|
||||
and target["active"]
|
||||
and pair["distance_ft"] <= spell["range_ft"]
|
||||
):
|
||||
return Action(
|
||||
kind="cast_spell",
|
||||
target_id=target["id"],
|
||||
spell_name=spell["name"],
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _retrieve_stolen_action(obs: Observation) -> Action | None:
|
||||
"""Return a retrieve_weapon Action for the first stolen weapon id exposed
|
||||
in the attacks reference, or None."""
|
||||
acting = obs.combatants[obs.acting_combatant]
|
||||
if not acting["weapon_stolen"]:
|
||||
return None
|
||||
stolen_id = acting["weapon_stolen"][0]
|
||||
for attack in acting["attacks"]:
|
||||
if attack["id"] == stolen_id:
|
||||
return Action(kind="retrieve_weapon", weapon_id=attack["id"])
|
||||
return None
|
||||
|
||||
|
||||
def _best_usable_attack(obs: Observation) -> dict[str, object] | None:
|
||||
"""Return the attack dict with the highest attack_bonus among usable
|
||||
weapons (skipping broken/stolen ids), or None."""
|
||||
acting = obs.combatants[obs.acting_combatant]
|
||||
best: dict[str, object] | None = None
|
||||
for attack in acting["attacks"]:
|
||||
if attack["id"] in acting["weapon_broken"]:
|
||||
continue
|
||||
if attack["id"] in acting["weapon_stolen"]:
|
||||
continue
|
||||
if best is None or attack["attack_bonus"] > best["attack_bonus"]:
|
||||
best = attack
|
||||
return best
|
||||
|
||||
|
||||
def _enriched_llm_policy(obs: Observation, state: CombatantState) -> tuple[Action, ...]:
|
||||
"""Read ONLY the observation — never state.combatant.* nor engine.*."""
|
||||
cast = _castable_spell_action(obs)
|
||||
if cast is not None:
|
||||
return (cast,)
|
||||
retrieve = _retrieve_stolen_action(obs)
|
||||
if retrieve is not None:
|
||||
return (retrieve,)
|
||||
if _best_usable_attack(obs) is None:
|
||||
return (Action(kind="wait"),)
|
||||
target_id = obs.combatants[obs.pairs[0]["target_index"]]["id"]
|
||||
return (Action(kind="full_attack", target_id=target_id),)
|
||||
|
||||
|
||||
def test_llm_policy_enriched_e2e() -> None:
|
||||
"""Given: a CombatEngine with an llm_policy that reads ONLY the enriched
|
||||
Observation (never ``state.combatant.*`` nor ``engine.*``)
|
||||
When: the battle runs
|
||||
Then: the policy emits at least 3 distinct decision types driven by the
|
||||
enriched fields — ``cast_spell`` (known offensive spell + slot available +
|
||||
target in range), ``retrieve_weapon`` (stolen weapon id taken from the
|
||||
``attacks`` reference) and ``full_attack`` with the best usable weapon —
|
||||
and the transcript shows the combat progressing."""
|
||||
magic_missile = SpellSpec(
|
||||
name="Magic Missile",
|
||||
level=1,
|
||||
school="evocation",
|
||||
range="medium",
|
||||
save="none",
|
||||
base_dc=10,
|
||||
effects=(DamageEffect(formula=parse_dice("1d4+1"), types=("force",)),),
|
||||
)
|
||||
sword = AttackSpec(
|
||||
id="hero-sword",
|
||||
name="short sword",
|
||||
kind="melee",
|
||||
attack_bonus=5,
|
||||
damage=[DamageComponent(formula=parse_dice("1d6"), types=["slashing"])],
|
||||
damage_bonus=2,
|
||||
)
|
||||
dagger = AttackSpec(
|
||||
id="hero-dagger",
|
||||
name="dagger",
|
||||
kind="melee",
|
||||
attack_bonus=8,
|
||||
damage=[DamageComponent(formula=parse_dice("1d4"), types=["piercing"])],
|
||||
damage_bonus=1,
|
||||
)
|
||||
hero = Combatant(
|
||||
id="hero",
|
||||
name="hero",
|
||||
level=1,
|
||||
size="Medium",
|
||||
abilities=AbilityScores(
|
||||
str_score=10,
|
||||
dex_score=10,
|
||||
con_score=12,
|
||||
int_score=10,
|
||||
wis_score=10,
|
||||
cha_score=10,
|
||||
),
|
||||
hp_max=20,
|
||||
ac=ACProfile(total=15, touch=15, flat_footed=15),
|
||||
bab=5,
|
||||
initiative_mod=0,
|
||||
speed_land_ft=30,
|
||||
saves=Saves(fort=0, ref=0, will=0),
|
||||
attacks=[sword, dagger],
|
||||
spells=["Magic Missile"],
|
||||
spell_slots={1: 1},
|
||||
)
|
||||
foe = make_combatant("foe", hp=10)
|
||||
hero_state = make_state(hero, "players", (0, 0))
|
||||
foe_state = make_state(foe, "monsters", (0, 1))
|
||||
hero_state.weapon_stolen.add("hero-dagger")
|
||||
engine = CombatEngine(
|
||||
ScriptedRng([20, 1, 1, 4, 1, 1, 15, 6, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]),
|
||||
make_grid(),
|
||||
[hero_state, foe_state],
|
||||
round_cap=10,
|
||||
llm_policy=_enriched_llm_policy,
|
||||
spell_registry={"Magic Missile": magic_missile},
|
||||
)
|
||||
|
||||
result = engine.run()
|
||||
transcript = result.transcript
|
||||
# cast_spell decision (round 1): the hero casts Magic Missile.
|
||||
assert any("cast Magic Missile" in line for line in transcript)
|
||||
# retrieve_weapon decision (round 2): the stolen dagger is restored.
|
||||
assert any("retrieve hero-dagger" in line for line in transcript)
|
||||
# full_attack decision (round 3): the hero swings the best usable weapon.
|
||||
assert any("hero: short sword vs foe" in line for line in transcript)
|
||||
|
||||
Reference in New Issue
Block a user