"""Tests for E1-E3: enriched per-combatant data in the Observation snapshot. 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 ``Combatant``). - ``cmb``/``cmd`` coherence with ``_maneuver_cmb``/``_maneuver_cmd`` on the 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 from pf1e_simulator.map import MapSpec, TerrainType from pf1e_simulator.models import ( AbilityScores, ACProfile, AttackSpec, Combatant, DamageComponent, Saves, ) from pf1e_simulator.rng import ScriptedRng from pf1e_simulator.spells import SpellSpec, load_spell def make_combatant( cid: str, *, size: str = "Medium", hp: int = 20, ac: int = 15, 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( id=f"{cid}-w", name="short sword", kind="melee", attack_bonus=attack_bonus, damage=[DamageComponent(formula=parse_dice("1d6"), types=["slashing"])], ) return Combatant( id=cid, name=cid, level=1, size=size, abilities=AbilityScores( str_score=10, dex_score=10, con_score=12, int_score=10, wis_score=10, cha_score=10, ), hp_max=hp, ac=ACProfile(total=ac, touch=ac, flat_footed=ac), bab=attack_bonus, initiative_mod=0, speed_land_ft=speed, speed_fly_ft=speed_fly, saves=Saves(fort=0, ref=0, will=0), attacks=[attack], features=features or [], spells=spells or [], ) def make_state(combatant: Combatant, side: str, pos: tuple[int, int]) -> CombatantState: return CombatantState(combatant=combatant, side=side, pos=pos, hp=combatant.hp_max) def make_grid() -> Grid: legend = {".": TerrainType(type="floor", move_cost=1)} spec = MapSpec( name="test", terrain=tuple(["." * 8] * 8), legend=legend, ) return Grid.from_spec(spec) 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: return engine._build_observation(state).to_dict() # --------------------------------------------------------------------------- # 1. Enriched schema 1v1 — all 9 new fields present and correct # --------------------------------------------------------------------------- def test_observation_enriched_combat_stats_schema() -> None: """Given: a simple 1v1 with default state When: an Observation is built for the acting combatant Then: every enriched combat-stat field is present and correct.""" hero = make_combatant("hero") 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) assert len(obs["combatants"]) == 2 c = obs["combatants"][0] assert c["name"] == "hero" assert c["level"] == 1 assert c["size"] == "Medium" assert c["speed_land_ft"] == 30 assert c["speed_fly_ft"] is None assert c["ac"] == {"total": 15, "touch": 15, "flat_footed": 15} assert c["saves"] == {"fort": 0, "ref": 0, "will": 0} # Default combatant: str 10 (mod 0), dex 10 (mod 0), bab 5, Medium (0) assert c["cmb"] == 5 assert c["cmd"] == 15 # --------------------------------------------------------------------------- # 2. cmb/cmd coherent with _maneuver_cmb / _maneuver_cmd on the same state # --------------------------------------------------------------------------- def test_observation_enriched_cmb_cmd_match_maneuver_helpers() -> None: """Given: a 1v1 with default state When: the snapshot is built Then: cmb/cmd equal _maneuver_cmb/_maneuver_cmd on the same state.""" hero = make_combatant("hero") 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) for index, state in enumerate((hero_state, foe_state)): c = obs["combatants"][index] assert c["cmb"] == engine._maneuver_cmb(state) assert c["cmd"] == engine._maneuver_cmd(state) # --------------------------------------------------------------------------- # 3. speed_fly_ft optional (int | None) # --------------------------------------------------------------------------- def test_observation_enriched_speed_fly_ft_optional() -> None: """Given: a combatant with a fly speed and one without When: an Observation is built Then: speed_fly_ft is exposed when set and None when absent.""" flyer = make_combatant("flyer", speed_fly=60) walker = make_combatant("walker") flyer_state = make_state(flyer, "players", (0, 0)) walker_state = make_state(walker, "monsters", (0, 3)) engine = make_engine([flyer_state, walker_state]) obs = _snapshot(engine, flyer_state) assert obs["combatants"][0]["speed_fly_ft"] == 60 assert obs["combatants"][1]["speed_fly_ft"] is None # --------------------------------------------------------------------------- # 4. Enriched dict is JSON-serializable and frozen (L1 conventions 9/10) # --------------------------------------------------------------------------- def test_observation_enriched_json_serializable_and_frozen() -> None: """Given: an Observation already built When: to_dict() is called and the live state is then mutated Then: json.dumps succeeds on the enriched dict and the already-built snapshot does not change.""" hero = make_combatant("hero") 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 = engine._build_observation(hero_state) data_before = obs.to_dict() json.dumps(data_before) # must not raise # Mutate live state after construction hero_state.hp = 5 hero_state.pos = (5, 5) hero_state.weapon_broken.add("hero-w") data_after = obs.to_dict() assert data_after == data_before c = data_after["combatants"][0] assert c["name"] == "hero" assert c["ac"] == {"total": 15, "touch": 15, "flat_footed": 15} assert c["saves"] == {"fort": 0, "ref": 0, "will": 0} assert c["cmb"] == 5 assert c["cmd"] == 15 # --------------------------------------------------------------------------- # 5. E2: attacks reference — one dict per AttackSpec, broken/stolen included # --------------------------------------------------------------------------- def make_two_weapon_combatant(cid: str) -> Combatant: """Build a combatant with one melee and one ranged weapon.""" melee = AttackSpec( id=f"{cid}-sword", name="short sword", kind="melee", attack_bonus=5, damage=[DamageComponent(formula=parse_dice("1d6"), types=["slashing"])], damage_bonus=2, crit_range=19, crit_mult=2, reach_ft=5, count=1, firearm=False, ) ranged = AttackSpec( id=f"{cid}-bow", name="short bow", kind="ranged", attack_bonus=4, damage=[DamageComponent(formula=parse_dice("1d6"), types=["piercing"])], crit_mult=3, range_increment_ft=60, ) return Combatant( id=cid, name=cid, level=1, size="Medium", abilities=AbilityScores( str_score=10, dex_score=10, con_score=12, int_score=10, wis_score=10, cha_score=10, ), hp_max=20, ac=ACProfile(total=15, touch=15, flat_footed=15), bab=5, initiative_mod=0, speed_land_ft=30, saves=Saves(fort=0, ref=0, will=0), attacks=[melee, ranged], ) def test_observation_enriched_attacks_schema() -> None: """Given: a combatant with a melee and a ranged weapon When: an Observation is built Then: the attacks dict lists both weapons with full serialized fields.""" hero = make_two_weapon_combatant("hero") 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) attacks = obs["combatants"][0]["attacks"] assert len(attacks) == 2 sword = attacks[0] assert sword["id"] == "hero-sword" assert sword["name"] == "short sword" assert sword["kind"] == "melee" assert sword["attack_bonus"] == 5 assert sword["damage"] == ({"formula": "1d6", "types": ("slashing",)},) assert sword["damage_bonus"] == 2 assert sword["crit_range"] == 19 assert sword["crit_mult"] == 2 assert sword["reach_ft"] == 5 assert sword["range_increment_ft"] is None assert sword["count"] == 1 assert sword["firearm"] is False bow = attacks[1] assert bow["id"] == "hero-bow" assert bow["name"] == "short bow" assert bow["kind"] == "ranged" assert bow["attack_bonus"] == 4 assert bow["damage"] == ({"formula": "1d6", "types": ("piercing",)},) assert bow["damage_bonus"] == 0 assert bow["crit_range"] == 20 assert bow["crit_mult"] == 3 assert bow["reach_ft"] == 5 assert bow["range_increment_ft"] == 60 assert bow["count"] == 1 assert bow["firearm"] is False def test_observation_enriched_attacks_include_broken_and_stolen() -> None: """Given: a combatant whose melee weapon is broken and ranged weapon stolen When: an Observation is built Then: both weapons are still listed with their ids.""" hero = make_two_weapon_combatant("hero") foe = make_combatant("foe") hero_state = make_state(hero, "players", (0, 0)) foe_state = make_state(foe, "monsters", (0, 3)) hero_state.weapon_broken.add("hero-sword") hero_state.weapon_stolen.add("hero-bow") engine = make_engine([hero_state, foe_state]) obs = _snapshot(engine, hero_state) attacks = obs["combatants"][0]["attacks"] assert len(attacks) == 2 assert {a["id"] for a in attacks} == {"hero-sword", "hero-bow"} assert obs["combatants"][0]["weapon_broken"] == ("hero-sword",) assert obs["combatants"][0]["weapon_stolen"] == ("hero-bow",) def test_observation_enriched_attacks_json_serializable() -> None: """Given: an Observation with the attacks reference When: to_dict() is serialized Then: json.dumps succeeds and the attacks dict is frozen.""" hero = make_two_weapon_combatant("hero") 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 = engine._build_observation(hero_state) data = obs.to_dict() json.dumps(data) # must not raise hero_state.weapon_broken.add("hero-sword") data_after = obs.to_dict() assert data_after == data assert data_after["combatants"][0]["attacks"][0]["id"] == "hero-sword" def test_observation_enriched_attacks_formula_uses_dice_to_str() -> None: """Given: a weapon with a bonus and a negative-bonus damage formula When: an Observation is built Then: the formula strings match dice_to_str canonical output.""" hero = make_two_weapon_combatant("hero") hero = hero.model_copy( update={ "attacks": [ hero.attacks[0].model_copy( update={ "damage": [ DamageComponent( formula=parse_dice("2d6+3"), types=["slashing"] ) ] } ), hero.attacks[1].model_copy( update={ "damage": [ DamageComponent( formula=parse_dice("2d6-1"), types=["piercing"] ) ] } ), ] } ) 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) 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"