feat(combat): expose class features and spell data in observation snapshot
This commit is contained in:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user