From e29c472a6748e0359cacb4a4a9962bc4408db674 Mon Sep 17 00:00:00 2001 From: ctan Date: Sun, 6 Sep 2026 18:18:13 +0200 Subject: [PATCH] feat(combat): expose DR, conditions, creature type, features, and spell save semantics in observation --- README.md | 27 +- src/pf1e_simulator/combat.py | 118 ++++++ tests/test_observation_enriched.py | 587 ++++++++++++++++++++++++++++- 3 files changed, 724 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index f5f5df8..9ea1801 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index 045f731..56df17a 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -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( diff --git a/tests/test_observation_enriched.py b/tests/test_observation_enriched.py index bd71670..ada79f7 100644 --- a/tests/test_observation_enriched.py +++ b/tests/test_observation_enriched.py @@ -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"]