180 lines
6.1 KiB
Python
180 lines
6.1 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, "centered_on_caster": False}
|
|
|
|
|
|
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, "centered_on_caster": False}
|
|
|
|
|
|
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", "cast_time" and
|
|
"harmless"."""
|
|
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 s["harmless"] is False
|
|
assert s["cast_time"] == "standard"
|
|
assert set(s.keys()) == {
|
|
"name",
|
|
"level",
|
|
"school",
|
|
"range_ft",
|
|
"save",
|
|
"dc",
|
|
"summary",
|
|
"half_on_save",
|
|
"save_negates",
|
|
"harmless",
|
|
"area",
|
|
"cast_time",
|
|
}
|
|
|
|
|
|
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, "centered_on_caster": False}
|
|
assert spells[1]["area"] is None
|