feat(combat): add serializable Observation snapshot and LLMPolicy adapter

This commit is contained in:
2026-09-06 11:07:30 +02:00
parent 7843756de2
commit e75ab58055
2 changed files with 644 additions and 1 deletions
+147 -1
View File
@@ -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
]