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
+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"