feat(combat): expose class features and spell data in observation snapshot
This commit is contained in:
@@ -183,6 +183,29 @@ def _save_label(result: SaveResult | None) -> str:
|
|||||||
return "SAVED" if result.success else "FAILED"
|
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, ...] = (
|
_STEP_DELTAS: tuple[Pos, ...] = (
|
||||||
(-1, -1),
|
(-1, -1),
|
||||||
(-1, 0),
|
(-1, 0),
|
||||||
@@ -637,6 +660,10 @@ class CombatEngine:
|
|||||||
}
|
}
|
||||||
for attack in state.combatant.attacks
|
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(
|
def _pair_snapshot(
|
||||||
@@ -2561,6 +2588,36 @@ class CombatEngine:
|
|||||||
return
|
return
|
||||||
self._charge(state, target, result.weapon)
|
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(
|
def _cast_spell(
|
||||||
self, caster: CombatantState, target: CombatantState | None, spell_name: str
|
self, caster: CombatantState, target: CombatantState | None, spell_name: str
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -2593,11 +2650,7 @@ class CombatEngine:
|
|||||||
if self._check_casting_aoo(caster, spell.level):
|
if self._check_casting_aoo(caster, spell.level):
|
||||||
return
|
return
|
||||||
caster.spend_spell_slot(spell.level)
|
caster.spend_spell_slot(spell.level)
|
||||||
# DC = 10 + spell level + caster's casting ability modifier
|
dc = self._spell_dc(caster, spell)
|
||||||
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
|
|
||||||
save_result = (
|
save_result = (
|
||||||
self.resolve_save(
|
self.resolve_save(
|
||||||
target, spell.save, dc, save_descriptors=spell_save_descriptors(spell)
|
target, spell.save, dc, save_descriptors=spell_save_descriptors(spell)
|
||||||
|
|||||||
@@ -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
|
Adds the static combat-stat fields (E1), the attacks reference (E2) and the
|
||||||
snapshot (name, level, size, speeds, ac, saves, cmb, cmd) — a strict
|
class features + spell data (E3) to each ``combatants`` dict of the snapshot
|
||||||
superset of the L1 schema. No existing key is modified or renamed.
|
— a strict superset of the L1 schema. No existing key is modified or renamed.
|
||||||
|
|
||||||
Covers:
|
Covers:
|
||||||
- The enriched schema 1v1 (exact values derived from the constructed
|
- The enriched schema 1v1 (exact values derived from the constructed
|
||||||
@@ -11,12 +11,16 @@ Covers:
|
|||||||
same state.
|
same state.
|
||||||
- The optional ``speed_fly_ft`` field (``int | None``).
|
- The optional ``speed_fly_ft`` field (``int | None``).
|
||||||
- JSON serializability and frozen copies (same conventions as L1 tests 9/10).
|
- 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
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from pf1e_simulator.abilities import AbilitySpec
|
||||||
from pf1e_simulator.combat import CombatantState, CombatEngine
|
from pf1e_simulator.combat import CombatantState, CombatEngine
|
||||||
from pf1e_simulator.dice import dice_to_str, parse_dice
|
from pf1e_simulator.dice import dice_to_str, parse_dice
|
||||||
from pf1e_simulator.grid import Grid
|
from pf1e_simulator.grid import Grid
|
||||||
@@ -30,6 +34,7 @@ from pf1e_simulator.models import (
|
|||||||
Saves,
|
Saves,
|
||||||
)
|
)
|
||||||
from pf1e_simulator.rng import ScriptedRng
|
from pf1e_simulator.rng import ScriptedRng
|
||||||
|
from pf1e_simulator.spells import SpellSpec, load_spell
|
||||||
|
|
||||||
|
|
||||||
def make_combatant(
|
def make_combatant(
|
||||||
@@ -41,6 +46,8 @@ def make_combatant(
|
|||||||
attack_bonus: int = 5,
|
attack_bonus: int = 5,
|
||||||
speed: int = 30,
|
speed: int = 30,
|
||||||
speed_fly: int | None = None,
|
speed_fly: int | None = None,
|
||||||
|
features: list[AbilitySpec] | None = None,
|
||||||
|
spells: list[str] | None = None,
|
||||||
) -> Combatant:
|
) -> Combatant:
|
||||||
"""Build a single-weapon melee combatant with known static stats."""
|
"""Build a single-weapon melee combatant with known static stats."""
|
||||||
attack = AttackSpec(
|
attack = AttackSpec(
|
||||||
@@ -71,6 +78,8 @@ def make_combatant(
|
|||||||
speed_fly_ft=speed_fly,
|
speed_fly_ft=speed_fly,
|
||||||
saves=Saves(fort=0, ref=0, will=0),
|
saves=Saves(fort=0, ref=0, will=0),
|
||||||
attacks=[attack],
|
attacks=[attack],
|
||||||
|
features=features or [],
|
||||||
|
spells=spells or [],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -88,8 +97,13 @@ def make_grid() -> Grid:
|
|||||||
return Grid.from_spec(spec)
|
return Grid.from_spec(spec)
|
||||||
|
|
||||||
|
|
||||||
def make_engine(states: list[CombatantState]) -> CombatEngine:
|
def make_engine(
|
||||||
return CombatEngine(ScriptedRng([]), make_grid(), states)
|
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:
|
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"]
|
attacks = obs["combatants"][0]["attacks"]
|
||||||
assert attacks[0]["damage"][0]["formula"] == dice_to_str(parse_dice("2d6+3"))
|
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"))
|
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"
|
||||||
|
|||||||
Reference in New Issue
Block a user