feat(combat): expose per-pair movement and threat info in observation
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user