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