feat(combat): expose DR, conditions, creature type, features, and spell save semantics in observation

This commit is contained in:
2026-09-06 18:18:13 +02:00
parent d2cd6f0702
commit e29c472a67
3 changed files with 724 additions and 8 deletions
+118
View File
@@ -206,6 +206,49 @@ def _spell_summary(spell: SpellSpec) -> str:
return ", ".join(_spell_effect_summary(e) for e in spell.effects)
def _stat_modifier_dict(mod: StatModifier) -> dict[str, object]:
"""Serialize one StatModifier to a JSON-safe dict."""
return {
"target": mod.target,
"value": mod.value,
"bonus_type": mod.bonus_type,
"condition": mod.condition,
"weapon_filter": mod.weapon_filter,
}
def _condition_definition_dict(cond: Condition) -> dict[str, object]:
"""Serialize one Condition definition (mods + behavioral flags)."""
flags = [
name
for name, value in (
("denies_dex_to_ac", cond.denies_dex_to_ac),
("cant_act", cond.cant_act),
("cant_move", cond.cant_move),
("cant_attack", cond.cant_attack),
("cant_charge", cond.cant_charge),
("cant_aoo", cond.cant_aoo),
("must_flee", cond.must_flee),
)
if value
]
return {
"mods": [_stat_modifier_dict(m) for m in cond.modifiers],
"flags": flags,
"speed_mult": cond.speed_mult,
}
def _spell_half_on_save(spell: SpellSpec) -> bool:
"""True if any effect grants half damage on a successful save."""
for effect in spell.effects:
if isinstance(effect, DamageEffect) and effect.half_on_save:
return True
if isinstance(effect, AreaEffect) and effect.save_for_half:
return True
return False
_STEP_DELTAS: tuple[Pos, ...] = (
(-1, -1),
(-1, 0),
@@ -400,6 +443,7 @@ class Observation:
elevation: tuple[tuple[int, ...], ...]
threatened: tuple[tuple[bool, ...], ...] = ()
terrain_semantics: tuple[dict[str, object], ...] = ()
condition_definitions: tuple[tuple[str, dict[str, object]], ...] = ()
def to_dict(self) -> dict[str, object]:
"""Return a JSON-serializable dict with the pinned top-level keys."""
@@ -412,6 +456,7 @@ class Observation:
"elevation": self.elevation,
"threatened": self.threatened,
"terrain_semantics": self.terrain_semantics,
"condition_definitions": self.condition_definitions,
}
@@ -601,6 +646,10 @@ class CombatEngine:
elevation = ()
threatened = self._threat_grid(state)
terrain_semantics = self._terrain_semantics()
condition_definitions = tuple(
(name, _condition_definition_dict(cond))
for name, cond in sorted(CONDITIONS_BY_NAME.items())
)
return Observation(
round=self._current_round,
acting_combatant=acting_index,
@@ -610,6 +659,7 @@ class CombatEngine:
elevation=elevation,
threatened=threatened,
terrain_semantics=terrain_semantics,
condition_definitions=condition_definitions,
)
def _threat_grid(self, state: CombatantState) -> tuple[tuple[bool, ...], ...]:
@@ -668,6 +718,12 @@ class CombatEngine:
resources = dict(state.resources_left)
for level, count in state.spell_slots_left.items():
resources[f"spell_slot_{level}"] = count
dr = None
if state.combatant.dr is not None:
dr = {
"amount": state.combatant.dr.amount,
"bypass": ",".join(sorted(state.combatant.dr.bypass)),
}
return {
"id": state.combatant.id,
"side": state.side,
@@ -687,6 +743,50 @@ class CombatEngine:
"name": state.combatant.name,
"level": state.combatant.level,
"size": state.combatant.size,
"creature_type": state.combatant.creature_type,
"bab": state.combatant.bab,
"initiative_mod": state.combatant.initiative_mod,
"casting_ability": (
state.combatant.casting_ability if state.combatant.spells else None
),
"dr": dr,
"abilities": {
"str": state.combatant.abilities.str_score,
"dex": state.combatant.abilities.dex_score,
"con": state.combatant.abilities.con_score,
"int": state.combatant.abilities.int_score,
"wis": state.combatant.abilities.wis_score,
"cha": state.combatant.abilities.cha_score,
},
"skills": {
name: {
"rank": rank.rank,
"class_skill": rank.class_skill,
"armor_penalty": rank.armor_penalty,
}
for name, rank in state.combatant.skills.items()
if rank.rank > 0
},
"metamagic_feats": tuple(sorted(state.combatant.metamagic_feats)),
"weapon_damage": dict(state.weapon_damage),
"summon_duration": state.summon_duration or None,
"immediate_used": state.immediate_used,
"deed_up_close_and_deadly": state.deed_up_close_and_deadly,
"active_buffs": tuple(
{
"source": buff.source,
"rounds_left": buff.rounds_left,
"mods": tuple(
{
"target": mod.target,
"value": mod.value,
"bonus_type": mod.bonus_type,
}
for mod in buff.modifiers
),
}
for buff in state.active_buffs
),
"speed_land_ft": state.combatant.speed_land_ft,
"speed_fly_ft": state.combatant.speed_fly_ft,
"ac": {
@@ -725,6 +825,19 @@ class CombatEngine:
for attack in state.combatant.attacks
),
"features": tuple(sorted(f.name for f in state.combatant.features)),
"feature_details": tuple(
{
"name": feature.name,
"category": feature.category,
"source_detail": feature.source_detail,
"effects": tuple(
_stat_modifier_dict(mod) for mod in feature.effects
),
}
for feature in sorted(
state.combatant.features, key=lambda f: f.name
)
),
"spells": tuple(
self._spell_snapshot(state, name) for name in state.combatant.spells
),
@@ -2684,7 +2797,10 @@ class CombatEngine:
"save": "none",
"dc": None,
"summary": "",
"half_on_save": False,
"save_negates": False,
}
half_on_save = _spell_half_on_save(spell)
return {
"name": spell.name,
"level": spell.level,
@@ -2693,6 +2809,8 @@ class CombatEngine:
"save": spell.save,
"dc": self._spell_dc(state, spell),
"summary": _spell_summary(spell),
"half_on_save": half_on_save,
"save_negates": spell.save != "none" and not half_on_save,
}
def _cast_spell(