feat(observation): expose structured spell areas
This commit is contained in:
@@ -529,6 +529,7 @@ def test_observation_enriched_spells_unknown_graceful() -> None:
|
||||
"summary": "",
|
||||
"half_on_save": False,
|
||||
"save_negates": False,
|
||||
"area": None,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
"""Structured spell area data in the Observation (Package U7a).
|
||||
|
||||
``_spell_snapshot`` now exposes an additive ``area`` key: the first top-level
|
||||
``AreaEffect`` of a spell as ``{"shape": str, "radius_ft": int}``, or ``None``
|
||||
for spells without one. Factories are reused from ``test_observation_enriched``
|
||||
(``make_combatant``/``make_state``/``make_engine``/``_snapshot``). The engine
|
||||
runs on ``ScriptedRng([])``: building a snapshot consumes no dice rolls (any
|
||||
consumption — e.g. initiative via ``run`` — would raise ``RngExhaustedError``),
|
||||
so these tests call ``_build_observation`` directly and never roll initiative.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from pf1e_simulator.dice import parse_dice
|
||||
from pf1e_simulator.spells import AreaEffect, DamageEffect, SpellSpec
|
||||
from tests.test_observation_enriched import _snapshot, make_combatant, make_engine, make_state
|
||||
|
||||
_FIREBALL = SpellSpec(
|
||||
name="Fireball",
|
||||
level=3,
|
||||
school="evocation",
|
||||
range="long",
|
||||
save="ref",
|
||||
base_dc=10,
|
||||
effects=(
|
||||
AreaEffect(
|
||||
radius_ft=20,
|
||||
effects=(
|
||||
DamageEffect(
|
||||
formula=parse_dice("8d6"),
|
||||
types=("fire",),
|
||||
half_on_save=True,
|
||||
),
|
||||
),
|
||||
save_for_half=True,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
_MAGIC_MISSILE = SpellSpec(
|
||||
name="Magic Missile",
|
||||
level=1,
|
||||
school="evocation",
|
||||
range="medium",
|
||||
save="none",
|
||||
base_dc=10,
|
||||
effects=(
|
||||
DamageEffect(
|
||||
formula=parse_dice("1d4+1"),
|
||||
types=("force",),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
_TWO_AREAS = SpellSpec(
|
||||
name="Twin Burst",
|
||||
level=4,
|
||||
school="evocation",
|
||||
range="long",
|
||||
save="ref",
|
||||
base_dc=10,
|
||||
effects=(
|
||||
AreaEffect(radius_ft=20),
|
||||
AreaEffect(radius_ft=40),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _spell_dict_for(spell: SpellSpec, spell_name: str) -> dict:
|
||||
"""Given: a caster knowing one spell registered under ``spell_name``
|
||||
When: an Observation is built for the caster
|
||||
Then: return that spell's snapshot dict."""
|
||||
hero = make_combatant("hero", spells=[spell_name])
|
||||
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={spell_name: spell})
|
||||
obs = _snapshot(engine, hero_state)
|
||||
spells = obs["combatants"][0]["spells"]
|
||||
assert len(spells) == 1
|
||||
return spells[0]
|
||||
|
||||
|
||||
def test_area_snapshot_burst_exposes_shape_and_radius() -> None:
|
||||
"""Given: a Fireball-like spell with a top-level AreaEffect(radius 20)
|
||||
When: its snapshot dict is built
|
||||
Then: area equals {"shape": "burst", "radius_ft": 20}."""
|
||||
s = _spell_dict_for(_FIREBALL, "Fireball")
|
||||
assert s["area"] == {"shape": "burst", "radius_ft": 20}
|
||||
|
||||
|
||||
def test_area_snapshot_single_target_is_none() -> None:
|
||||
"""Given: Magic Missile (no AreaEffect anywhere)
|
||||
When: its snapshot dict is built
|
||||
Then: area is None."""
|
||||
s = _spell_dict_for(_MAGIC_MISSILE, "Magic Missile")
|
||||
assert s["area"] is None
|
||||
|
||||
|
||||
def test_area_snapshot_first_area_wins() -> None:
|
||||
"""Given: a spell declaring two top-level AreaEffects (20 ft then 40 ft)
|
||||
When: its snapshot dict is built
|
||||
Then: only the FIRST AreaEffect is exposed."""
|
||||
s = _spell_dict_for(_TWO_AREAS, "Twin Burst")
|
||||
assert s["area"] == {"shape": "burst", "radius_ft": 20}
|
||||
|
||||
|
||||
def test_area_snapshot_unknown_spell_is_none() -> None:
|
||||
"""Given: a spell name absent from the registry (graceful template branch)
|
||||
When: its snapshot dict is built
|
||||
Then: area is None alongside the other neutral defaults."""
|
||||
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], spell_registry={"Magic Missile": _MAGIC_MISSILE})
|
||||
obs = _snapshot(engine, hero_state)
|
||||
s = obs["combatants"][0]["spells"][0]
|
||||
assert s["area"] is None
|
||||
assert s["name"] == "Mystery Spell"
|
||||
assert s["level"] is None
|
||||
|
||||
|
||||
def test_area_snapshot_existing_keys_unchanged() -> None:
|
||||
"""Given: a known Fireball snapshot dict
|
||||
When: all keys and values are checked
|
||||
Then: the nine pre-U7a keys are present with unchanged values, and the
|
||||
exact key set is the old nine plus the additive "area"."""
|
||||
s = _spell_dict_for(_FIREBALL, "Fireball")
|
||||
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 + 3 + wis mod 0 = 13
|
||||
assert s["dc"] == 13
|
||||
assert s["summary"] == "area 20ft"
|
||||
assert s["half_on_save"] is True
|
||||
assert s["save_negates"] is False
|
||||
assert set(s.keys()) == {
|
||||
"name",
|
||||
"level",
|
||||
"school",
|
||||
"range_ft",
|
||||
"save",
|
||||
"dc",
|
||||
"summary",
|
||||
"half_on_save",
|
||||
"save_negates",
|
||||
"area",
|
||||
}
|
||||
|
||||
|
||||
def test_area_snapshot_is_json_serializable() -> None:
|
||||
"""Given: an Observation with area-bearing and area-less spells
|
||||
When: to_dict() is serialized through json.dumps/loads
|
||||
Then: the round-trip succeeds and the area dict survives intact."""
|
||||
hero = make_combatant("hero", spells=["Fireball", "Magic Missile"])
|
||||
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, "Magic Missile": _MAGIC_MISSILE},
|
||||
)
|
||||
data = engine._build_observation(hero_state).to_dict()
|
||||
json.dumps(data)
|
||||
restored = json.loads(json.dumps(data))
|
||||
spells = restored["combatants"][0]["spells"]
|
||||
assert spells[0]["area"] == {"shape": "burst", "radius_ft": 20}
|
||||
assert spells[1]["area"] is None
|
||||
Reference in New Issue
Block a user