diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index ff8e337..fb4a6b6 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -183,6 +183,29 @@ def _save_label(result: SaveResult | None) -> str: return "SAVED" if result.success else "FAILED" +def _spell_effect_summary(effect: SpellEffect) -> str: + """Short serializable summary of one spell effect (never the description).""" + if isinstance(effect, DamageEffect): + parts = [dice_to_str(effect.formula), *effect.types] + if effect.half_on_save: + parts.append("half on save") + return " ".join(parts) + if isinstance(effect, ConditionEffect): + return f"{effect.condition_name} {effect.duration_rounds}r" + if isinstance(effect, HealEffect): + return f"heal {dice_to_str(effect.formula)}" + if isinstance(effect, BuffEffect): + return f"buff {effect.duration_rounds}r" + if isinstance(effect, AreaEffect): + return f"area {effect.radius_ft}ft" + return f"summon {effect.creature}" + + +def _spell_summary(spell: SpellSpec) -> str: + """Short serializable summary of a spell's effects, joined with ', '.""" + return ", ".join(_spell_effect_summary(e) for e in spell.effects) + + _STEP_DELTAS: tuple[Pos, ...] = ( (-1, -1), (-1, 0), @@ -637,6 +660,10 @@ class CombatEngine: } for attack in state.combatant.attacks ), + "features": tuple(sorted(f.name for f in state.combatant.features)), + "spells": tuple( + self._spell_snapshot(state, name) for name in state.combatant.spells + ), } def _pair_snapshot( @@ -2561,6 +2588,36 @@ class CombatEngine: return self._charge(state, target, result.weapon) + def _spell_dc(self, caster: CombatantState, spell: SpellSpec) -> int: + """Spell save DC: 10 + spell level + casting ability modifier.""" + ability_name = caster.combatant.casting_ability.lower() + ability_score = getattr(caster.combatant.abilities, f"{ability_name}_score") + ability_mod = (ability_score - 10) // 2 + return 10 + spell.level + ability_mod + + def _spell_snapshot(self, state: CombatantState, spell_name: str) -> dict[str, object]: + """One per-spell dict for the Observation, enriched from the registry.""" + spell = self._spell_registry.get(spell_name) + if spell is None: + return { + "name": spell_name, + "level": None, + "school": "", + "range_ft": 0, + "save": "none", + "dc": None, + "summary": "", + } + return { + "name": spell.name, + "level": spell.level, + "school": spell.school, + "range_ft": spell_range_ft(spell, state.combatant.level), + "save": spell.save, + "dc": self._spell_dc(state, spell), + "summary": _spell_summary(spell), + } + def _cast_spell( self, caster: CombatantState, target: CombatantState | None, spell_name: str ) -> None: @@ -2593,11 +2650,7 @@ class CombatEngine: if self._check_casting_aoo(caster, spell.level): return caster.spend_spell_slot(spell.level) - # DC = 10 + spell level + caster's casting ability modifier - ability_name = caster.combatant.casting_ability.lower() - ability_score = getattr(caster.combatant.abilities, f"{ability_name}_score") - ability_mod = (ability_score - 10) // 2 - dc = 10 + spell.level + ability_mod + dc = self._spell_dc(caster, spell) save_result = ( self.resolve_save( target, spell.save, dc, save_descriptors=spell_save_descriptors(spell) diff --git a/tests/test_observation_enriched.py b/tests/test_observation_enriched.py index 4c03892..9f159ce 100644 --- a/tests/test_observation_enriched.py +++ b/tests/test_observation_enriched.py @@ -1,8 +1,8 @@ -"""Tests for E1: enriched per-combatant combat stats in the Observation snapshot. +"""Tests for E1-E3: enriched per-combatant data in the Observation snapshot. -Adds the static combat-stat fields to each ``combatants`` dict of the -snapshot (name, level, size, speeds, ac, saves, cmb, cmd) — a strict -superset of the L1 schema. No existing key is modified or renamed. +Adds the static combat-stat fields (E1), the attacks reference (E2) and the +class features + spell data (E3) to each ``combatants`` dict of the snapshot +— a strict superset of the L1 schema. No existing key is modified or renamed. Covers: - The enriched schema 1v1 (exact values derived from the constructed @@ -11,12 +11,16 @@ Covers: same state. - The optional ``speed_fly_ft`` field (``int | None``). - JSON serializability and frozen copies (same conventions as L1 tests 9/10). +- ``features`` (sorted AbilitySpec names) and ``spells`` (registry-enriched + dicts, graceful for unknown spells). """ from __future__ import annotations import json +from pathlib import Path +from pf1e_simulator.abilities import AbilitySpec from pf1e_simulator.combat import CombatantState, CombatEngine from pf1e_simulator.dice import dice_to_str, parse_dice from pf1e_simulator.grid import Grid @@ -30,6 +34,7 @@ from pf1e_simulator.models import ( Saves, ) from pf1e_simulator.rng import ScriptedRng +from pf1e_simulator.spells import SpellSpec, load_spell def make_combatant( @@ -41,6 +46,8 @@ def make_combatant( attack_bonus: int = 5, speed: int = 30, speed_fly: int | None = None, + features: list[AbilitySpec] | None = None, + spells: list[str] | None = None, ) -> Combatant: """Build a single-weapon melee combatant with known static stats.""" attack = AttackSpec( @@ -71,6 +78,8 @@ def make_combatant( speed_fly_ft=speed_fly, saves=Saves(fort=0, ref=0, will=0), attacks=[attack], + features=features or [], + spells=spells or [], ) @@ -88,8 +97,13 @@ def make_grid() -> Grid: return Grid.from_spec(spec) -def make_engine(states: list[CombatantState]) -> CombatEngine: - return CombatEngine(ScriptedRng([]), make_grid(), states) +def make_engine( + states: list[CombatantState], + spell_registry: dict[str, SpellSpec] | None = None, +) -> CombatEngine: + return CombatEngine( + ScriptedRng([]), make_grid(), states, spell_registry=spell_registry + ) def _snapshot(engine: CombatEngine, state: CombatantState) -> dict: @@ -377,3 +391,151 @@ def test_observation_enriched_attacks_formula_uses_dice_to_str() -> None: attacks = obs["combatants"][0]["attacks"] assert attacks[0]["damage"][0]["formula"] == dice_to_str(parse_dice("2d6+3")) assert attacks[1]["damage"][0]["formula"] == dice_to_str(parse_dice("2d6-1")) + + +# --------------------------------------------------------------------------- +# 6. E3: features — sorted AbilitySpec names +# --------------------------------------------------------------------------- + + +def test_observation_enriched_features_sorted() -> None: + """Given: a combatant with several class features + When: an Observation is built + Then: features lists the AbilitySpec names, sorted for determinism.""" + hero = make_combatant( + "hero", + features=[ + AbilitySpec(name="Channel Energy", category="class"), + AbilitySpec(name="Power Attack", category="feat"), + AbilitySpec(name="Toughness", category="feat"), + ], + ) + 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]) + + obs = _snapshot(engine, hero_state) + features = obs["combatants"][0]["features"] + assert features == ("Channel Energy", "Power Attack", "Toughness") + assert obs["combatants"][1]["features"] == () + + +# --------------------------------------------------------------------------- +# 7. E3: spells — known spell enriched from the registry +# --------------------------------------------------------------------------- + + +def test_observation_enriched_spells_known() -> None: + """Given: a combatant with a known spell and a populated registry + When: an Observation is built + Then: the spell dict carries level/school/range_ft/save/dc/summary.""" + fireball = load_spell(Path("data/spells/fireball.json")) + hero = make_combatant("hero", spells=["Fireball"]) + 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}) + + obs = _snapshot(engine, hero_state) + spells = obs["combatants"][0]["spells"] + assert len(spells) == 1 + s = spells[0] + 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 + spell.level + casting ability mod (wis 10 -> 0) = 13 + assert s["dc"] == 13 + # AreaEffect -> "area {radius}ft" + assert s["summary"] == "area 20ft" + + +def test_observation_enriched_spells_dc_matches_engine() -> None: + """Given: a combatant with a known spell and a non-default casting ability + When: an Observation is built + Then: the snapshot dc equals the engine's _cast_spell dc on the same state.""" + fireball = load_spell(Path("data/spells/fireball.json")) + hero = make_combatant("hero", spells=["Fireball"]) + hero = hero.model_copy( + update={ + "abilities": hero.abilities.model_copy( + update={"cha_score": 16} + ), + "casting_ability": "cha", + } + ) + 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}) + + obs = _snapshot(engine, hero_state) + s = obs["combatants"][0]["spells"][0] + # DC = 10 + 3 + 3 = 16 + assert s["dc"] == 16 + + +# --------------------------------------------------------------------------- +# 8. E3: spells — unknown spell -> empty dict, no exception +# --------------------------------------------------------------------------- + + +def test_observation_enriched_spells_unknown_graceful() -> None: + """Given: a combatant with a spell absent from the (possibly empty) registry + When: an Observation is built + Then: the spell dict is the empty template and no exception is raised.""" + 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]) + + obs = _snapshot(engine, hero_state) + spells = obs["combatants"][0]["spells"] + assert spells == ( + { + "name": "Mystery Spell", + "level": None, + "school": "", + "range_ft": 0, + "save": "none", + "dc": None, + "summary": "", + }, + ) + + +# --------------------------------------------------------------------------- +# 9. E3: spells/features JSON-serializable and frozen +# --------------------------------------------------------------------------- + + +def test_observation_enriched_spells_features_json_serializable() -> None: + """Given: an Observation with features and spells + When: to_dict() is serialized + Then: json.dumps succeeds and the new fields are frozen.""" + fireball = load_spell(Path("data/spells/fireball.json")) + hero = make_combatant( + "hero", + spells=["Fireball", "Mystery Spell"], + features=[AbilitySpec(name="Channel Energy", category="class")], + ) + 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}) + + obs = engine._build_observation(hero_state) + data = obs.to_dict() + json.dumps(data) + + hero_state.hp = 5 + data_after = obs.to_dict() + assert data_after == data + c = data_after["combatants"][0] + assert c["features"] == ("Channel Energy",) + assert c["spells"][0]["name"] == "Fireball" + assert c["spells"][1]["name"] == "Mystery Spell"