diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index 4e0ffbe..b3c3056 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -596,6 +596,23 @@ class CombatEngine: "grappled_with": state.grappled_with, "moved_this_turn": state.moved_this_turn, "power_attack": state.power_attack, + "name": state.combatant.name, + "level": state.combatant.level, + "size": state.combatant.size, + "speed_land_ft": state.combatant.speed_land_ft, + "speed_fly_ft": state.combatant.speed_fly_ft, + "ac": { + "total": state.combatant.ac.total, + "touch": state.combatant.ac.touch, + "flat_footed": state.combatant.ac.flat_footed, + }, + "saves": { + "fort": state.combatant.saves.fort, + "ref": state.combatant.saves.ref, + "will": state.combatant.saves.will, + }, + "cmb": self._maneuver_cmb(state), + "cmd": self._maneuver_cmd(state), } def _pair_snapshot( diff --git a/tests/test_observation_enriched.py b/tests/test_observation_enriched.py new file mode 100644 index 0000000..57d5d5f --- /dev/null +++ b/tests/test_observation_enriched.py @@ -0,0 +1,204 @@ +"""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 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