4d89305881
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>
1806 lines
65 KiB
Python
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"]
|