feat(combat): expose DR, conditions, creature type, features, and spell save semantics in observation
This commit is contained in:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user