docs: document enriched observation snapshot and add LLM demo test

This commit is contained in:
2026-09-06 16:50:42 +02:00
parent bfb8d0a30c
commit d2cd6f0702
2 changed files with 187 additions and 15 deletions
+154 -2
View File
@@ -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)