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
+20 -7
View File
@@ -462,19 +462,32 @@ Règles modélisées :
- Par combattant : id, side, pos, occupied_cells, hp/hp_max, active/dead,
conditions (noms + rounds_left), resources_left (+ `spell_slot_{level}`),
weapon_broken/weapon_stolen, grappled_with, moved_this_turn, power_attack,
name, level, size, speed_land_ft, speed_fly_ft, ac {total, touch,
flat_footed}, saves {fort, ref, will}, cmb, cmd, attacks (référentiel
complet : id, name, kind, attack_bonus, damage, damage_bonus, crit_range,
crit_mult, reach_ft, range_increment_ft, count, firearm — y compris les
armes broken/stolen), features (noms des capacités), spells (résumés
enrichis du registre : name, level, school, range_ft, save, dc, summary).
name, level, size, dr {amount, bypass} ou None (C1), creature_type (C3),
bab (E1), initiative_mod (E2), casting_ability ou None pour non-jeteur
(E3), abilities {str/dex/con/int/wis/cha: score brut} (Medium), skills
{nom: {rank, class_skill, armor_penalty}} uniquement rank > 0 (Medium),
metamagic_feats (tuple trié), weapon_damage {weapon_id: dégâts courants},
summon_duration (int ou None), immediate_used, deed_up_close_and_deadly
(flags actuels), active_buffs {source, rounds_left, mods: tuple de
{target, value, bonus_type}} (E5), speed_land_ft, speed_fly_ft, ac {total,
touch, flat_footed}, saves {fort, ref, will}, cmb, cmd, attacks
(référentiel complet : id, name, kind, attack_bonus, damage, damage_bonus,
crit_range, crit_mult, reach_ft, range_increment_ft, count, firearm —
y compris les armes broken/stolen), features (noms des capacités),
feature_details (C4 : tuple de {name, category, source_detail, effects:
tuple de {target, value, bonus_type, condition, weapon_filter}}),
spells (résumés enrichis du registre : name, level, school, range_ft,
save, dc, summary, half_on_save, save_negates (E4)).
- Par paire combattant actif × cible : target_index, distance_ft, weapon_for,
range_penalty, can_charge, can_step5_to_attack, has_line_of_effect,
flanking, movement_cost_ft, target_threatens_me, enemies_threatening_me.
- Top-level : round, acting_combatant, combatants, pairs, terrain, elevation,
threatened (grille bool des cases menacées par les ennemis),
terrain_semantics (un dict par type de terrain : type, move_cost,
blocks_los, cover).
blocks_los, cover), condition_definitions (C2 : tuple trié de (nom,
{mods, flags, speed_mult}) — définitions complètes des 21 conditions de
CONDITIONS_BY_NAME pour que la LLM interprète les drapeaux et
modificateurs).
Les helpers publics du moteur (L5) sont désormais embarqués EN DONNÉES dans
le snapshot (`threatened`, `terrain_semantics`, `movement_cost_ft`,
`target_threatens_me`, `enemies_threatening_me`) — une `LLMPolicy` ne peut
+118
View File
@@ -206,6 +206,49 @@ def _spell_summary(spell: SpellSpec) -> str:
return ", ".join(_spell_effect_summary(e) for e in spell.effects)
def _stat_modifier_dict(mod: StatModifier) -> dict[str, object]:
"""Serialize one StatModifier to a JSON-safe dict."""
return {
"target": mod.target,
"value": mod.value,
"bonus_type": mod.bonus_type,
"condition": mod.condition,
"weapon_filter": mod.weapon_filter,
}
def _condition_definition_dict(cond: Condition) -> dict[str, object]:
"""Serialize one Condition definition (mods + behavioral flags)."""
flags = [
name
for name, value in (
("denies_dex_to_ac", cond.denies_dex_to_ac),
("cant_act", cond.cant_act),
("cant_move", cond.cant_move),
("cant_attack", cond.cant_attack),
("cant_charge", cond.cant_charge),
("cant_aoo", cond.cant_aoo),
("must_flee", cond.must_flee),
)
if value
]
return {
"mods": [_stat_modifier_dict(m) for m in cond.modifiers],
"flags": flags,
"speed_mult": cond.speed_mult,
}
def _spell_half_on_save(spell: SpellSpec) -> bool:
"""True if any effect grants half damage on a successful save."""
for effect in spell.effects:
if isinstance(effect, DamageEffect) and effect.half_on_save:
return True
if isinstance(effect, AreaEffect) and effect.save_for_half:
return True
return False
_STEP_DELTAS: tuple[Pos, ...] = (
(-1, -1),
(-1, 0),
@@ -400,6 +443,7 @@ class Observation:
elevation: tuple[tuple[int, ...], ...]
threatened: tuple[tuple[bool, ...], ...] = ()
terrain_semantics: tuple[dict[str, object], ...] = ()
condition_definitions: tuple[tuple[str, dict[str, object]], ...] = ()
def to_dict(self) -> dict[str, object]:
"""Return a JSON-serializable dict with the pinned top-level keys."""
@@ -412,6 +456,7 @@ class Observation:
"elevation": self.elevation,
"threatened": self.threatened,
"terrain_semantics": self.terrain_semantics,
"condition_definitions": self.condition_definitions,
}
@@ -601,6 +646,10 @@ class CombatEngine:
elevation = ()
threatened = self._threat_grid(state)
terrain_semantics = self._terrain_semantics()
condition_definitions = tuple(
(name, _condition_definition_dict(cond))
for name, cond in sorted(CONDITIONS_BY_NAME.items())
)
return Observation(
round=self._current_round,
acting_combatant=acting_index,
@@ -610,6 +659,7 @@ class CombatEngine:
elevation=elevation,
threatened=threatened,
terrain_semantics=terrain_semantics,
condition_definitions=condition_definitions,
)
def _threat_grid(self, state: CombatantState) -> tuple[tuple[bool, ...], ...]:
@@ -668,6 +718,12 @@ class CombatEngine:
resources = dict(state.resources_left)
for level, count in state.spell_slots_left.items():
resources[f"spell_slot_{level}"] = count
dr = None
if state.combatant.dr is not None:
dr = {
"amount": state.combatant.dr.amount,
"bypass": ",".join(sorted(state.combatant.dr.bypass)),
}
return {
"id": state.combatant.id,
"side": state.side,
@@ -687,6 +743,50 @@ class CombatEngine:
"name": state.combatant.name,
"level": state.combatant.level,
"size": state.combatant.size,
"creature_type": state.combatant.creature_type,
"bab": state.combatant.bab,
"initiative_mod": state.combatant.initiative_mod,
"casting_ability": (
state.combatant.casting_ability if state.combatant.spells else None
),
"dr": dr,
"abilities": {
"str": state.combatant.abilities.str_score,
"dex": state.combatant.abilities.dex_score,
"con": state.combatant.abilities.con_score,
"int": state.combatant.abilities.int_score,
"wis": state.combatant.abilities.wis_score,
"cha": state.combatant.abilities.cha_score,
},
"skills": {
name: {
"rank": rank.rank,
"class_skill": rank.class_skill,
"armor_penalty": rank.armor_penalty,
}
for name, rank in state.combatant.skills.items()
if rank.rank > 0
},
"metamagic_feats": tuple(sorted(state.combatant.metamagic_feats)),
"weapon_damage": dict(state.weapon_damage),
"summon_duration": state.summon_duration or None,
"immediate_used": state.immediate_used,
"deed_up_close_and_deadly": state.deed_up_close_and_deadly,
"active_buffs": tuple(
{
"source": buff.source,
"rounds_left": buff.rounds_left,
"mods": tuple(
{
"target": mod.target,
"value": mod.value,
"bonus_type": mod.bonus_type,
}
for mod in buff.modifiers
),
}
for buff in state.active_buffs
),
"speed_land_ft": state.combatant.speed_land_ft,
"speed_fly_ft": state.combatant.speed_fly_ft,
"ac": {
@@ -725,6 +825,19 @@ class CombatEngine:
for attack in state.combatant.attacks
),
"features": tuple(sorted(f.name for f in state.combatant.features)),
"feature_details": tuple(
{
"name": feature.name,
"category": feature.category,
"source_detail": feature.source_detail,
"effects": tuple(
_stat_modifier_dict(mod) for mod in feature.effects
),
}
for feature in sorted(
state.combatant.features, key=lambda f: f.name
)
),
"spells": tuple(
self._spell_snapshot(state, name) for name in state.combatant.spells
),
@@ -2684,7 +2797,10 @@ class CombatEngine:
"save": "none",
"dc": None,
"summary": "",
"half_on_save": False,
"save_negates": False,
}
half_on_save = _spell_half_on_save(spell)
return {
"name": spell.name,
"level": spell.level,
@@ -2693,6 +2809,8 @@ class CombatEngine:
"save": spell.save,
"dc": self._spell_dc(state, spell),
"summary": _spell_summary(spell),
"half_on_save": half_on_save,
"save_negates": spell.save != "none" and not half_on_save,
}
def _cast_spell(
+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"]