feat(combat): expose DR, conditions, creature type, features, and spell save semantics in observation
This commit is contained in:
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user