Compare commits
6 Commits
cd796ecffb
...
d2cd6f0702
| Author | SHA1 | Date | |
|---|---|---|---|
| d2cd6f0702 | |||
| bfb8d0a30c | |||
| cfeb28e336 | |||
| 90c71bbeb0 | |||
| f3d874790a | |||
| cd8051f2c4 |
@@ -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
|
||||
```
|
||||
|
||||
@@ -86,6 +86,7 @@ from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
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.loaders.combatant import CombatantLoadError, load_combatant
|
||||
from pf1e_simulator.models import PREDEFINED_SKILLS
|
||||
@@ -106,7 +107,7 @@ if TYPE_CHECKING:
|
||||
from typing import Literal
|
||||
|
||||
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.rng import Rng
|
||||
from pf1e_simulator.spells import SpellSpec
|
||||
@@ -182,6 +183,29 @@ def _save_label(result: SaveResult | None) -> str:
|
||||
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, ...] = (
|
||||
(-1, -1),
|
||||
(-1, 0),
|
||||
@@ -357,7 +381,15 @@ class Observation:
|
||||
``pairs`` holds one dict per (acting combatant x each other combatant) with
|
||||
``target_index`` disambiguating the target. ``terrain`` and ``elevation``
|
||||
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
|
||||
@@ -366,6 +398,8 @@ class Observation:
|
||||
pairs: tuple[dict[str, object], ...]
|
||||
terrain: tuple[tuple[str | None, ...], ...]
|
||||
elevation: tuple[tuple[int, ...], ...]
|
||||
threatened: tuple[tuple[bool, ...], ...] = ()
|
||||
terrain_semantics: tuple[dict[str, object], ...] = ()
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
"""Return a JSON-serializable dict with the pinned top-level keys."""
|
||||
@@ -376,6 +410,8 @@ class Observation:
|
||||
"pairs": self.pairs,
|
||||
"terrain": self.terrain,
|
||||
"elevation": self.elevation,
|
||||
"threatened": self.threatened,
|
||||
"terrain_semantics": self.terrain_semantics,
|
||||
}
|
||||
|
||||
|
||||
@@ -563,6 +599,8 @@ class CombatEngine:
|
||||
)
|
||||
else:
|
||||
elevation = ()
|
||||
threatened = self._threat_grid(state)
|
||||
terrain_semantics = self._terrain_semantics()
|
||||
return Observation(
|
||||
round=self._current_round,
|
||||
acting_combatant=acting_index,
|
||||
@@ -570,6 +608,56 @@ class CombatEngine:
|
||||
pairs=pairs,
|
||||
terrain=terrain,
|
||||
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]:
|
||||
@@ -596,6 +684,50 @@ class CombatEngine:
|
||||
"grappled_with": state.grappled_with,
|
||||
"moved_this_turn": state.moved_this_turn,
|
||||
"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(
|
||||
@@ -610,6 +742,16 @@ class CombatEngine:
|
||||
)
|
||||
charge = self.can_charge(state, target)
|
||||
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 {
|
||||
"target_index": self._states.index(target),
|
||||
"distance_ft": distance_ft,
|
||||
@@ -621,6 +763,9 @@ class CombatEngine:
|
||||
self._grid, state.occupied_cells(), target.occupied_cells()
|
||||
),
|
||||
"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:
|
||||
@@ -2520,6 +2665,36 @@ class CombatEngine:
|
||||
return
|
||||
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(
|
||||
self, caster: CombatantState, target: CombatantState | None, spell_name: str
|
||||
) -> None:
|
||||
@@ -2552,11 +2727,7 @@ class CombatEngine:
|
||||
if self._check_casting_aoo(caster, spell.level):
|
||||
return
|
||||
caster.spend_spell_slot(spell.level)
|
||||
# DC = 10 + spell level + caster's 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
|
||||
dc = 10 + spell.level + ability_mod
|
||||
dc = self._spell_dc(caster, spell)
|
||||
save_result = (
|
||||
self.resolve_save(
|
||||
target, spell.save, dc, save_descriptors=spell_save_descriptors(spell)
|
||||
|
||||
@@ -122,3 +122,20 @@ def parse_dice(text: str) -> DiceExpr:
|
||||
raise DiceParseError(text) from None
|
||||
|
||||
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
@@ -6,7 +6,7 @@ from collections import Counter
|
||||
|
||||
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
|
||||
|
||||
# ── Parsing: accepted forms ──────────────────────────────────────────────────
|
||||
@@ -194,3 +194,69 @@ class TestDiceExprFlat:
|
||||
rng = ScriptedRng([])
|
||||
expr = DiceExpr.flat(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
Reference in New Issue
Block a user