"""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 Action, CombatantState, CombatEngine, Observation from pf1e_simulator.conditions import CONDITIONS_BY_NAME from pf1e_simulator.dice import dice_to_str, parse_dice from pf1e_simulator.effects import ActiveBuff, StatModifier from pf1e_simulator.grid import Grid from pf1e_simulator.map import MapSpec, TerrainType from pf1e_simulator.models import ( AbilityScores, ACProfile, AttackSpec, Combatant, DamageComponent, DamageReduction, Saves, SkillRank, ) from pf1e_simulator.rng import ScriptedRng from pf1e_simulator.spells import ( DamageEffect, 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, ranged: bool = False, spell_slots: dict[int, int] | None = None, ) -> Combatant: """Build a single-weapon combatant with known static stats. ``ranged=True`` builds a ranged-only combatant (no melee weapon), so it never threatens adjacent squares. """ if ranged: attack = AttackSpec( id=f"{cid}-w", name="short bow", kind="ranged", attack_bonus=attack_bonus, damage=[DamageComponent(formula=parse_dice("1d6"), types=["piercing"])], range_increment_ft=60, ) else: 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 [], spell_slots=spell_slots 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": "", "half_on_save": False, "save_negates": False, "area": None, "cast_time": "standard", }, ) # --------------------------------------------------------------------------- # 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" # --------------------------------------------------------------------------- # 10. E4: per-pair movement cost and threat info # --------------------------------------------------------------------------- def _pair_for(obs: dict, target_index: int) -> dict: """Return the pair dict whose target_index matches ``target_index``.""" for p in obs["pairs"]: if p["target_index"] == target_index: return p msg = "no pair for target_index" raise AssertionError(msg) def test_observation_enriched_pair_adjacent_melee() -> None: """Given: an acting melee combatant adjacent to a melee target When: an Observation is built Then: movement_cost_ft is 5 (one cell) and target_threatens_me is True.""" hero = make_combatant("hero") foe = make_combatant("foe") hero_state = make_state(hero, "players", (0, 0)) foe_state = make_state(foe, "monsters", (0, 1)) engine = make_engine([hero_state, foe_state]) obs = _snapshot(engine, hero_state) pair = _pair_for(obs, 1) assert pair["movement_cost_ft"] == 5 assert pair["target_threatens_me"] is True def test_observation_enriched_pair_distant_ranged_target() -> None: """Given: an acting melee combatant and a distant ranged-only target When: an Observation is built Then: movement_cost_ft is None (beyond speed) and target_threatens_me is False (the target has no melee weapon).""" hero = make_combatant("hero") archer = make_combatant("archer", ranged=True) hero_state = make_state(hero, "players", (0, 0)) archer_state = make_state(archer, "monsters", (0, 7)) engine = make_engine([hero_state, archer_state]) obs = _snapshot(engine, hero_state) pair = _pair_for(obs, 1) # 7 cells = 35 ft > 30 ft speed -> unreachable under speed assert pair["movement_cost_ft"] is None assert pair["target_threatens_me"] is False def test_observation_enriched_pair_two_adjacent_enemies() -> None: """Given: an acting combatant with two adjacent melee enemies When: an Observation is built Then: enemies_threatening_me counts both distinct enemies.""" hero = make_combatant("hero") foe1 = make_combatant("foe1") foe2 = make_combatant("foe2") hero_state = make_state(hero, "players", (0, 0)) foe1_state = make_state(foe1, "monsters", (0, 1)) foe2_state = make_state(foe2, "monsters", (1, 0)) engine = make_engine([hero_state, foe1_state, foe2_state]) obs = _snapshot(engine, hero_state) pair = _pair_for(obs, 1) assert pair["enemies_threatening_me"] == 2 def test_observation_enriched_pair_large_enemy_counts_once() -> None: """Given: an acting combatant and one Large (2x2) enemy whose threatened squares cover multiple occupied cells of the actor When: an Observation is built Then: enemies_threatening_me counts the single enemy once.""" hero = make_combatant("hero") large = make_combatant("large", size="Large") hero_state = make_state(hero, "players", (0, 0)) # Large enemy anchored at (0, 2): occupies (0,2),(0,3),(1,2),(1,3). # Its 5-ft reach disk covers (0,1) and (1,1) — two of the hero's cells. large_state = make_state(large, "monsters", (0, 2)) engine = make_engine([hero_state, large_state]) obs = _snapshot(engine, hero_state) pair = _pair_for(obs, 1) assert pair["enemies_threatening_me"] == 1 def test_observation_enriched_pair_existing_keys_preserved() -> None: """Given: an Observation with the enriched pair schema When: a pair dict is inspected Then: all 8 pre-existing pair keys remain present and unchanged.""" hero = make_combatant("hero") foe = make_combatant("foe") hero_state = make_state(hero, "players", (0, 0)) foe_state = make_state(foe, "monsters", (0, 1)) engine = make_engine([hero_state, foe_state]) obs = _snapshot(engine, hero_state) pair = _pair_for(obs, 1) for key in ( "target_index", "distance_ft", "weapon_for", "range_penalty", "can_charge", "can_step5_to_attack", "has_line_of_effect", "flanking", ): assert key in pair assert set(pair) == { "target_index", "distance_ft", "weapon_for", "weapon_reason", "range_penalty", "can_charge", "charge_reason", "can_step5_to_attack", "has_line_of_effect", "flanking", "movement_cost_ft", "target_threatens_me", "enemies_threatening_me", "cover_bonus", } # --------------------------------------------------------------------------- # 11. E5: top-level threat grid (union of enemy threatened squares) # --------------------------------------------------------------------------- def make_reach_combatant(cid: str, *, reach_ft: int = 10) -> Combatant: """Build a combatant whose melee weapon has the requested reach. ``reach_ft=10`` is a typical reach weapon (e.g. longspear): threatens the ring at 2 squares (5-10-5) and NOT the 1-square dead zone. ``reach_ft=5`` is a non-reach melee weapon: threatens the disk of 1 cell. """ attack = AttackSpec( id=f"{cid}-w", name="longspear" if reach_ft > 5 else "short sword", kind="melee", attack_bonus=5, damage=[DamageComponent(formula=parse_dice("1d8"), types=["piercing"])], reach_ft=reach_ft, ) 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=[attack], ) def test_observation_enriched_threatened_reach_weapon_ring() -> None: """Given: a reach (reach_ft=10) melee enemy at (3, 3) When: an Observation is built for the actor at (3, 0) Then: threatened is the 2-cell ring around (3, 3) and NOT the 1-cell dead zone (ring semantics of _weapon_threatened_squares).""" actor = make_combatant("actor") reach_enemy = make_reach_combatant("reach_enemy", reach_ft=10) actor_state = make_state(actor, "players", (3, 0)) reach_state = make_state(reach_enemy, "monsters", (3, 3)) engine = make_engine([actor_state, reach_state]) obs = _snapshot(engine, actor_state) grid = obs["threatened"] assert len(grid) == 8 assert all(len(row) == 8 for row in grid) # Dead zone at distance 1 from (3, 3) is NOT threatened. for r, c in ((2, 3), (4, 3), (3, 2), (3, 4), (2, 2), (2, 4), (4, 2), (4, 4)): assert grid[r][c] is False, f"dead zone at {(r, c)} should be False" # Ring at 5-10-5 distance 2 from (3, 3) IS threatened. for r, c in ( (1, 3), (5, 3), (3, 1), (3, 5), (1, 2), (1, 4), (5, 2), (5, 4), (2, 1), (2, 5), (4, 1), (4, 5), ): assert grid[r][c] is True, f"ring at {(r, c)} should be True" def test_observation_enriched_threatened_non_reach_disk() -> None: """Given: a non-reach melee enemy at (2, 2) on an open grid When: an Observation is built for the actor Then: threatened covers the 1-cell disk of distance-1 cells around the enemy (no reach means no dead zone).""" actor = make_combatant("actor") enemy = make_combatant("enemy") actor_state = make_state(actor, "players", (0, 0)) enemy_state = make_state(enemy, "monsters", (2, 2)) engine = make_engine([actor_state, enemy_state]) obs = _snapshot(engine, actor_state) grid = obs["threatened"] # Distance-1 disk from (2, 2): the 8 cells around it. expected_true = { (1, 1), (1, 2), (1, 3), (2, 1), (2, 3), (3, 1), (3, 2), (3, 3), } for r in range(8): for c in range(8): assert grid[r][c] is ((r, c) in expected_true) def test_observation_enriched_threatened_no_melee_weapon_empty() -> None: """Given: an enemy with only a ranged weapon (no melee) When: an Observation is built Then: threatened has no True cells (ranged threats are out of scope).""" actor = make_combatant("actor") archer = make_combatant("archer", ranged=True) actor_state = make_state(actor, "players", (0, 0)) archer_state = make_state(archer, "monsters", (0, 7)) engine = make_engine([actor_state, archer_state]) obs = _snapshot(engine, actor_state) grid = obs["threatened"] assert all(cell is False for row in grid for cell in row) def test_observation_enriched_threatened_bounded_to_grid() -> None: """Given: a melee enemy at (1, 1) on an 8x8 grid When: an Observation is built Then: threatened cells outside the grid do not raise — only in-bounds cells appear. In particular the (0, 0) corner must be True (it's a threatened distance-1 cell, in bounds) and no IndexError is raised.""" actor = make_combatant("actor") enemy = make_combatant("enemy") actor_state = make_state(actor, "players", (0, 0)) enemy_state = make_state(enemy, "monsters", (1, 1)) engine = make_engine([actor_state, enemy_state]) obs = _snapshot(engine, actor_state) grid = obs["threatened"] # (0, 0) is at distance 1 from (1, 1) and is threatened. assert grid[0][0] is True # Grid dimensions match terrain. assert len(grid) == 8 assert all(len(row) == 8 for row in grid) def test_observation_enriched_threatened_ignores_inactive_enemy() -> None: """Given: an enemy whose hp < 0 (dying, inactive) When: an Observation is built Then: threatened has no True cells (inactive enemies do not threaten). B11 conformance: at exactly 0 hp a creature is disabled but still active and threatening — downing it requires hp < 0.""" actor = make_combatant("actor") enemy = make_combatant("enemy") actor_state = make_state(actor, "players", (0, 0)) enemy_state = make_state(enemy, "monsters", (0, 3)) enemy_state.hp = -1 engine = make_engine([actor_state, enemy_state]) obs = _snapshot(engine, actor_state) grid = obs["threatened"] assert all(cell is False for row in grid for cell in row) def test_observation_enriched_threatened_ignores_actor_own_threat() -> None: """Given: an actor with a melee weapon and an ally (same side) with a melee weapon nearby When: an Observation is built Then: threatened only covers ENEMY threats (ally threats excluded).""" actor = make_combatant("actor") ally = make_combatant("ally") enemy = make_combatant("enemy") actor_state = make_state(actor, "players", (0, 0)) ally_state = make_state(ally, "players", (0, 2)) enemy_state = make_state(enemy, "monsters", (0, 5)) engine = make_engine([actor_state, ally_state, enemy_state]) obs = _snapshot(engine, actor_state) grid = obs["threatened"] # Cells around the ally (0, 2) must NOT be marked (ally, not enemy). for r, c in ((0, 1), (0, 3), (1, 2)): assert grid[r][c] is False, f"ally-threatened {(r, c)} should be False" # Cells around the enemy (0, 5) at distance 1 ARE marked. for r, c in ((0, 4), (0, 6), (1, 5)): assert grid[r][c] is True, f"enemy-threatened {(r, c)} should be True" def test_observation_enriched_threatened_union_of_two_enemies() -> None: """Given: two enemies at distinct positions When: an Observation is built Then: threatened is the union of both disks (any True if either enemy threatens it).""" actor = make_combatant("actor") enemy1 = make_combatant("enemy1") enemy2 = make_combatant("enemy2") actor_state = make_state(actor, "players", (0, 0)) enemy1_state = make_state(enemy1, "monsters", (2, 2)) enemy2_state = make_state(enemy2, "monsters", (5, 5)) engine = make_engine([actor_state, enemy1_state, enemy2_state]) obs = _snapshot(engine, actor_state) grid = obs["threatened"] # Cells in enemy1's distance-1 disk OR enemy2's distance-1 disk. disk1 = { (1, 1), (1, 2), (1, 3), (2, 1), (2, 3), (3, 1), (3, 2), (3, 3), } disk2 = { (4, 4), (4, 5), (4, 6), (5, 4), (5, 6), (6, 4), (6, 5), (6, 6), } union = disk1 | disk2 for r in range(8): for c in range(8): assert grid[r][c] is ((r, c) in union) # --------------------------------------------------------------------------- # 12. E5: top-level terrain semantics (one dict per distinct terrain type) # --------------------------------------------------------------------------- def make_multi_terrain_grid() -> Grid: """Build a 6x6 grid with wall, floor and pillar tiles.""" legend = { ".": TerrainType(type="floor", move_cost=1), "#": TerrainType(type="wall", move_cost=None, blocks_los=True), "C": TerrainType(type="pillar", move_cost=None, cover=True), } spec = MapSpec( name="multi", terrain=( "######", "#....C", "#..C..", "#....#", "C....#", "######", ), legend=legend, ) return Grid.from_spec(spec) def test_observation_enriched_terrain_semantics_all_types() -> None: """Given: a grid with wall / floor / pillar tiles When: an Observation is built Then: terrain_semantics has one entry per distinct terrain type (sorted by type for determinism), each carrying type/move_cost/blocks_los/cover/cover_bonus.""" hero = make_combatant("hero") foe = make_combatant("foe") hero_state = make_state(hero, "players", (0, 0)) foe_state = make_state(foe, "monsters", (3, 3)) grid = make_multi_terrain_grid() engine = CombatEngine(ScriptedRng([]), grid, [hero_state, foe_state]) obs = _snapshot(engine, hero_state) semantics = obs["terrain_semantics"] assert semantics == ( { "type": "floor", "move_cost": 1, "blocks_los": False, "cover": False, "cover_bonus": 0, }, { "type": "pillar", "move_cost": None, "blocks_los": False, "cover": True, "cover_bonus": 0, }, { "type": "wall", "move_cost": None, "blocks_los": True, "cover": False, "cover_bonus": 0, }, ) def test_observation_enriched_terrain_semantics_single_type() -> None: """Given: a grid with a single terrain type (all floor) When: an Observation is built Then: terrain_semantics has exactly one entry, the floor.""" 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 obs["terrain_semantics"] == ( { "type": "floor", "move_cost": 1, "blocks_los": False, "cover": False, "cover_bonus": 0, }, ) def test_observation_enriched_terrain_semantics_no_grid_iteration_needed() -> None: """Given: a single-tile map containing only the wall at (0, 0) When: an Observation is built Then: terrain_semantics contains only the wall entry, even though no passable cell is present (the function must still inspect the grid).""" legend = {"#": TerrainType(type="wall", move_cost=None, blocks_los=True)} spec = MapSpec(name="allwall", terrain=("##", "##"), legend=legend) grid = Grid.from_spec(spec) hero = make_combatant("hero") foe = make_combatant("foe") hero_state = make_state(hero, "players", (0, 0)) foe_state = make_state(foe, "monsters", (0, 0)) engine = CombatEngine(ScriptedRng([]), grid, [hero_state, foe_state]) obs = _snapshot(engine, hero_state) assert obs["terrain_semantics"] == ( { "type": "wall", "move_cost": None, "blocks_los": True, "cover": False, "cover_bonus": 0, }, ) def test_observation_enriched_terrain_semantics_no_callable_no_share() -> None: """Given: an Observation built over a multi-terrain grid When: to_dict() is called and json.dumps is invoked Then: serialization succeeds and no TerrainType / callable leaks into the snapshot (only builtins).""" hero = make_combatant("hero") foe = make_combatant("foe") hero_state = make_state(hero, "players", (0, 0)) foe_state = make_state(foe, "monsters", (0, 0)) grid = make_multi_terrain_grid() engine = CombatEngine(ScriptedRng([]), grid, [hero_state, foe_state]) obs = engine._build_observation(hero_state) data = obs.to_dict() json.dumps(data) # must not raise for entry in data["terrain_semantics"]: assert set(entry) == { "type", "move_cost", "blocks_los", "cover", "cover_bonus", } # --------------------------------------------------------------------------- # 13. E5: top-level new fields don't disturb the L1/L2/L3/L4 contract # --------------------------------------------------------------------------- def test_observation_enriched_e5_existing_keys_preserved() -> None: """Given: an Observation built for the L5 fields When: the top-level dict is inspected Then: all pre-existing top-level keys remain (round, acting_combatant, combatants, pairs, terrain, elevation) and the new keys (threatened, ranged_threatened, terrain_semantics, condition_definitions, initiative_order) are added — exact key set.""" 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 set(obs) == { "round", "acting_combatant", "combatants", "pairs", "terrain", "elevation", "threatened", "ranged_threatened", "terrain_semantics", "condition_definitions", "initiative_order", } # combatants / pairs untouched by E5 assert len(obs["combatants"]) == 2 assert len(obs["pairs"]) == 1 # --------------------------------------------------------------------------- # 14. E6: end-to-end LLM policy reading ONLY the enriched Observation # --------------------------------------------------------------------------- def _castable_spell_action(obs: Observation) -> Action | None: """Return a cast_spell Action for the first in-range offensive spell with an available slot, or None.""" acting = obs.combatants[obs.acting_combatant] for spell in acting["spells"]: if spell["level"] is None: continue slot_key = f"spell_slot_{spell['level']}" if acting["resources_left"].get(slot_key, 0) <= 0: continue for pair in obs.pairs: target = obs.combatants[pair["target_index"]] if ( target["side"] != acting["side"] and target["active"] and pair["distance_ft"] <= spell["range_ft"] ): return Action( kind="cast_spell", target_id=target["id"], spell_name=spell["name"], ) return None def _retrieve_stolen_action(obs: Observation) -> Action | None: """Return a retrieve_weapon Action for the first stolen weapon id exposed in the attacks reference, or None.""" acting = obs.combatants[obs.acting_combatant] if not acting["weapon_stolen"]: return None stolen_id = acting["weapon_stolen"][0] for attack in acting["attacks"]: if attack["id"] == stolen_id: return Action(kind="retrieve_weapon", weapon_id=attack["id"]) return None def _best_usable_attack(obs: Observation) -> dict[str, object] | None: """Return the attack dict with the highest attack_bonus among usable weapons (skipping broken/stolen ids), or None.""" acting = obs.combatants[obs.acting_combatant] best: dict[str, object] | None = None for attack in acting["attacks"]: if attack["id"] in acting["weapon_broken"]: continue if attack["id"] in acting["weapon_stolen"]: continue if best is None or attack["attack_bonus"] > best["attack_bonus"]: best = attack return best def _enriched_llm_policy(obs: Observation, state: CombatantState) -> tuple[Action, ...]: """Read ONLY the observation — never state.combatant.* nor engine.*.""" cast = _castable_spell_action(obs) if cast is not None: return (cast,) retrieve = _retrieve_stolen_action(obs) if retrieve is not None: return (retrieve,) if _best_usable_attack(obs) is None: return (Action(kind="wait"),) target_id = obs.combatants[obs.pairs[0]["target_index"]]["id"] return (Action(kind="full_attack", target_id=target_id),) def test_llm_policy_enriched_e2e() -> None: """Given: a CombatEngine with an llm_policy that reads ONLY the enriched Observation (never ``state.combatant.*`` nor ``engine.*``) When: the battle runs Then: the policy emits at least 3 distinct decision types driven by the enriched fields — ``cast_spell`` (known offensive spell + slot available + target in range), ``retrieve_weapon`` (stolen weapon id taken from the ``attacks`` reference) and ``full_attack`` with the best usable weapon — and the transcript shows the combat progressing.""" 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",)),), ) sword = AttackSpec( id="hero-sword", name="short sword", kind="melee", attack_bonus=5, damage=[DamageComponent(formula=parse_dice("1d6"), types=["slashing"])], damage_bonus=2, ) dagger = AttackSpec( id="hero-dagger", name="dagger", kind="melee", attack_bonus=8, damage=[DamageComponent(formula=parse_dice("1d4"), types=["piercing"])], damage_bonus=1, ) hero = Combatant( id="hero", name="hero", 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=[sword, dagger], spells=["Magic Missile"], spell_slots={1: 1}, ) foe = make_combatant("foe", hp=10) hero_state = make_state(hero, "players", (0, 0)) foe_state = make_state(foe, "monsters", (0, 1)) hero_state.weapon_stolen.add("hero-dagger") engine = CombatEngine( ScriptedRng([20, 1, 1, 4, 1, 1, 15, 6, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]), make_grid(), [hero_state, foe_state], round_cap=10, llm_policy=_enriched_llm_policy, spell_registry={"Magic Missile": magic_missile}, ) result = engine.run() transcript = result.transcript # cast_spell decision (round 1): the hero casts Magic Missile. assert any("cast Magic Missile" in line for line in transcript) # retrieve_weapon decision (round 2): the stolen dagger is restored. assert any("retrieve hero-dagger" in line for line in transcript) # full_attack decision (round 3): the hero swings the best usable weapon. assert any("hero: short sword vs foe" in line for line in transcript) # --------------------------------------------------------------------------- # 15. C1: DR exposed per combatant # --------------------------------------------------------------------------- def test_observation_enriched_dr_exposed() -> None: """Given: a combatant with DR and one without When: an Observation is built Then: dr is {amount, bypass} for the DR combatant and None otherwise.""" hero = make_combatant("hero") hero = hero.model_copy( update={ "dr": DamageReduction(amount=5, bypass=frozenset({"magic"})), } ) 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 obs["combatants"][0]["dr"] == {"amount": 5, "bypass": "magic"} assert obs["combatants"][1]["dr"] is None def test_observation_enriched_dr_empty_bypass() -> None: """Given: a combatant with DR/— (empty bypass) When: an Observation is built Then: dr.bypass is the empty string.""" hero = make_combatant("hero") hero = hero.model_copy( update={ "dr": DamageReduction(amount=10, bypass=frozenset()), } ) 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 obs["combatants"][0]["dr"] == {"amount": 10, "bypass": ""} # --------------------------------------------------------------------------- # 16. C2: top-level condition_definitions # --------------------------------------------------------------------------- def test_observation_enriched_condition_definitions_present() -> None: """Given: an Observation built over any combat When: the top-level dict is inspected Then: condition_definitions is a sorted tuple of (name, {mods, flags, speed_mult, miss_chance}) covering every entry of CONDITIONS_BY_NAME.""" 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) defs = obs["condition_definitions"] assert isinstance(defs, tuple) assert len(defs) == len(CONDITIONS_BY_NAME) # Sorted by name for determinism. assert [n for n, _ in defs] == sorted(CONDITIONS_BY_NAME) for _name, body in defs: assert set(body) == {"mods", "flags", "speed_mult", "miss_chance"} assert isinstance(body["mods"], list) assert isinstance(body["flags"], list) assert isinstance(body["speed_mult"], float) assert isinstance(body["miss_chance"], int) def test_observation_enriched_condition_definitions_shaken() -> None: """Given: the shaken condition definition When: condition_definitions is inspected Then: shaken carries its -2 attack/fort/ref/will mods, no flags, speed 1.0.""" 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) defs = dict(obs["condition_definitions"]) shaken = defs["shaken"] _skel: dict[str, object] = { "bonus_type": "", "condition": None, "weapon_filter": None, } assert shaken["mods"] == [ {"target": "attack", "value": -2, **_skel}, {"target": "fort", "value": -2, **_skel}, {"target": "ref", "value": -2, **_skel}, {"target": "will", "value": -2, **_skel}, ] assert shaken["flags"] == [] assert shaken["speed_mult"] == 1.0 def test_observation_enriched_condition_definitions_flags() -> None: """Given: a condition with behavioral flags (e.g. paralyzed) When: condition_definitions is inspected Then: the flags list names every True behavioral flag.""" 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) defs = dict(obs["condition_definitions"]) paralyzed = defs["paralyzed"] assert "cant_act" in paralyzed["flags"] assert "denies_dex_to_ac" in paralyzed["flags"] # --------------------------------------------------------------------------- # 17. C3: creature_type exposed # --------------------------------------------------------------------------- def test_observation_enriched_creature_type() -> None: """Given: a combatant with a non-default creature_type When: an Observation is built Then: creature_type is exposed as a string.""" hero = make_combatant("hero") hero = hero.model_copy(update={"creature_type": "undead"}) 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 obs["combatants"][0]["creature_type"] == "undead" assert obs["combatants"][1]["creature_type"] == "living" # --------------------------------------------------------------------------- # 18. C4: feature_details exposed # --------------------------------------------------------------------------- def test_observation_enriched_feature_details() -> None: """Given: a combatant with features carrying effects When: an Observation is built Then: feature_details carries {name, category, source_detail, effects} while the existing features name-only list is untouched.""" hero = make_combatant( "hero", features=[ AbilitySpec( name="Weapon Focus (short sword)", category="feat", source_detail="feat lv1", effects=(StatModifier(target="attack", value=1, weapon_filter="short sword"),), ), 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]) obs = _snapshot(engine, hero_state) c = obs["combatants"][0] # Existing name-only list unchanged. assert c["features"] == ("Channel Energy", "Weapon Focus (short sword)") details = c["feature_details"] assert len(details) == 2 # Sorted by name. assert [d["name"] for d in details] == ["Channel Energy", "Weapon Focus (short sword)"] wf = details[1] assert wf["category"] == "feat" assert wf["source_detail"] == "feat lv1" assert wf["effects"] == ( { "target": "attack", "value": 1, "bonus_type": "", "condition": None, "weapon_filter": "short sword", }, ) # Combatant without features -> empty tuple. assert obs["combatants"][1]["feature_details"] == () # --------------------------------------------------------------------------- # 19. E1/E2: bab and initiative_mod exposed # --------------------------------------------------------------------------- def test_observation_enriched_bab_and_initiative_mod() -> None: """Given: a combatant with known bab and initiative_mod When: an Observation is built Then: bab and initiative_mod are exposed as ints.""" hero = make_combatant("hero") hero = hero.model_copy(update={"bab": 7, "initiative_mod": 4}) 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 obs["combatants"][0]["bab"] == 7 assert obs["combatants"][0]["initiative_mod"] == 4 assert obs["combatants"][1]["bab"] == 5 assert obs["combatants"][1]["initiative_mod"] == 0 # --------------------------------------------------------------------------- # 20. E3: casting_ability exposed # --------------------------------------------------------------------------- def test_observation_enriched_casting_ability_caster() -> None: """Given: a combatant with spells and a non-default casting ability When: an Observation is built Then: casting_ability is the ability name used for spell DCs.""" hero = make_combatant("hero", spells=["Fireball"]) hero = hero.model_copy(update={"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]) obs = _snapshot(engine, hero_state) assert obs["combatants"][0]["casting_ability"] == "cha" def test_observation_enriched_casting_ability_non_caster() -> None: """Given: a combatant with no spells When: an Observation is built Then: casting_ability is None (no spell DCs to evaluate).""" 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 obs["combatants"][0]["casting_ability"] is None assert obs["combatants"][1]["casting_ability"] is None # --------------------------------------------------------------------------- # 21. E4: half_on_save / save_negates per spell # --------------------------------------------------------------------------- def test_observation_enriched_spell_half_on_save_area() -> None: """Given: a spell with an AreaEffect save_for_half (Fireball) When: an Observation is built Then: half_on_save is True and save_negates is False.""" 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) s = obs["combatants"][0]["spells"][0] assert s["half_on_save"] is True assert s["save_negates"] is False def test_observation_enriched_spell_save_negates_targeted() -> None: """Given: a targeted spell with a save and no half-on-save (Hold Person) When: an Observation is built Then: half_on_save is False and save_negates is True.""" hold_person = load_spell(Path("data/spells/hold_person.json")) hero = make_combatant("hero", spells=["Hold Person"]) 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={"Hold Person": hold_person}) obs = _snapshot(engine, hero_state) s = obs["combatants"][0]["spells"][0] assert s["half_on_save"] is False assert s["save_negates"] is True def test_observation_enriched_spell_no_save() -> None: """Given: a spell with save=none (Magic Missile) When: an Observation is built Then: half_on_save is False and save_negates is False.""" magic_missile = load_spell(Path("data/spells/magic_missile.json")) hero = make_combatant("hero", spells=["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={"Magic Missile": magic_missile}) obs = _snapshot(engine, hero_state) s = obs["combatants"][0]["spells"][0] assert s["half_on_save"] is False assert s["save_negates"] is False def test_observation_enriched_spell_unknown_save_fields() -> None: """Given: an unknown spell (absent from the registry) When: an Observation is built Then: half_on_save and save_negates are both False.""" 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) s = obs["combatants"][0]["spells"][0] assert s["half_on_save"] is False assert s["save_negates"] is False # --------------------------------------------------------------------------- # 22. E5: active_buffs exposed # --------------------------------------------------------------------------- def test_observation_enriched_active_buffs() -> None: """Given: a combatant with an active buff When: an Observation is built Then: active_buffs carries {source, rounds_left, mods}.""" hero = make_combatant("hero") foe = make_combatant("foe") hero_state = make_state(hero, "players", (0, 0)) foe_state = make_state(foe, "monsters", (0, 3)) hero_state.active_buffs.append( ActiveBuff( modifiers=( StatModifier(target="attack", value=2, bonus_type="morale"), StatModifier(target="damage", value=1), ), rounds_left=3, source="spell: bull's strength", ) ) engine = make_engine([hero_state, foe_state]) obs = _snapshot(engine, hero_state) buffs = obs["combatants"][0]["active_buffs"] assert buffs == ( { "source": "spell: bull's strength", "rounds_left": 3, "mods": ( {"target": "attack", "value": 2, "bonus_type": "morale"}, {"target": "damage", "value": 1, "bonus_type": ""}, ), }, ) assert obs["combatants"][1]["active_buffs"] == () # --------------------------------------------------------------------------- # 23. Medium items: abilities, skills, metamagic_feats, weapon_damage, # summon_duration, immediate_used, deed_up_close_and_deadly # --------------------------------------------------------------------------- def test_observation_enriched_abilities() -> None: """Given: a combatant with known ability scores When: an Observation is built Then: abilities maps each ability name to its raw base score.""" hero = make_combatant("hero") hero = hero.model_copy( update={ "abilities": hero.abilities.model_copy( update={ "str_score": 16, "dex_score": 14, "con_score": 12, "int_score": 10, "wis_score": 8, "cha_score": 6, } ) } ) 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 obs["combatants"][0]["abilities"] == { "str": 16, "dex": 14, "con": 12, "int": 10, "wis": 8, "cha": 6, } def test_observation_enriched_skills_rank_gt_zero() -> None: """Given: a combatant with skills, some with rank 0 When: an Observation is built Then: skills lists only skills with rank > 0, each with {rank, class_skill, armor_penalty}.""" hero = make_combatant("hero") hero = hero.model_copy( update={ "skills": { "acrobatics": SkillRank(rank=3, class_skill=True, armor_penalty=1), "perception": SkillRank(rank=0, class_skill=False, armor_penalty=0), "stealth": SkillRank(rank=1, class_skill=False, armor_penalty=1), } } ) 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) skills = obs["combatants"][0]["skills"] assert skills == { "acrobatics": {"rank": 3, "class_skill": True, "armor_penalty": 1}, "stealth": {"rank": 1, "class_skill": False, "armor_penalty": 1}, } assert obs["combatants"][1]["skills"] == {} def test_observation_enriched_metamagic_feats() -> None: """Given: a combatant with metamagic feats When: an Observation is built Then: metamagic_feats is a sorted tuple of feat names.""" hero = make_combatant("hero") hero = hero.model_copy( update={"metamagic_feats": ["Extend Spell", "Empower 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) assert obs["combatants"][0]["metamagic_feats"] == ("Empower Spell", "Extend Spell") assert obs["combatants"][1]["metamagic_feats"] == () def test_observation_enriched_weapon_damage() -> None: """Given: a combatant with accumulated weapon damage When: an Observation is built Then: weapon_damage maps weapon id to current damage.""" hero = make_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_damage["hero-w"] = 7 engine = make_engine([hero_state, foe_state]) obs = _snapshot(engine, hero_state) assert obs["combatants"][0]["weapon_damage"] == {"hero-w": 7} assert obs["combatants"][1]["weapon_damage"] == {} def test_observation_enriched_summon_duration() -> None: """Given: a summoned combatant and a non-summoned one When: an Observation is built Then: summon_duration is the remaining rounds for the summon and None otherwise.""" hero = make_combatant("hero") foe = make_combatant("foe") hero_state = make_state(hero, "players", (0, 0)) foe_state = make_state(foe, "monsters", (0, 3)) hero_state.summon_duration = 4 engine = make_engine([hero_state, foe_state]) obs = _snapshot(engine, hero_state) assert obs["combatants"][0]["summon_duration"] == 4 assert obs["combatants"][1]["summon_duration"] is None def test_observation_enriched_immediate_used_and_deed() -> None: """Given: a combatant with immediate_used and deed flags set When: an Observation is built Then: immediate_used and deed_up_close_and_deadly reflect the current flag values.""" hero = make_combatant("hero") foe = make_combatant("foe") hero_state = make_state(hero, "players", (0, 0)) foe_state = make_state(foe, "monsters", (0, 3)) hero_state.immediate_used = True hero_state.deed_up_close_and_deadly = True engine = make_engine([hero_state, foe_state]) obs = _snapshot(engine, hero_state) assert obs["combatants"][0]["immediate_used"] is True assert obs["combatants"][0]["deed_up_close_and_deadly"] is True assert obs["combatants"][1]["immediate_used"] is False assert obs["combatants"][1]["deed_up_close_and_deadly"] is False # --------------------------------------------------------------------------- # 24. All new fields are JSON-serializable and frozen # --------------------------------------------------------------------------- def test_observation_enriched_all_new_fields_json_serializable() -> None: """Given: an Observation with every new field populated 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"], features=[ AbilitySpec( name="Weapon Focus (short sword)", category="feat", source_detail="feat lv1", effects=(StatModifier(target="attack", value=1, weapon_filter="short sword"),), ), ], ) hero = hero.model_copy( update={ "dr": DamageReduction(amount=5, bypass=frozenset({"magic"})), "creature_type": "undead", "bab": 7, "initiative_mod": 4, "casting_ability": "cha", "metamagic_feats": ["Extend Spell"], "skills": {"acrobatics": SkillRank(rank=2, class_skill=True, armor_penalty=1)}, } ) foe = make_combatant("foe") hero_state = make_state(hero, "players", (0, 0)) foe_state = make_state(foe, "monsters", (0, 3)) hero_state.active_buffs.append( ActiveBuff( modifiers=(StatModifier(target="attack", value=2, bonus_type="morale"),), rounds_left=2, source="spell: bull's strength", ) ) hero_state.weapon_damage["hero-w"] = 3 hero_state.summon_duration = 2 hero_state.immediate_used = True hero_state.deed_up_close_and_deadly = True engine = make_engine([hero_state, foe_state], spell_registry={"Fireball": fireball}) obs = engine._build_observation(hero_state) data = obs.to_dict() json.dumps(data) # must not raise hero_state.hp = 5 data_after = obs.to_dict() assert data_after == data c = data_after["combatants"][0] assert c["dr"] == {"amount": 5, "bypass": "magic"} assert c["creature_type"] == "undead" assert c["bab"] == 7 assert c["initiative_mod"] == 4 assert c["casting_ability"] == "cha" assert c["metamagic_feats"] == ("Extend Spell",) assert c["skills"] == { "acrobatics": {"rank": 2, "class_skill": True, "armor_penalty": 1} } assert c["weapon_damage"] == {"hero-w": 3} assert c["summon_duration"] == 2 assert c["immediate_used"] is True assert c["deed_up_close_and_deadly"] is True assert c["active_buffs"][0]["source"] == "spell: bull's strength" assert c["feature_details"][0]["name"] == "Weapon Focus (short sword)" assert data_after["condition_definitions"] == data["condition_definitions"]