diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index fb4a6b6..2fa5182 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -678,6 +678,16 @@ class CombatEngine: ) charge = self.can_charge(state, target) can_charge = charge is not None and charge.weapon is not None + occupied = state.occupied_cells() + target_threatens_me = bool( + target.active + and any(w.kind == "melee" for w in target.usable_attacks()) + and self.threatened_squares(target) & occupied + ) + threatening_enemies: set[str] = set() + for cell in occupied: + for enemy in self.enemies_threatening(state, cell): + threatening_enemies.add(enemy.combatant.id) return { "target_index": self._states.index(target), "distance_ft": distance_ft, @@ -689,6 +699,9 @@ class CombatEngine: self._grid, state.occupied_cells(), target.occupied_cells() ), "flanking": self._flanking_bonus(state, target) > 0, + "movement_cost_ft": self.movement_cost_ft(state, target), + "target_threatens_me": target_threatens_me, + "enemies_threatening_me": len(threatening_enemies), } def _take_turn(self, state: CombatantState) -> bool: diff --git a/tests/test_observation_enriched.py b/tests/test_observation_enriched.py index 9f159ce..472a3db 100644 --- a/tests/test_observation_enriched.py +++ b/tests/test_observation_enriched.py @@ -48,15 +48,30 @@ def make_combatant( speed_fly: int | None = None, features: list[AbilitySpec] | None = None, spells: list[str] | None = None, + ranged: bool = False, ) -> Combatant: - """Build a single-weapon melee combatant with known static stats.""" - attack = AttackSpec( - id=f"{cid}-w", - name="short sword", - kind="melee", - attack_bonus=attack_bonus, - damage=[DamageComponent(formula=parse_dice("1d6"), types=["slashing"])], - ) + """Build a single-weapon combatant with known static stats. + + ``ranged=True`` builds a ranged-only combatant (no melee weapon), so it + never threatens adjacent squares. + """ + if ranged: + attack = AttackSpec( + id=f"{cid}-w", + name="short bow", + kind="ranged", + attack_bonus=attack_bonus, + damage=[DamageComponent(formula=parse_dice("1d6"), types=["piercing"])], + range_increment_ft=60, + ) + else: + attack = AttackSpec( + id=f"{cid}-w", + name="short sword", + kind="melee", + attack_bonus=attack_bonus, + damage=[DamageComponent(formula=parse_dice("1d6"), types=["slashing"])], + ) return Combatant( id=cid, name=cid, @@ -539,3 +554,124 @@ def test_observation_enriched_spells_features_json_serializable() -> None: assert c["features"] == ("Channel Energy",) assert c["spells"][0]["name"] == "Fireball" assert c["spells"][1]["name"] == "Mystery Spell" + + +# --------------------------------------------------------------------------- +# 10. E4: per-pair movement cost and threat info +# --------------------------------------------------------------------------- + + +def _pair_for(obs: dict, target_index: int) -> dict: + """Return the pair dict whose target_index matches ``target_index``.""" + for p in obs["pairs"]: + if p["target_index"] == target_index: + return p + msg = "no pair for target_index" + raise AssertionError(msg) + + +def test_observation_enriched_pair_adjacent_melee() -> None: + """Given: an acting melee combatant adjacent to a melee target + When: an Observation is built + Then: movement_cost_ft is 5 (one cell) and target_threatens_me is True.""" + hero = make_combatant("hero") + foe = make_combatant("foe") + hero_state = make_state(hero, "players", (0, 0)) + foe_state = make_state(foe, "monsters", (0, 1)) + engine = make_engine([hero_state, foe_state]) + + obs = _snapshot(engine, hero_state) + pair = _pair_for(obs, 1) + assert pair["movement_cost_ft"] == 5 + assert pair["target_threatens_me"] is True + + +def test_observation_enriched_pair_distant_ranged_target() -> None: + """Given: an acting melee combatant and a distant ranged-only target + When: an Observation is built + Then: movement_cost_ft is None (beyond speed) and target_threatens_me is + False (the target has no melee weapon).""" + hero = make_combatant("hero") + archer = make_combatant("archer", ranged=True) + hero_state = make_state(hero, "players", (0, 0)) + archer_state = make_state(archer, "monsters", (0, 7)) + engine = make_engine([hero_state, archer_state]) + + obs = _snapshot(engine, hero_state) + pair = _pair_for(obs, 1) + # 7 cells = 35 ft > 30 ft speed -> unreachable under speed + assert pair["movement_cost_ft"] is None + assert pair["target_threatens_me"] is False + + +def test_observation_enriched_pair_two_adjacent_enemies() -> None: + """Given: an acting combatant with two adjacent melee enemies + When: an Observation is built + Then: enemies_threatening_me counts both distinct enemies.""" + hero = make_combatant("hero") + foe1 = make_combatant("foe1") + foe2 = make_combatant("foe2") + hero_state = make_state(hero, "players", (0, 0)) + foe1_state = make_state(foe1, "monsters", (0, 1)) + foe2_state = make_state(foe2, "monsters", (1, 0)) + engine = make_engine([hero_state, foe1_state, foe2_state]) + + obs = _snapshot(engine, hero_state) + pair = _pair_for(obs, 1) + assert pair["enemies_threatening_me"] == 2 + + +def test_observation_enriched_pair_large_enemy_counts_once() -> None: + """Given: an acting combatant and one Large (2x2) enemy whose threatened + squares cover multiple occupied cells of the actor + When: an Observation is built + Then: enemies_threatening_me counts the single enemy once.""" + hero = make_combatant("hero") + large = make_combatant("large", size="Large") + hero_state = make_state(hero, "players", (0, 0)) + # Large enemy anchored at (0, 2): occupies (0,2),(0,3),(1,2),(1,3). + # Its 5-ft reach disk covers (0,1) and (1,1) — two of the hero's cells. + large_state = make_state(large, "monsters", (0, 2)) + engine = make_engine([hero_state, large_state]) + + obs = _snapshot(engine, hero_state) + pair = _pair_for(obs, 1) + assert pair["enemies_threatening_me"] == 1 + + +def test_observation_enriched_pair_existing_keys_preserved() -> None: + """Given: an Observation with the enriched pair schema + When: a pair dict is inspected + Then: all 8 pre-existing pair keys remain present and unchanged.""" + hero = make_combatant("hero") + foe = make_combatant("foe") + hero_state = make_state(hero, "players", (0, 0)) + foe_state = make_state(foe, "monsters", (0, 1)) + engine = make_engine([hero_state, foe_state]) + + obs = _snapshot(engine, hero_state) + pair = _pair_for(obs, 1) + for key in ( + "target_index", + "distance_ft", + "weapon_for", + "range_penalty", + "can_charge", + "can_step5_to_attack", + "has_line_of_effect", + "flanking", + ): + assert key in pair + assert set(pair) == { + "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", + }