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