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, - Par combattant : id, side, pos, occupied_cells, hp/hp_max, active/dead,
conditions (noms + rounds_left), resources_left (+ `spell_slot_{level}`), conditions (noms + rounds_left), resources_left (+ `spell_slot_{level}`),
weapon_broken/weapon_stolen, grappled_with, moved_this_turn, power_attack, weapon_broken/weapon_stolen, grappled_with, moved_this_turn, power_attack,
name, level, size, speed_land_ft, speed_fly_ft, ac {total, touch, name, level, size, dr {amount, bypass} ou None (C1), creature_type (C3),
flat_footed}, saves {fort, ref, will}, cmb, cmd, attacks (référentiel bab (E1), initiative_mod (E2), casting_ability ou None pour non-jeteur
complet : id, name, kind, attack_bonus, damage, damage_bonus, crit_range, (E3), abilities {str/dex/con/int/wis/cha: score brut} (Medium), skills
crit_mult, reach_ft, range_increment_ft, count, firearm — y compris les {nom: {rank, class_skill, armor_penalty}} uniquement rank > 0 (Medium),
armes broken/stolen), features (noms des capacités), spells (résumés metamagic_feats (tuple trié), weapon_damage {weapon_id: dégâts courants},
enrichis du registre : name, level, school, range_ft, save, dc, summary). 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, - Par paire combattant actif × cible : target_index, distance_ft, weapon_for,
range_penalty, can_charge, can_step5_to_attack, has_line_of_effect, range_penalty, can_charge, can_step5_to_attack, has_line_of_effect,
flanking, movement_cost_ft, target_threatens_me, enemies_threatening_me. flanking, movement_cost_ft, target_threatens_me, enemies_threatening_me.
- Top-level : round, acting_combatant, combatants, pairs, terrain, elevation, - Top-level : round, acting_combatant, combatants, pairs, terrain, elevation,
threatened (grille bool des cases menacées par les ennemis), threatened (grille bool des cases menacées par les ennemis),
terrain_semantics (un dict par type de terrain : type, move_cost, 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 Les helpers publics du moteur (L5) sont désormais embarqués EN DONNÉES dans
le snapshot (`threatened`, `terrain_semantics`, `movement_cost_ft`, le snapshot (`threatened`, `terrain_semantics`, `movement_cost_ft`,
`target_threatens_me`, `enemies_threatening_me`) — une `LLMPolicy` ne peut `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) 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, ...] = ( _STEP_DELTAS: tuple[Pos, ...] = (
(-1, -1), (-1, -1),
(-1, 0), (-1, 0),
@@ -400,6 +443,7 @@ class Observation:
elevation: tuple[tuple[int, ...], ...] elevation: tuple[tuple[int, ...], ...]
threatened: tuple[tuple[bool, ...], ...] = () threatened: tuple[tuple[bool, ...], ...] = ()
terrain_semantics: tuple[dict[str, object], ...] = () terrain_semantics: tuple[dict[str, object], ...] = ()
condition_definitions: tuple[tuple[str, dict[str, object]], ...] = ()
def to_dict(self) -> dict[str, object]: def to_dict(self) -> dict[str, object]:
"""Return a JSON-serializable dict with the pinned top-level keys.""" """Return a JSON-serializable dict with the pinned top-level keys."""
@@ -412,6 +456,7 @@ class Observation:
"elevation": self.elevation, "elevation": self.elevation,
"threatened": self.threatened, "threatened": self.threatened,
"terrain_semantics": self.terrain_semantics, "terrain_semantics": self.terrain_semantics,
"condition_definitions": self.condition_definitions,
} }
@@ -601,6 +646,10 @@ class CombatEngine:
elevation = () elevation = ()
threatened = self._threat_grid(state) threatened = self._threat_grid(state)
terrain_semantics = self._terrain_semantics() terrain_semantics = self._terrain_semantics()
condition_definitions = tuple(
(name, _condition_definition_dict(cond))
for name, cond in sorted(CONDITIONS_BY_NAME.items())
)
return Observation( return Observation(
round=self._current_round, round=self._current_round,
acting_combatant=acting_index, acting_combatant=acting_index,
@@ -610,6 +659,7 @@ class CombatEngine:
elevation=elevation, elevation=elevation,
threatened=threatened, threatened=threatened,
terrain_semantics=terrain_semantics, terrain_semantics=terrain_semantics,
condition_definitions=condition_definitions,
) )
def _threat_grid(self, state: CombatantState) -> tuple[tuple[bool, ...], ...]: def _threat_grid(self, state: CombatantState) -> tuple[tuple[bool, ...], ...]:
@@ -668,6 +718,12 @@ class CombatEngine:
resources = dict(state.resources_left) resources = dict(state.resources_left)
for level, count in state.spell_slots_left.items(): for level, count in state.spell_slots_left.items():
resources[f"spell_slot_{level}"] = count 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 { return {
"id": state.combatant.id, "id": state.combatant.id,
"side": state.side, "side": state.side,
@@ -687,6 +743,50 @@ class CombatEngine:
"name": state.combatant.name, "name": state.combatant.name,
"level": state.combatant.level, "level": state.combatant.level,
"size": state.combatant.size, "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_land_ft": state.combatant.speed_land_ft,
"speed_fly_ft": state.combatant.speed_fly_ft, "speed_fly_ft": state.combatant.speed_fly_ft,
"ac": { "ac": {
@@ -725,6 +825,19 @@ class CombatEngine:
for attack in state.combatant.attacks for attack in state.combatant.attacks
), ),
"features": tuple(sorted(f.name for f in state.combatant.features)), "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( "spells": tuple(
self._spell_snapshot(state, name) for name in state.combatant.spells self._spell_snapshot(state, name) for name in state.combatant.spells
), ),
@@ -2684,7 +2797,10 @@ class CombatEngine:
"save": "none", "save": "none",
"dc": None, "dc": None,
"summary": "", "summary": "",
"half_on_save": False,
"save_negates": False,
} }
half_on_save = _spell_half_on_save(spell)
return { return {
"name": spell.name, "name": spell.name,
"level": spell.level, "level": spell.level,
@@ -2693,6 +2809,8 @@ class CombatEngine:
"save": spell.save, "save": spell.save,
"dc": self._spell_dc(state, spell), "dc": self._spell_dc(state, spell),
"summary": _spell_summary(spell), "summary": _spell_summary(spell),
"half_on_save": half_on_save,
"save_negates": spell.save != "none" and not half_on_save,
} }
def _cast_spell( def _cast_spell(
+586 -1
View File
@@ -22,7 +22,9 @@ from pathlib import Path
from pf1e_simulator.abilities import AbilitySpec from pf1e_simulator.abilities import AbilitySpec
from pf1e_simulator.combat import Action, CombatantState, CombatEngine, Observation 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.dice import dice_to_str, parse_dice
from pf1e_simulator.effects import ActiveBuff, StatModifier
from pf1e_simulator.grid import Grid from pf1e_simulator.grid import Grid
from pf1e_simulator.map import MapSpec, TerrainType from pf1e_simulator.map import MapSpec, TerrainType
from pf1e_simulator.models import ( from pf1e_simulator.models import (
@@ -31,10 +33,16 @@ from pf1e_simulator.models import (
AttackSpec, AttackSpec,
Combatant, Combatant,
DamageComponent, DamageComponent,
DamageReduction,
Saves, Saves,
SkillRank,
) )
from pf1e_simulator.rng import ScriptedRng 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( def make_combatant(
@@ -519,6 +527,8 @@ def test_observation_enriched_spells_unknown_graceful() -> None:
"save": "none", "save": "none",
"dc": None, "dc": None,
"summary": "", "summary": "",
"half_on_save": False,
"save_negates": False,
}, },
) )
@@ -1010,6 +1020,7 @@ def test_observation_enriched_e5_existing_keys_preserved() -> None:
"elevation", "elevation",
"threatened", "threatened",
"terrain_semantics", "terrain_semantics",
"condition_definitions",
} }
# combatants / pairs untouched by E5 # combatants / pairs untouched by E5
assert len(obs["combatants"]) == 2 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) assert any("retrieve hero-dagger" in line for line in transcript)
# full_attack decision (round 3): the hero swings the best usable weapon. # 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) 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"]