From d2cd6f07028c37fc9bcb645b0d1153b8bb8e8455 Mon Sep 17 00:00:00 2001 From: ctan Date: Sun, 6 Sep 2026 16:50:42 +0200 Subject: [PATCH] docs: document enriched observation snapshot and add LLM demo test --- README.md | 46 ++++++--- tests/test_observation_enriched.py | 156 ++++++++++++++++++++++++++++- 2 files changed, 187 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 4957627..f5f5df8 100644 --- a/README.md +++ b/README.md @@ -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 ``` diff --git a/tests/test_observation_enriched.py b/tests/test_observation_enriched.py index 45207f0..bd71670 100644 --- a/tests/test_observation_enriched.py +++ b/tests/test_observation_enriched.py @@ -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)