Compare commits

...

6 Commits

5 changed files with 1463 additions and 21 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
``` ```
+178 -7
View File
@@ -86,6 +86,7 @@ from pathlib import Path
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from pf1e_simulator.conditions import CONDITIONS_BY_NAME, Condition from pf1e_simulator.conditions import CONDITIONS_BY_NAME, Condition
from pf1e_simulator.dice import dice_to_str
from pf1e_simulator.effects import ActiveBuff, ActiveCondition, StatModifier, resolve_modifiers from pf1e_simulator.effects import ActiveBuff, ActiveCondition, StatModifier, resolve_modifiers
from pf1e_simulator.loaders.combatant import CombatantLoadError, load_combatant from pf1e_simulator.loaders.combatant import CombatantLoadError, load_combatant
from pf1e_simulator.models import PREDEFINED_SKILLS from pf1e_simulator.models import PREDEFINED_SKILLS
@@ -106,7 +107,7 @@ if TYPE_CHECKING:
from typing import Literal from typing import Literal
from pf1e_simulator.grid import Grid from pf1e_simulator.grid import Grid
from pf1e_simulator.map import Pos from pf1e_simulator.map import Pos, TerrainType
from pf1e_simulator.models import AttackSpec, Combatant, DamageReduction from pf1e_simulator.models import AttackSpec, Combatant, DamageReduction
from pf1e_simulator.rng import Rng from pf1e_simulator.rng import Rng
from pf1e_simulator.spells import SpellSpec from pf1e_simulator.spells import SpellSpec
@@ -182,6 +183,29 @@ def _save_label(result: SaveResult | None) -> str:
return "SAVED" if result.success else "FAILED" return "SAVED" if result.success else "FAILED"
def _spell_effect_summary(effect: SpellEffect) -> str:
"""Short serializable summary of one spell effect (never the description)."""
if isinstance(effect, DamageEffect):
parts = [dice_to_str(effect.formula), *effect.types]
if effect.half_on_save:
parts.append("half on save")
return " ".join(parts)
if isinstance(effect, ConditionEffect):
return f"{effect.condition_name} {effect.duration_rounds}r"
if isinstance(effect, HealEffect):
return f"heal {dice_to_str(effect.formula)}"
if isinstance(effect, BuffEffect):
return f"buff {effect.duration_rounds}r"
if isinstance(effect, AreaEffect):
return f"area {effect.radius_ft}ft"
return f"summon {effect.creature}"
def _spell_summary(spell: SpellSpec) -> str:
"""Short serializable summary of a spell's effects, joined with ', '."""
return ", ".join(_spell_effect_summary(e) for e in spell.effects)
_STEP_DELTAS: tuple[Pos, ...] = ( _STEP_DELTAS: tuple[Pos, ...] = (
(-1, -1), (-1, -1),
(-1, 0), (-1, 0),
@@ -357,7 +381,15 @@ class Observation:
``pairs`` holds one dict per (acting combatant x each other combatant) with ``pairs`` holds one dict per (acting combatant x each other combatant) with
``target_index`` disambiguating the target. ``terrain`` and ``elevation`` ``target_index`` disambiguating the target. ``terrain`` and ``elevation``
are height x width layers aligned on the same dimensions (``elevation`` is are height x width layers aligned on the same dimensions (``elevation`` is
``()`` when the grid has no elevation layer). ``()`` when the grid has no elevation layer). ``threatened`` is a height x
width boolean grid whose cells are True when threatened by at least one
ENEMY of the acting combatant (active, opposite side, with a melee
weapon); the union is bounded to the grid — out-of-grid threatened cells
are dropped. ``terrain_semantics`` is one dict per distinct terrain type
on the map (sorted by type for determinism), carrying
``{type, move_cost, blocks_los, cover}`` — derived from
``grid.terrain_at((r, c))`` over the entire map (the Grid does not expose
``MapSpec.legend``).
""" """
round: int round: int
@@ -366,6 +398,8 @@ class Observation:
pairs: tuple[dict[str, object], ...] pairs: tuple[dict[str, object], ...]
terrain: tuple[tuple[str | None, ...], ...] terrain: tuple[tuple[str | None, ...], ...]
elevation: tuple[tuple[int, ...], ...] elevation: tuple[tuple[int, ...], ...]
threatened: tuple[tuple[bool, ...], ...] = ()
terrain_semantics: tuple[dict[str, object], ...] = ()
def to_dict(self) -> dict[str, object]: def to_dict(self) -> dict[str, object]:
"""Return a JSON-serializable dict with the pinned top-level keys.""" """Return a JSON-serializable dict with the pinned top-level keys."""
@@ -376,6 +410,8 @@ class Observation:
"pairs": self.pairs, "pairs": self.pairs,
"terrain": self.terrain, "terrain": self.terrain,
"elevation": self.elevation, "elevation": self.elevation,
"threatened": self.threatened,
"terrain_semantics": self.terrain_semantics,
} }
@@ -563,6 +599,8 @@ class CombatEngine:
) )
else: else:
elevation = () elevation = ()
threatened = self._threat_grid(state)
terrain_semantics = self._terrain_semantics()
return Observation( return Observation(
round=self._current_round, round=self._current_round,
acting_combatant=acting_index, acting_combatant=acting_index,
@@ -570,6 +608,56 @@ class CombatEngine:
pairs=pairs, pairs=pairs,
terrain=terrain, terrain=terrain,
elevation=elevation, elevation=elevation,
threatened=threatened,
terrain_semantics=terrain_semantics,
)
def _threat_grid(self, state: CombatantState) -> tuple[tuple[bool, ...], ...]:
"""Build the height x width bool grid of cells threatened by enemies.
Union of ``threatened_squares(e)`` for every enemy ``e`` of ``state``
(active, opposite side, with a usable melee weapon). The union is
bounded to the grid: cells produced by the public helper that lie
outside the map are dropped (helper output is in 5-ft-square coords
and may overflow on small maps).
"""
enemies = (
s for s in self._states
if s is not state
and s.active
and s.side != state.side
and any(w.kind == "melee" for w in s.usable_attacks())
)
threatened: set[Pos] = set()
for enemy in enemies:
threatened |= self.threatened_squares(enemy)
return tuple(
tuple((r, c) in threatened for c in range(self._grid.width))
for r in range(self._grid.height)
)
def _terrain_semantics(self) -> tuple[dict[str, object], ...]:
"""One dict per distinct ``TerrainType`` on the map (sorted by type).
Derived by deduplicating ``grid.terrain_at`` over the entire map: the
Grid stores resolved ``TerrainType`` objects in ``_cells`` but does
not expose ``MapSpec.legend`` directly, so iterating the cells is
the safest source. The Grid is guaranteed to hold at least one
cell (constructor enforces non-empty ``cells``).
"""
seen: dict[str, TerrainType] = {}
for r in range(self._grid.height):
for c in range(self._grid.width):
cell = self._grid.terrain_at((r, c))
seen.setdefault(cell.type, cell)
return tuple(
{
"type": cell.type,
"move_cost": cell.move_cost,
"blocks_los": cell.blocks_los,
"cover": cell.cover,
}
for cell in (seen[name] for name in sorted(seen))
) )
def _combatant_snapshot(self, state: CombatantState) -> dict[str, object]: def _combatant_snapshot(self, state: CombatantState) -> dict[str, object]:
@@ -596,6 +684,50 @@ class CombatEngine:
"grappled_with": state.grappled_with, "grappled_with": state.grappled_with,
"moved_this_turn": state.moved_this_turn, "moved_this_turn": state.moved_this_turn,
"power_attack": state.power_attack, "power_attack": state.power_attack,
"name": state.combatant.name,
"level": state.combatant.level,
"size": state.combatant.size,
"speed_land_ft": state.combatant.speed_land_ft,
"speed_fly_ft": state.combatant.speed_fly_ft,
"ac": {
"total": state.combatant.ac.total,
"touch": state.combatant.ac.touch,
"flat_footed": state.combatant.ac.flat_footed,
},
"saves": {
"fort": state.combatant.saves.fort,
"ref": state.combatant.saves.ref,
"will": state.combatant.saves.will,
},
"cmb": self._maneuver_cmb(state),
"cmd": self._maneuver_cmd(state),
"attacks": tuple(
{
"id": attack.id,
"name": attack.name,
"kind": attack.kind,
"attack_bonus": attack.attack_bonus,
"damage": tuple(
{
"formula": dice_to_str(component.formula),
"types": tuple(component.types),
}
for component in attack.damage
),
"damage_bonus": attack.damage_bonus,
"crit_range": attack.crit_range,
"crit_mult": attack.crit_mult,
"reach_ft": attack.reach_ft,
"range_increment_ft": attack.range_increment_ft,
"count": attack.count,
"firearm": attack.firearm,
}
for attack in state.combatant.attacks
),
"features": tuple(sorted(f.name for f in state.combatant.features)),
"spells": tuple(
self._spell_snapshot(state, name) for name in state.combatant.spells
),
} }
def _pair_snapshot( def _pair_snapshot(
@@ -610,6 +742,16 @@ class CombatEngine:
) )
charge = self.can_charge(state, target) charge = self.can_charge(state, target)
can_charge = charge is not None and charge.weapon is not None can_charge = charge is not None and charge.weapon is not None
occupied = state.occupied_cells()
target_threatens_me = bool(
target.active
and any(w.kind == "melee" for w in target.usable_attacks())
and self.threatened_squares(target) & occupied
)
threatening_enemies: set[str] = set()
for cell in occupied:
for enemy in self.enemies_threatening(state, cell):
threatening_enemies.add(enemy.combatant.id)
return { return {
"target_index": self._states.index(target), "target_index": self._states.index(target),
"distance_ft": distance_ft, "distance_ft": distance_ft,
@@ -621,6 +763,9 @@ class CombatEngine:
self._grid, state.occupied_cells(), target.occupied_cells() self._grid, state.occupied_cells(), target.occupied_cells()
), ),
"flanking": self._flanking_bonus(state, target) > 0, "flanking": self._flanking_bonus(state, target) > 0,
"movement_cost_ft": self.movement_cost_ft(state, target),
"target_threatens_me": target_threatens_me,
"enemies_threatening_me": len(threatening_enemies),
} }
def _take_turn(self, state: CombatantState) -> bool: def _take_turn(self, state: CombatantState) -> bool:
@@ -2520,6 +2665,36 @@ class CombatEngine:
return return
self._charge(state, target, result.weapon) self._charge(state, target, result.weapon)
def _spell_dc(self, caster: CombatantState, spell: SpellSpec) -> int:
"""Spell save DC: 10 + spell level + casting ability modifier."""
ability_name = caster.combatant.casting_ability.lower()
ability_score = getattr(caster.combatant.abilities, f"{ability_name}_score")
ability_mod = (ability_score - 10) // 2
return 10 + spell.level + ability_mod
def _spell_snapshot(self, state: CombatantState, spell_name: str) -> dict[str, object]:
"""One per-spell dict for the Observation, enriched from the registry."""
spell = self._spell_registry.get(spell_name)
if spell is None:
return {
"name": spell_name,
"level": None,
"school": "",
"range_ft": 0,
"save": "none",
"dc": None,
"summary": "",
}
return {
"name": spell.name,
"level": spell.level,
"school": spell.school,
"range_ft": spell_range_ft(spell, state.combatant.level),
"save": spell.save,
"dc": self._spell_dc(state, spell),
"summary": _spell_summary(spell),
}
def _cast_spell( def _cast_spell(
self, caster: CombatantState, target: CombatantState | None, spell_name: str self, caster: CombatantState, target: CombatantState | None, spell_name: str
) -> None: ) -> None:
@@ -2552,11 +2727,7 @@ class CombatEngine:
if self._check_casting_aoo(caster, spell.level): if self._check_casting_aoo(caster, spell.level):
return return
caster.spend_spell_slot(spell.level) caster.spend_spell_slot(spell.level)
# DC = 10 + spell level + caster's casting ability modifier dc = self._spell_dc(caster, spell)
ability_name = caster.combatant.casting_ability.lower()
ability_score = getattr(caster.combatant.abilities, f"{ability_name}_score")
ability_mod = (ability_score - 10) // 2
dc = 10 + spell.level + ability_mod
save_result = ( save_result = (
self.resolve_save( self.resolve_save(
target, spell.save, dc, save_descriptors=spell_save_descriptors(spell) target, spell.save, dc, save_descriptors=spell_save_descriptors(spell)
+17
View File
@@ -122,3 +122,20 @@ def parse_dice(text: str) -> DiceExpr:
raise DiceParseError(text) from None raise DiceParseError(text) from None
return DiceExpr.flat(n) return DiceExpr.flat(n)
def dice_to_str(expr: DiceExpr) -> str:
"""Serialize a DiceExpr into canonical dice notation.
Inverse of `parse_dice`: flat expressions (count=0) render as the bare
integer, dice expressions render as "NdS" with a signed bonus appended
only when non-zero.
"""
if expr.count == 0:
return str(expr.bonus)
base = f"{expr.count}d{expr.sides}"
if expr.bonus > 0:
return f"{base}+{expr.bonus}"
if expr.bonus < 0:
return f"{base}{expr.bonus}"
return base
+67 -1
View File
@@ -6,7 +6,7 @@ from collections import Counter
import pytest import pytest
from pf1e_simulator.dice import DiceExpr, DiceParseError, parse_dice from pf1e_simulator.dice import DiceExpr, DiceParseError, dice_to_str, parse_dice
from pf1e_simulator.rng import ScriptedRng from pf1e_simulator.rng import ScriptedRng
# ── Parsing: accepted forms ────────────────────────────────────────────────── # ── Parsing: accepted forms ──────────────────────────────────────────────────
@@ -194,3 +194,69 @@ class TestDiceExprFlat:
rng = ScriptedRng([]) rng = ScriptedRng([])
expr = DiceExpr.flat(5) expr = DiceExpr.flat(5)
assert expr.roll(rng) == 5 assert expr.roll(rng) == 5
# ── dice_to_str: canonical string representation ────────────────────────────
class TestDiceToStr:
"""Given: a DiceExpr
When: dice_to_str is called
Then: returns the canonical string inverse of parse_dice."""
def test_2d6_plus_3(self) -> None:
assert dice_to_str(DiceExpr(count=2, sides=6, bonus=3)) == "2d6+3"
def test_1d4_no_bonus(self) -> None:
assert dice_to_str(DiceExpr(count=1, sides=4, bonus=0)) == "1d4"
def test_flat_positive(self) -> None:
assert dice_to_str(DiceExpr(count=0, sides=0, bonus=7)) == "7"
def test_flat_negative(self) -> None:
assert dice_to_str(DiceExpr(count=0, sides=0, bonus=-5)) == "-5"
def test_flat_zero(self) -> None:
assert dice_to_str(DiceExpr(count=0, sides=0, bonus=0)) == "0"
def test_negative_bonus(self) -> None:
assert dice_to_str(DiceExpr(count=2, sides=6, bonus=-1)) == "2d6-1"
def test_d20_no_bonus(self) -> None:
assert dice_to_str(DiceExpr(count=1, sides=20, bonus=0)) == "1d20"
def test_large_dice(self) -> None:
assert dice_to_str(DiceExpr(count=10, sides=100, bonus=50)) == "10d100+50"
class TestDiceToStrRoundTrip:
"""Given: a valid dice string
When: parse_dice → dice_to_str → parse_dice
Then: the final DiceExpr equals the first."""
@pytest.mark.parametrize(
"text",
[
"2d6+3",
"2d6-1",
"1d4",
"d20",
"7",
"-5",
"0",
"10d100+50",
"1d6",
"4d8-2",
],
)
def test_round_trip_equivalence(self, text: str) -> None:
first = parse_dice(text)
serialized = dice_to_str(first)
second = parse_dice(serialized)
assert first == second
def test_round_trip_any_flat(self) -> None:
for n in (-100, -1, 0, 1, 42, 999):
expr = parse_dice(str(n))
assert dice_to_str(expr) == str(n)
assert parse_dice(dice_to_str(expr)) == expr
File diff suppressed because it is too large Load Diff