"""Tests for E1: enriched per-combatant combat stats 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. 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). """ from __future__ import annotations import json 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 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, ) -> 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], ) 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]) -> CombatEngine: return CombatEngine(ScriptedRng([]), make_grid(), states) 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"))