From 7fafbe43cf93055e3267e835fcd588af965ede4b Mon Sep 17 00:00:00 2001 From: ctan Date: Thu, 10 Sep 2026 14:14:54 +0200 Subject: [PATCH] feat(observation): expose structured spell areas --- README.md | 4 +- src/pf1e_simulator/combat.py | 15 +++ tests/test_observation_enriched.py | 1 + tests/test_spell_area_snapshot.py | 174 +++++++++++++++++++++++++++++ 4 files changed, 193 insertions(+), 1 deletion(-) create mode 100644 tests/test_spell_area_snapshot.py diff --git a/README.md b/README.md index ca2c557..0317755 100644 --- a/README.md +++ b/README.md @@ -640,7 +640,9 @@ Règles modélisées : feature_details (C4 : tuple de {name, category, source_detail, effects: tuple de {target, value, bonus_type, condition, weapon_filter}}), spells (résumés enrichis du registre : name, level, school, range_ft, - save, dc, summary, half_on_save, save_negates (E4)), + save, dc, summary, half_on_save, save_negates (E4), area {shape, + radius_ft} | None (U7a : premier AreaEffect de niveau supérieur du sort, + gabarit de zone structuré, None si le sort n'a pas de zone)), spell_resistance (int), resistances {type: montant}, immunities (tuple triée des types immunisés), sneak_dice (dés de précision disponibles : (niveau + 1) // 2 avec « Sneak Attack », sinon 0), engaged_in_melee (bool — diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index 25cc0bf..e34feaf 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -268,6 +268,19 @@ def _spell_half_on_save(spell: SpellSpec) -> bool: return False +def _spell_area_dict(spell: SpellSpec) -> dict[str, object] | None: + """Structured area template of a spell, or None if it has no top-level AreaEffect. + + Returns ``{"shape": str, "radius_ft": int}`` for the FIRST AreaEffect found + in ``spell.effects`` (a spell may declare several effects; only the first + area is exposed, and effects nested inside an AreaEffect are not scanned). + """ + for effect in spell.effects: + if isinstance(effect, AreaEffect): + return {"shape": effect.shape, "radius_ft": effect.radius_ft} + return None + + _STEP_DELTAS: tuple[Pos, ...] = ( (-1, -1), (-1, 0), @@ -3710,6 +3723,7 @@ class CombatEngine: "summary": "", "half_on_save": False, "save_negates": False, + "area": None, } half_on_save = _spell_half_on_save(spell) return { @@ -3722,6 +3736,7 @@ class CombatEngine: "summary": _spell_summary(spell), "half_on_save": half_on_save, "save_negates": spell.save != "none" and not half_on_save, + "area": _spell_area_dict(spell), } def _cast_spell( diff --git a/tests/test_observation_enriched.py b/tests/test_observation_enriched.py index 9a26e32..5a81a50 100644 --- a/tests/test_observation_enriched.py +++ b/tests/test_observation_enriched.py @@ -529,6 +529,7 @@ def test_observation_enriched_spells_unknown_graceful() -> None: "summary": "", "half_on_save": False, "save_negates": False, + "area": None, }, ) diff --git a/tests/test_spell_area_snapshot.py b/tests/test_spell_area_snapshot.py new file mode 100644 index 0000000..174eeed --- /dev/null +++ b/tests/test_spell_area_snapshot.py @@ -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