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)
|
charge = self.can_charge(state, target)
|
||||||
can_charge = charge is not None and charge.weapon is not None
|
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 {
|
return {
|
||||||
"target_index": self._states.index(target),
|
"target_index": self._states.index(target),
|
||||||
"distance_ft": distance_ft,
|
"distance_ft": distance_ft,
|
||||||
@@ -689,6 +699,9 @@ class CombatEngine:
|
|||||||
self._grid, state.occupied_cells(), target.occupied_cells()
|
self._grid, state.occupied_cells(), target.occupied_cells()
|
||||||
),
|
),
|
||||||
"flanking": self._flanking_bonus(state, target) > 0,
|
"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:
|
def _take_turn(self, state: CombatantState) -> bool:
|
||||||
|
|||||||
@@ -48,8 +48,23 @@ def make_combatant(
|
|||||||
speed_fly: int | None = None,
|
speed_fly: int | None = None,
|
||||||
features: list[AbilitySpec] | None = None,
|
features: list[AbilitySpec] | None = None,
|
||||||
spells: list[str] | None = None,
|
spells: list[str] | None = None,
|
||||||
|
ranged: bool = False,
|
||||||
) -> Combatant:
|
) -> 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(
|
attack = AttackSpec(
|
||||||
id=f"{cid}-w",
|
id=f"{cid}-w",
|
||||||
name="short sword",
|
name="short sword",
|
||||||
@@ -539,3 +554,124 @@ def test_observation_enriched_spells_features_json_serializable() -> None:
|
|||||||
assert c["features"] == ("Channel Energy",)
|
assert c["features"] == ("Channel Energy",)
|
||||||
assert c["spells"][0]["name"] == "Fireball"
|
assert c["spells"][0]["name"] == "Fireball"
|
||||||
assert c["spells"][1]["name"] == "Mystery Spell"
|
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