Files
pf1e-simulator/tests/test_spell_area_snapshot.py
T

175 lines
5.8 KiB
Python

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