Files
pf1e-simulator/tests/test_observation_enriched.py
T

1791 lines
65 KiB
Python

"""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,
) -> 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 [],
)
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,
},
)
# ---------------------------------------------------------------------------
# 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",
"range_penalty",
"can_charge",
"can_step5_to_attack",
"has_line_of_effect",
"flanking",
"movement_cost_ft",
"target_threatens_me",
"enemies_threatening_me",
}
# ---------------------------------------------------------------------------
# 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 (inactive)
When: an Observation is built
Then: threatened has no True cells (inactive enemies do not threaten)."""
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 = 0
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 two new keys
(threatened, terrain_semantics) 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",
"terrain_semantics",
"condition_definitions",
}
# 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"]