feat(combat): expose per-pair movement and threat info in observation

This commit is contained in:
2026-09-06 14:01:34 +02:00
parent 90c71bbeb0
commit cfeb28e336
2 changed files with 157 additions and 8 deletions
+13
View File
@@ -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:
+137 -1
View File
@@ -48,8 +48,23 @@ 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."""
"""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",
@@ -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",
}