docs: document enriched observation snapshot and add LLM demo test
This commit is contained in:
@@ -455,19 +455,39 @@ Règles modélisées :
|
||||
`spell_slots` sans clés dupliquées.
|
||||
- Snapshot d'observation (contrat LLM, Phase 2) : avant chaque appel de
|
||||
politique, le moteur construit une `Observation` sérialisable (`to_dict()` →
|
||||
JSON) figée — champs par combattant (id, side, pos, occupied_cells,
|
||||
hp/hp_max, active/dead, conditions noms+durées, resources_left,
|
||||
weapon_broken/weapon_stolen, grappled_with, moved_this_turn, power_attack),
|
||||
par paire combattant actif × cible (distance_ft, weapon_for, range_penalty,
|
||||
can_charge, can_step5_to_attack, has_line_of_effect, flanking) et couches
|
||||
terrain/élévation de la grille. Les API de décision exposées (items L1-L8)
|
||||
JSON) figée — le snapshot est la SEULE source d'info de la LLM (aucune
|
||||
référence `engine.*` exposée, collections copiées/figées). Une politique LLM
|
||||
se branche via `CombatEngine(..., llm_policy=...)` (`LLMPolicy(observation,
|
||||
state) -> tuple[Action, ...]`). Schéma enrichi (E1-E5) :
|
||||
- Par combattant : id, side, pos, occupied_cells, hp/hp_max, active/dead,
|
||||
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`,
|
||||
`can_charge` → `ChargeResult`, zones menacées `threatened_squares`/
|
||||
`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).
|
||||
`can_charge` → `ChargeResult`, kinds `retrieve_weapon`/`repair_weapon`.
|
||||
|
||||
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.
|
||||
@@ -555,7 +575,7 @@ la lecture mais non appliquée dans la résolution.
|
||||
La gate de validation complète (tests + lint + types) :
|
||||
|
||||
```bash
|
||||
uv run pytest -q # 664 tests
|
||||
uv run pytest -q # 714 tests
|
||||
uv run ruff check src tests
|
||||
uv run basedpyright src # mode strict
|
||||
```
|
||||
|
||||
@@ -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