feat(combat): expose class features and spell data in observation snapshot

This commit is contained in:
2026-09-06 13:54:26 +02:00
parent f3d874790a
commit 90c71bbeb0
2 changed files with 226 additions and 11 deletions
+58 -5
View File
@@ -183,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),
@@ -637,6 +660,10 @@ class CombatEngine:
}
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(
@@ -2561,6 +2588,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:
@@ -2593,11 +2650,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)