678 lines
24 KiB
Python
678 lines
24 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 CombatantState, CombatEngine
|
|
from pf1e_simulator.dice import dice_to_str, parse_dice
|
|
from pf1e_simulator.grid import Grid
|
|
from pf1e_simulator.map import MapSpec, TerrainType
|
|
from pf1e_simulator.models import (
|
|
AbilityScores,
|
|
ACProfile,
|
|
AttackSpec,
|
|
Combatant,
|
|
DamageComponent,
|
|
Saves,
|
|
)
|
|
from pf1e_simulator.rng import ScriptedRng
|
|
from pf1e_simulator.spells import 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": "",
|
|
},
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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",
|
|
}
|