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
+33 -13
View File
@@ -455,19 +455,39 @@ Règles modélisées :
`spell_slots` sans clés dupliquées. `spell_slots` sans clés dupliquées.
- Snapshot d'observation (contrat LLM, Phase 2) : avant chaque appel de - Snapshot d'observation (contrat LLM, Phase 2) : avant chaque appel de
politique, le moteur construit une `Observation` sérialisable (`to_dict()` → politique, le moteur construit une `Observation` sérialisable (`to_dict()` →
JSON) figée — champs par combattant (id, side, pos, occupied_cells, JSON) figée — le snapshot est la SEULE source d'info de la LLM (aucune
hp/hp_max, active/dead, conditions noms+durées, resources_left, référence `engine.*` exposée, collections copiées/figées). Une politique LLM
weapon_broken/weapon_stolen, grappled_with, moved_this_turn, power_attack), se branche via `CombatEngine(..., llm_policy=...)` (`LLMPolicy(observation,
par paire combattant actif × cible (distance_ft, weapon_for, range_penalty, state) -> tuple[Action, ...]`). Schéma enrichi (E1-E5) :
can_charge, can_step5_to_attack, has_line_of_effect, flanking) et couches - Par combattant : id, side, pos, occupied_cells, hp/hp_max, active/dead,
terrain/élévation de la grille. Les API de décision exposées (items L1-L8) conditions (noms + rounds_left), resources_left (+ `spell_slot_{level}`),
weapon_broken/weapon_stolen, grappled_with, moved_this_turn, power_attack,
name, level, size, speed_land_ft, speed_fly_ft, ac {total, touch,
flat_footed}, saves {fort, ref, will}, cmb, cmd, attacks (référentiel
complet : id, name, kind, attack_bonus, damage, damage_bonus, crit_range,
crit_mult, reach_ft, range_increment_ft, count, firearm — y compris les
armes broken/stolen), features (noms des capacités), spells (résumés
enrichis du registre : name, level, school, range_ft, save, dc, summary).
- Par paire combattant actif × cible : target_index, distance_ft, weapon_for,
range_penalty, can_charge, can_step5_to_attack, has_line_of_effect,
flanking, movement_cost_ft, target_threatens_me, enemies_threatening_me.
- Top-level : round, acting_combatant, combatants, pairs, terrain, elevation,
threatened (grille bool des cases menacées par les ennemis),
terrain_semantics (un dict par type de terrain : type, move_cost,
blocks_los, cover).
Les helpers publics du moteur (L5) sont désormais embarqués EN DONNÉES dans
le snapshot (`threatened`, `terrain_semantics`, `movement_cost_ft`,
`target_threatens_me`, `enemies_threatening_me`) — une `LLMPolicy` ne peut
pas appeler `engine.*`, elle lit leurs résultats. Mapping des clés
`resources_left` (vérifié par grep sur `spend_resource`/`resources_left[` :
`channel_energy`, `hexes`, `grit`, `arcane_pool`, `bardic_rounds`, plus
`spell_slot_{level}` ajouté par le snapshot). Limites documentées : pas de
grille `reachable_ft` budget=speed (délibérément rejetée —
`movement_cost_ft` par paire + `terrain_semantics` + `speed_land_ft`
suffisent), pas de couvert par paire (déductible via `has_line_of_effect` +
`terrain_semantics` + positions). Les API de décision exposées (items L1-L8)
s'y retrouvent : `direction`/`trick_condition`/`weapon_id` sur `Action`, s'y retrouvent : `direction`/`trick_condition`/`weapon_id` sur `Action`,
`can_charge` → `ChargeResult`, zones menacées `threatened_squares`/ `can_charge` → `ChargeResult`, kinds `retrieve_weapon`/`repair_weapon`.
`enemies_threatening`, coûts `reachable_ft`/`movement_cost_ft`, kinds
`retrieve_weapon`/`repair_weapon`. Une politique LLM se branche via
`CombatEngine(..., llm_policy=...)` (`LLMPolicy(observation, state) ->
tuple[Action, ...]`) ; le snapshot est la SEULE source d'info de la LLM
(aucune référence `engine.*` exposée, collections copiées/figées).
Non modélisé : la couche `markers` (points nommés de la carte) est validée à Non modélisé : la couche `markers` (points nommés de la carte) est validée à
la lecture mais non appliquée dans la résolution. la lecture mais non appliquée dans la résolution.
@@ -555,7 +575,7 @@ la lecture mais non appliquée dans la résolution.
La gate de validation complète (tests + lint + types) : La gate de validation complète (tests + lint + types) :
```bash ```bash
uv run pytest -q # 664 tests uv run pytest -q # 714 tests
uv run ruff check src tests uv run ruff check src tests
uv run basedpyright src # mode strict uv run basedpyright src # mode strict
``` ```
+154 -2
View File
@@ -21,7 +21,7 @@ import json
from pathlib import Path from pathlib import Path
from pf1e_simulator.abilities import AbilitySpec 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.dice import dice_to_str, parse_dice
from pf1e_simulator.grid import Grid from pf1e_simulator.grid import Grid
from pf1e_simulator.map import MapSpec, TerrainType from pf1e_simulator.map import MapSpec, TerrainType
@@ -34,7 +34,7 @@ from pf1e_simulator.models import (
Saves, Saves,
) )
from pf1e_simulator.rng import ScriptedRng 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( def make_combatant(
@@ -1014,3 +1014,155 @@ def test_observation_enriched_e5_existing_keys_preserved() -> None:
# combatants / pairs untouched by E5 # combatants / pairs untouched by E5
assert len(obs["combatants"]) == 2 assert len(obs["combatants"]) == 2
assert len(obs["pairs"]) == 1 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)