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.
|
`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
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user