Files
pf1e-simulator/tests/test_observation_enriched.py
T
ctan 4d89305881 fix(rules): distinguish natural reach from reach weapons
PF1e CRB 'Big and Little Creatures in Combat': a creature's natural
reach is a disk (a Large creature still threatens and strikes adjacent
squares), while a reach weapon doubles the wielder's natural reach but
'can't strike at their natural reach or less' (a ring whose dead zone is
the natural reach). The engine keyed the ring off reach_ft > 5, so
reach-10 natural attacks (giant belostomatid bite/claw, mounted
hooves) wrongly got a 5-ft dead zone.

AttackSpec gains reach_weapon (parsed from the combatant JSON); the
disk-vs-ring rule is centralized in _melee_reach_zone_cells and applied
consistently to weapon selection, melee usability, threatened squares
and charge end placement. Esha's Lance is the only reach weapon in the
shipped data; Large natural attackers keep the default (disk).

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-11 22:06:46 +02:00

1806 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,
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, flagged
``reach_weapon=True``): 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,
reach_weapon=reach_ft > 5,
)
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"]