feat(combat): add serializable Observation snapshot and LLMPolicy adapter
This commit is contained in:
@@ -344,6 +344,41 @@ class ChargeResult:
|
||||
reason: str | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Observation:
|
||||
"""Serializable snapshot of combat state for a policy/LLM.
|
||||
|
||||
Built by the engine at the point of policy invocation; the snapshot is the
|
||||
ONLY source of information for an ``LLMPolicy`` — it never exposes
|
||||
``engine.*`` callables nor mutable references to live state. All collections
|
||||
are copied (tuples/dicts) so the snapshot is frozen and JSON-serializable.
|
||||
|
||||
``combatants`` holds one dict per combatant in ``self._states`` order;
|
||||
``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).
|
||||
"""
|
||||
|
||||
round: int
|
||||
acting_combatant: int
|
||||
combatants: tuple[dict[str, object], ...]
|
||||
pairs: tuple[dict[str, object], ...]
|
||||
terrain: tuple[tuple[str | None, ...], ...]
|
||||
elevation: tuple[tuple[int, ...], ...]
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
"""Return a JSON-serializable dict with the pinned top-level keys."""
|
||||
return {
|
||||
"round": self.round,
|
||||
"acting_combatant": self.acting_combatant,
|
||||
"combatants": self.combatants,
|
||||
"pairs": self.pairs,
|
||||
"terrain": self.terrain,
|
||||
"elevation": self.elevation,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SaveResult:
|
||||
"""Outcome of one saving throw roll."""
|
||||
@@ -446,14 +481,21 @@ class CombatEngine:
|
||||
*,
|
||||
round_cap: int = 100,
|
||||
policy: Policy | None = None,
|
||||
llm_policy: LLMPolicy | None = None,
|
||||
spell_registry: dict[str, SpellSpec] | None = None,
|
||||
reaction_policy: ReactionPolicy | None = None,
|
||||
) -> None:
|
||||
if policy is not None and llm_policy is not None:
|
||||
msg = "policy and llm_policy are mutually exclusive"
|
||||
raise ValueError(msg)
|
||||
self._rng = rng
|
||||
self._grid = grid
|
||||
self._states = states
|
||||
self._round_cap = round_cap
|
||||
self._policy = policy if policy is not None else default_policy
|
||||
if llm_policy is not None:
|
||||
self._policy = self._llm_adapter(llm_policy)
|
||||
else:
|
||||
self._policy = policy if policy is not None else default_policy
|
||||
self._spell_registry = spell_registry or {}
|
||||
self._reaction_policy = reaction_policy
|
||||
self._in_reaction = False
|
||||
@@ -479,6 +521,108 @@ class CombatEngine:
|
||||
"""The spell registry used for spell lookups."""
|
||||
return self._spell_registry
|
||||
|
||||
def _llm_adapter(self, llm_policy: LLMPolicy) -> Policy:
|
||||
"""Wrap an ``LLMPolicy`` into a ``Policy``.
|
||||
|
||||
At the point of invocation (``actions = self._policy(self, state)`` in
|
||||
``_take_turn``), the Observation is built for the current state and
|
||||
delegated to ``llm_policy(observation, state)``. The adapter consumes
|
||||
no RNG and mutates no state.
|
||||
"""
|
||||
|
||||
def adapter(_engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
|
||||
observation = self._build_observation(state)
|
||||
return llm_policy(observation, state)
|
||||
|
||||
return adapter
|
||||
|
||||
def _build_observation(self, state: CombatantState) -> Observation:
|
||||
"""Build a frozen, serializable snapshot of the current combat state.
|
||||
|
||||
Pure read: never mutates state nor consumes RNG. ``state`` is the
|
||||
acting combatant (the one whose turn it is).
|
||||
"""
|
||||
acting_index = self._states.index(state)
|
||||
combatants = tuple(self._combatant_snapshot(s) for s in self._states)
|
||||
pairs = tuple(
|
||||
self._pair_snapshot(state, target)
|
||||
for target in self._states
|
||||
if target is not state
|
||||
)
|
||||
terrain = tuple(
|
||||
tuple(self._grid.terrain_at((r, c)).type for c in range(self._grid.width))
|
||||
for r in range(self._grid.height)
|
||||
)
|
||||
has_elevation = getattr(self._grid, "_elevation", None) is not None
|
||||
if has_elevation:
|
||||
elevation = tuple(
|
||||
tuple(
|
||||
self._grid.elevation_at((r, c)) for c in range(self._grid.width)
|
||||
)
|
||||
for r in range(self._grid.height)
|
||||
)
|
||||
else:
|
||||
elevation = ()
|
||||
return Observation(
|
||||
round=self._current_round,
|
||||
acting_combatant=acting_index,
|
||||
combatants=combatants,
|
||||
pairs=pairs,
|
||||
terrain=terrain,
|
||||
elevation=elevation,
|
||||
)
|
||||
|
||||
def _combatant_snapshot(self, state: CombatantState) -> dict[str, object]:
|
||||
"""One per-combatant dict for the Observation (frozen copies)."""
|
||||
conditions = tuple(
|
||||
(c.condition.name, c.rounds_left) for c in state.active_conditions
|
||||
) + tuple((c.name, 0) for c in state.conditions)
|
||||
resources = dict(state.resources_left)
|
||||
for level, count in state.spell_slots_left.items():
|
||||
resources[f"spell_slot_{level}"] = count
|
||||
return {
|
||||
"id": state.combatant.id,
|
||||
"side": state.side,
|
||||
"pos": state.pos,
|
||||
"occupied_cells": tuple(sorted(state.occupied_cells())),
|
||||
"hp": state.hp,
|
||||
"hp_max": state.combatant.hp_max,
|
||||
"active": state.active,
|
||||
"dead": state.dead,
|
||||
"conditions": conditions,
|
||||
"resources_left": resources,
|
||||
"weapon_broken": tuple(sorted(state.weapon_broken)),
|
||||
"weapon_stolen": tuple(sorted(state.weapon_stolen)),
|
||||
"grappled_with": state.grappled_with,
|
||||
"moved_this_turn": state.moved_this_turn,
|
||||
"power_attack": state.power_attack,
|
||||
}
|
||||
|
||||
def _pair_snapshot(
|
||||
self, state: CombatantState, target: CombatantState
|
||||
) -> dict[str, object]:
|
||||
"""One per-pair dict (acting combatant x target) for the Observation."""
|
||||
distance_ft = self.creature_distance(state, target) * _SQUARE_FT
|
||||
weapon = self.weapon_for(state, target)
|
||||
weapon_name = weapon.name if weapon is not None else None
|
||||
range_penalty = (
|
||||
self.range_penalty(weapon, distance_ft) if weapon is not None else 0
|
||||
)
|
||||
charge = self.can_charge(state, target)
|
||||
can_charge = charge is not None and charge.weapon is not None
|
||||
return {
|
||||
"target_index": self._states.index(target),
|
||||
"distance_ft": distance_ft,
|
||||
"weapon_for": weapon_name,
|
||||
"range_penalty": range_penalty,
|
||||
"can_charge": can_charge,
|
||||
"can_step5_to_attack": self.can_step5_to_attack(state, target),
|
||||
"has_line_of_effect": has_line_of_effect_cells(
|
||||
self._grid, state.occupied_cells(), target.occupied_cells()
|
||||
),
|
||||
"flanking": self._flanking_bonus(state, target) > 0,
|
||||
}
|
||||
|
||||
def _take_turn(self, state: CombatantState) -> bool:
|
||||
"""Execute one combatant's full turn; return True if the battle is over."""
|
||||
self._decrement_durations(state)
|
||||
@@ -3289,6 +3433,8 @@ def _nauseated_filter(actions: tuple[Action, ...]) -> tuple[Action, ...]:
|
||||
|
||||
Policy = Callable[[CombatEngine, CombatantState], tuple[Action, ...]]
|
||||
|
||||
LLMPolicy = Callable[[Observation, CombatantState], tuple[Action, ...]]
|
||||
|
||||
ReactionPolicy = Callable[
|
||||
[CombatEngine, CombatantState, ReactionEvent], Action | None
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user