feat(observation): expose structured spell areas

This commit is contained in:
2026-09-10 14:14:54 +02:00
parent 61c9b7fa47
commit 7fafbe43cf
4 changed files with 193 additions and 1 deletions
+3 -1
View File
@@ -640,7 +640,9 @@ Règles modélisées :
feature_details (C4 : tuple de {name, category, source_detail, effects: feature_details (C4 : tuple de {name, category, source_detail, effects:
tuple de {target, value, bonus_type, condition, weapon_filter}}), tuple de {target, value, bonus_type, condition, weapon_filter}}),
spells (résumés enrichis du registre : name, level, school, range_ft, 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 spell_resistance (int), resistances {type: montant}, immunities
(tuple triée des types immunisés), sneak_dice (dés de précision disponibles : (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 — (niveau + 1) // 2 avec « Sneak Attack », sinon 0), engaged_in_melee (bool —
+15
View File
@@ -268,6 +268,19 @@ def _spell_half_on_save(spell: SpellSpec) -> bool:
return False 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, ...] = ( _STEP_DELTAS: tuple[Pos, ...] = (
(-1, -1), (-1, -1),
(-1, 0), (-1, 0),
@@ -3710,6 +3723,7 @@ class CombatEngine:
"summary": "", "summary": "",
"half_on_save": False, "half_on_save": False,
"save_negates": False, "save_negates": False,
"area": None,
} }
half_on_save = _spell_half_on_save(spell) half_on_save = _spell_half_on_save(spell)
return { return {
@@ -3722,6 +3736,7 @@ class CombatEngine:
"summary": _spell_summary(spell), "summary": _spell_summary(spell),
"half_on_save": half_on_save, "half_on_save": half_on_save,
"save_negates": spell.save != "none" and not half_on_save, "save_negates": spell.save != "none" and not half_on_save,
"area": _spell_area_dict(spell),
} }
def _cast_spell( def _cast_spell(
+1
View File
@@ -529,6 +529,7 @@ def test_observation_enriched_spells_unknown_graceful() -> None:
"summary": "", "summary": "",
"half_on_save": False, "half_on_save": False,
"save_negates": False, "save_negates": False,
"area": None,
}, },
) )
+174
View File
@@ -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