feat(combat): expose DR, conditions, creature type, features, and spell save semantics in observation

This commit is contained in:
2026-09-06 18:18:13 +02:00
parent d2cd6f0702
commit e29c472a67
3 changed files with 724 additions and 8 deletions
+586 -1
View File
@@ -22,7 +22,9 @@ 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 (
@@ -31,10 +33,16 @@ from pf1e_simulator.models import (
AttackSpec,
Combatant,
DamageComponent,
DamageReduction,
Saves,
SkillRank,
)
from pf1e_simulator.rng import ScriptedRng
from pf1e_simulator.spells import DamageEffect, SpellSpec, load_spell
from pf1e_simulator.spells import (
DamageEffect,
SpellSpec,
load_spell,
)
def make_combatant(
@@ -519,6 +527,8 @@ def test_observation_enriched_spells_unknown_graceful() -> None:
"save": "none",
"dc": None,
"summary": "",
"half_on_save": False,
"save_negates": False,
},
)
@@ -1010,6 +1020,7 @@ def test_observation_enriched_e5_existing_keys_preserved() -> None:
"elevation",
"threatened",
"terrain_semantics",
"condition_definitions",
}
# combatants / pairs untouched by E5
assert len(obs["combatants"]) == 2
@@ -1166,3 +1177,577 @@ def test_llm_policy_enriched_e2e() -> None:
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}) 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"}
assert isinstance(body["mods"], list)
assert isinstance(body["flags"], list)
assert isinstance(body["speed_mult"], float)
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"]