1f8f068dd1
- SpellSpec.components (frozenset of V/S/M/F/DF, JSON key accepting the RAW string notation "V, S, M/DF" or a token list; slash = alternative; unknown token is a data error). Default empty = not encoded yet -> no failure. - Combatant.armor_spell_failure (0-100 percent, authored on the JSON - the model has no equipment) and arcane_caster (divine casters never incur ASF); both load directly through load_combatant. - _cast_spell: after the start-of-casting gates and before delivery, two conditional d100 rolls (convention mirrors the concealment roll, <=): deafened + V component spoils the spell on <=20 (SRD cast-a-spell.md); arcane_caster + S component + armor_spell_failure > 0 fails the spell on <= the ASF percent (SRD arcane-spells-and-armor.md). The slot stays spent (mirrors the concentration fizzle). No condition -> no roll: the RNG stream is bit-identical for every current combat (registry encodes no components yet - full encoding is a follow-up task). - Observation: spell summary gains "components" (sorted tuple), combatant snapshot gains "armor_spell_failure"/"arcane_caster"; README documents the keys and the two former out-of-scope bullets now describe the modeled rule with its data-encoding follow-up. - Data smoke test: lich arcane_caster true / ASF 0, oni arcane_caster false. - tests/test_spell_failure.py: 21 tests (parsing, thresholds <=20 / <=ASF, divine/no-ASF/no-component never roll, roll order, slot stays spent, observation keys). Golden transcripts unchanged.
182 lines
6.1 KiB
Python
182 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 s["components"] == ()
|
|
assert set(s.keys()) == {
|
|
"name",
|
|
"level",
|
|
"school",
|
|
"range_ft",
|
|
"save",
|
|
"dc",
|
|
"summary",
|
|
"half_on_save",
|
|
"save_negates",
|
|
"harmless",
|
|
"area",
|
|
"cast_time",
|
|
"components",
|
|
}
|
|
|
|
|
|
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
|