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)
+168 -6
View File
@@ -1,8 +1,8 @@
"""Tests for E1: enriched per-combatant combat stats in the Observation snapshot.
"""Tests for E1-E3: enriched per-combatant data in the Observation snapshot.
Adds the static combat-stat fields to each ``combatants`` dict of the
snapshot (name, level, size, speeds, ac, saves, cmb, cmd) — a strict
superset of the L1 schema. No existing key is modified or renamed.
Adds the static combat-stat fields (E1), the attacks reference (E2) and the
class features + spell data (E3) to each ``combatants`` dict of the snapshot
— a strict superset of the L1 schema. No existing key is modified or renamed.
Covers:
- The enriched schema 1v1 (exact values derived from the constructed
@@ -11,12 +11,16 @@ Covers:
same state.
- The optional ``speed_fly_ft`` field (``int | None``).
- JSON serializability and frozen copies (same conventions as L1 tests 9/10).
- ``features`` (sorted AbilitySpec names) and ``spells`` (registry-enriched
dicts, graceful for unknown spells).
"""
from __future__ import annotations
import json
from pathlib import Path
from pf1e_simulator.abilities import AbilitySpec
from pf1e_simulator.combat import CombatantState, CombatEngine
from pf1e_simulator.dice import dice_to_str, parse_dice
from pf1e_simulator.grid import Grid
@@ -30,6 +34,7 @@ from pf1e_simulator.models import (
Saves,
)
from pf1e_simulator.rng import ScriptedRng
from pf1e_simulator.spells import SpellSpec, load_spell
def make_combatant(
@@ -41,6 +46,8 @@ def make_combatant(
attack_bonus: int = 5,
speed: int = 30,
speed_fly: int | None = None,
features: list[AbilitySpec] | None = None,
spells: list[str] | None = None,
) -> Combatant:
"""Build a single-weapon melee combatant with known static stats."""
attack = AttackSpec(
@@ -71,6 +78,8 @@ def make_combatant(
speed_fly_ft=speed_fly,
saves=Saves(fort=0, ref=0, will=0),
attacks=[attack],
features=features or [],
spells=spells or [],
)
@@ -88,8 +97,13 @@ def make_grid() -> Grid:
return Grid.from_spec(spec)
def make_engine(states: list[CombatantState]) -> CombatEngine:
return CombatEngine(ScriptedRng([]), make_grid(), states)
def make_engine(
states: list[CombatantState],
spell_registry: dict[str, SpellSpec] | None = None,
) -> CombatEngine:
return CombatEngine(
ScriptedRng([]), make_grid(), states, spell_registry=spell_registry
)
def _snapshot(engine: CombatEngine, state: CombatantState) -> dict:
@@ -377,3 +391,151 @@ def test_observation_enriched_attacks_formula_uses_dice_to_str() -> None:
attacks = obs["combatants"][0]["attacks"]
assert attacks[0]["damage"][0]["formula"] == dice_to_str(parse_dice("2d6+3"))
assert attacks[1]["damage"][0]["formula"] == dice_to_str(parse_dice("2d6-1"))
# ---------------------------------------------------------------------------
# 6. E3: features — sorted AbilitySpec names
# ---------------------------------------------------------------------------
def test_observation_enriched_features_sorted() -> None:
"""Given: a combatant with several class features
When: an Observation is built
Then: features lists the AbilitySpec names, sorted for determinism."""
hero = make_combatant(
"hero",
features=[
AbilitySpec(name="Channel Energy", category="class"),
AbilitySpec(name="Power Attack", category="feat"),
AbilitySpec(name="Toughness", category="feat"),
],
)
foe = make_combatant("foe")
hero_state = make_state(hero, "players", (0, 0))
foe_state = make_state(foe, "monsters", (0, 3))
engine = make_engine([hero_state, foe_state])
obs = _snapshot(engine, hero_state)
features = obs["combatants"][0]["features"]
assert features == ("Channel Energy", "Power Attack", "Toughness")
assert obs["combatants"][1]["features"] == ()
# ---------------------------------------------------------------------------
# 7. E3: spells — known spell enriched from the registry
# ---------------------------------------------------------------------------
def test_observation_enriched_spells_known() -> None:
"""Given: a combatant with a known spell and a populated registry
When: an Observation is built
Then: the spell dict carries level/school/range_ft/save/dc/summary."""
fireball = load_spell(Path("data/spells/fireball.json"))
hero = make_combatant("hero", spells=["Fireball"])
foe = make_combatant("foe")
hero_state = make_state(hero, "players", (0, 0))
foe_state = make_state(foe, "monsters", (0, 3))
engine = make_engine([hero_state, foe_state], spell_registry={"Fireball": fireball})
obs = _snapshot(engine, hero_state)
spells = obs["combatants"][0]["spells"]
assert len(spells) == 1
s = spells[0]
assert s["name"] == "Fireball"
assert s["level"] == 3
assert s["school"] == "evocation"
# long range: 400 + 40 * caster_level (level 1) = 440
assert s["range_ft"] == 440
assert s["save"] == "ref"
# DC = 10 + spell.level + casting ability mod (wis 10 -> 0) = 13
assert s["dc"] == 13
# AreaEffect -> "area {radius}ft"
assert s["summary"] == "area 20ft"
def test_observation_enriched_spells_dc_matches_engine() -> None:
"""Given: a combatant with a known spell and a non-default casting ability
When: an Observation is built
Then: the snapshot dc equals the engine's _cast_spell dc on the same state."""
fireball = load_spell(Path("data/spells/fireball.json"))
hero = make_combatant("hero", spells=["Fireball"])
hero = hero.model_copy(
update={
"abilities": hero.abilities.model_copy(
update={"cha_score": 16}
),
"casting_ability": "cha",
}
)
foe = make_combatant("foe")
hero_state = make_state(hero, "players", (0, 0))
foe_state = make_state(foe, "monsters", (0, 3))
engine = make_engine([hero_state, foe_state], spell_registry={"Fireball": fireball})
obs = _snapshot(engine, hero_state)
s = obs["combatants"][0]["spells"][0]
# DC = 10 + 3 + 3 = 16
assert s["dc"] == 16
# ---------------------------------------------------------------------------
# 8. E3: spells — unknown spell -> empty dict, no exception
# ---------------------------------------------------------------------------
def test_observation_enriched_spells_unknown_graceful() -> None:
"""Given: a combatant with a spell absent from the (possibly empty) registry
When: an Observation is built
Then: the spell dict is the empty template and no exception is raised."""
hero = make_combatant("hero", spells=["Mystery Spell"])
foe = make_combatant("foe")
hero_state = make_state(hero, "players", (0, 0))
foe_state = make_state(foe, "monsters", (0, 3))
engine = make_engine([hero_state, foe_state])
obs = _snapshot(engine, hero_state)
spells = obs["combatants"][0]["spells"]
assert spells == (
{
"name": "Mystery Spell",
"level": None,
"school": "",
"range_ft": 0,
"save": "none",
"dc": None,
"summary": "",
},
)
# ---------------------------------------------------------------------------
# 9. E3: spells/features JSON-serializable and frozen
# ---------------------------------------------------------------------------
def test_observation_enriched_spells_features_json_serializable() -> None:
"""Given: an Observation with features and spells
When: to_dict() is serialized
Then: json.dumps succeeds and the new fields are frozen."""
fireball = load_spell(Path("data/spells/fireball.json"))
hero = make_combatant(
"hero",
spells=["Fireball", "Mystery Spell"],
features=[AbilitySpec(name="Channel Energy", category="class")],
)
foe = make_combatant("foe")
hero_state = make_state(hero, "players", (0, 0))
foe_state = make_state(foe, "monsters", (0, 3))
engine = make_engine([hero_state, foe_state], spell_registry={"Fireball": fireball})
obs = engine._build_observation(hero_state)
data = obs.to_dict()
json.dumps(data)
hero_state.hp = 5
data_after = obs.to_dict()
assert data_after == data
c = data_after["combatants"][0]
assert c["features"] == ("Channel Energy",)
assert c["spells"][0]["name"] == "Fireball"
assert c["spells"][1]["name"] == "Mystery Spell"