feat(observation): expose attack failure reasons
This commit is contained in:
@@ -0,0 +1,253 @@
|
||||
"""U6b: per-pair attack/charge failure reasons in the Observation snapshot.
|
||||
|
||||
The pair dict now exposes WHY an attack or a charge is unavailable, not just
|
||||
that it is: ``weapon_reason`` is None when ``weapon_for`` selected a weapon,
|
||||
otherwise the blocking cause (``no_weapon`` / ``no_line_of_effect`` /
|
||||
``out_of_range``, drawn from the U1 ``ACTION_RESULT_REASONS`` vocabulary);
|
||||
``charge_reason`` is None when ``can_charge`` is True, otherwise the
|
||||
``ChargeResult.reason`` verbatim (``no_line_of_effect`` / ``no_charge_path``)
|
||||
or ``no_weapon`` when the actor has no usable melee weapon at all.
|
||||
|
||||
Covers:
|
||||
- (a) unarmed actor -> ``weapon_reason`` / ``charge_reason`` ``no_weapon``;
|
||||
- (b) wall column blocking the line -> ``no_line_of_effect``;
|
||||
- (c) melee-only actor facing a distant target -> ``out_of_range``;
|
||||
- (d) valid adjacent target -> ``weapon_reason`` None;
|
||||
- (e) creatures interposed on every straight charge line -> the engine's
|
||||
``no_charge_path``;
|
||||
- (f) valid charge line -> ``charge_reason`` None; ranged-only actor ->
|
||||
``no_weapon``;
|
||||
- (g) ``to_dict()`` stays JSON-serializable.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from pf1e_simulator.combat import CombatantState, CombatEngine
|
||||
from pf1e_simulator.dice import parse_dice
|
||||
from pf1e_simulator.grid import Grid
|
||||
from pf1e_simulator.map import MapSpec, TerrainType
|
||||
from pf1e_simulator.models import (
|
||||
AbilityScores,
|
||||
ACProfile,
|
||||
AttackSpec,
|
||||
Combatant,
|
||||
DamageComponent,
|
||||
Saves,
|
||||
)
|
||||
from pf1e_simulator.rng import ScriptedRng
|
||||
from tests.test_observation_enriched import _pair_for, _snapshot, make_state
|
||||
|
||||
_LEGEND = {
|
||||
".": TerrainType(type="floor", move_cost=1),
|
||||
"#": TerrainType(type="wall", move_cost=None, blocks_los=True),
|
||||
}
|
||||
|
||||
|
||||
def _grid(rows: list[str]) -> Grid:
|
||||
spec = MapSpec(name="reasons-test", terrain=tuple(rows), legend=_LEGEND)
|
||||
return Grid.from_spec(spec)
|
||||
|
||||
|
||||
def _open_rows() -> list[str]:
|
||||
return ["........"] * 8
|
||||
|
||||
|
||||
def _bow(cid: str) -> AttackSpec:
|
||||
return AttackSpec(
|
||||
id=f"{cid}-bow",
|
||||
name="short bow",
|
||||
kind="ranged",
|
||||
attack_bonus=5,
|
||||
damage=[DamageComponent(formula=parse_dice("1d6"), types=["piercing"])],
|
||||
range_increment_ft=60,
|
||||
)
|
||||
|
||||
|
||||
def _sword(cid: str) -> AttackSpec:
|
||||
return AttackSpec(
|
||||
id=f"{cid}-sword",
|
||||
name="short sword",
|
||||
kind="melee",
|
||||
attack_bonus=5,
|
||||
damage=[DamageComponent(formula=parse_dice("1d6"), types=["slashing"])],
|
||||
)
|
||||
|
||||
|
||||
def _combatant(cid: str, attacks: list[AttackSpec]) -> Combatant:
|
||||
return Combatant(
|
||||
id=cid,
|
||||
name=cid,
|
||||
level=1,
|
||||
abilities=AbilityScores(
|
||||
str_score=10,
|
||||
dex_score=10,
|
||||
con_score=10,
|
||||
int_score=10,
|
||||
wis_score=10,
|
||||
cha_score=10,
|
||||
),
|
||||
ac=ACProfile(total=10, touch=10, flat_footed=10),
|
||||
bab=1,
|
||||
initiative_mod=0,
|
||||
speed_land_ft=30,
|
||||
hp_max=100,
|
||||
saves=Saves(fort=0, ref=0, will=0),
|
||||
attacks=attacks,
|
||||
)
|
||||
|
||||
|
||||
def _engine(states: list[CombatantState], rows: list[str]) -> CombatEngine:
|
||||
return CombatEngine(ScriptedRng([]), _grid(rows), states)
|
||||
|
||||
|
||||
# (a) no usable weapon at all.
|
||||
|
||||
|
||||
def test_weapon_reason_no_weapon_when_actor_unarmed() -> None:
|
||||
"""Given: an actor with no attacks at all facing an adjacent foe
|
||||
When: the Observation is built for the actor
|
||||
Then: weapon_for is None with weapon_reason no_weapon, and the charge
|
||||
helper's no-melee-weapon None maps to charge_reason no_weapon too."""
|
||||
actor = make_state(_combatant("unarmed", []), "players", (1, 0))
|
||||
foe = make_state(_combatant("foe", [_sword("foe")]), "monsters", (1, 1))
|
||||
engine = _engine([actor, foe], _open_rows())
|
||||
|
||||
obs = _snapshot(engine, actor)
|
||||
pair = _pair_for(obs, 1)
|
||||
assert pair["weapon_for"] is None
|
||||
assert pair["weapon_reason"] == "no_weapon"
|
||||
assert pair["can_charge"] is False
|
||||
assert pair["charge_reason"] == "no_weapon"
|
||||
|
||||
|
||||
# (b) line of effect blocked by a wall column.
|
||||
|
||||
|
||||
def test_weapon_reason_no_line_of_effect_behind_wall_column() -> None:
|
||||
"""Given: an archer at (4, 0) and a foe at (4, 4) behind a full wall column
|
||||
When: the Observation is built for the archer
|
||||
Then: weapon_reason and charge_reason are both no_line_of_effect."""
|
||||
archer = make_state(_combatant("archer", [_bow("archer")]), "players", (4, 0))
|
||||
foe = make_state(_combatant("foe", [_sword("foe")]), "monsters", (4, 4))
|
||||
rows = ["..#....."] * 8
|
||||
engine = _engine([archer, foe], rows)
|
||||
|
||||
obs = _snapshot(engine, archer)
|
||||
pair = _pair_for(obs, 1)
|
||||
assert pair["has_line_of_effect"] is False
|
||||
assert pair["weapon_for"] is None
|
||||
assert pair["weapon_reason"] == "no_line_of_effect"
|
||||
assert pair["can_charge"] is False
|
||||
assert pair["charge_reason"] == "no_line_of_effect"
|
||||
|
||||
|
||||
# (c) weapon usable but the target out of reach.
|
||||
|
||||
|
||||
def test_weapon_reason_out_of_range_with_clear_line() -> None:
|
||||
"""Given: a melee-only actor at (1, 0) and a foe at (1, 4) on open ground
|
||||
When: the Observation is built for the actor
|
||||
Then: weapon_reason is out_of_range (weapon exists, LoE is clear)."""
|
||||
striker = make_state(_combatant("swordsman", [_sword("swordsman")]), "players", (1, 0))
|
||||
foe = make_state(_combatant("foe", [_sword("foe")]), "monsters", (1, 4))
|
||||
engine = _engine([striker, foe], _open_rows())
|
||||
|
||||
obs = _snapshot(engine, striker)
|
||||
pair = _pair_for(obs, 1)
|
||||
assert pair["has_line_of_effect"] is True
|
||||
assert pair["weapon_for"] is None
|
||||
assert pair["weapon_reason"] == "out_of_range"
|
||||
|
||||
|
||||
# (d) a valid attack carries no reason.
|
||||
|
||||
|
||||
def test_weapon_reason_none_for_valid_adjacent_attack() -> None:
|
||||
"""Given: an armed actor adjacent to a foe on open ground
|
||||
When: the Observation is built
|
||||
Then: weapon_for names the weapon and weapon_reason is None."""
|
||||
actor = make_state(_combatant("hero", [_sword("hero")]), "players", (1, 0))
|
||||
foe = make_state(_combatant("foe", [_sword("foe")]), "monsters", (1, 1))
|
||||
engine = _engine([actor, foe], _open_rows())
|
||||
|
||||
obs = _snapshot(engine, actor)
|
||||
pair = _pair_for(obs, 1)
|
||||
assert pair["weapon_for"] == "short sword"
|
||||
assert pair["weapon_reason"] is None
|
||||
|
||||
|
||||
# (e) charge blocked by interposed creatures despite a clear line of effect.
|
||||
|
||||
|
||||
def test_charge_reason_no_charge_path_when_creatures_block_the_line() -> None:
|
||||
"""Given: an actor at (1, 0), a foe at (1, 3), creatures at (1, 1), (0, 2)
|
||||
and (2, 2) — every straight Bresenham line to any attackable anchor passes
|
||||
through one of them, while terrain LoE stays clear
|
||||
When: the Observation is built for the actor
|
||||
Then: can_charge is False and charge_reason is the engine's no_charge_path."""
|
||||
actor = make_state(_combatant("hero", [_sword("hero")]), "players", (1, 0))
|
||||
foe = make_state(_combatant("foe", [_sword("foe")]), "monsters", (1, 3))
|
||||
guard_a = make_state(_combatant("guard-a", [_sword("guard-a")]), "monsters", (1, 1))
|
||||
guard_b = make_state(_combatant("guard-b", [_sword("guard-b")]), "monsters", (0, 2))
|
||||
guard_c = make_state(_combatant("guard-c", [_sword("guard-c")]), "monsters", (2, 2))
|
||||
engine = _engine([actor, foe, guard_a, guard_b, guard_c], _open_rows())
|
||||
|
||||
obs = _snapshot(engine, actor)
|
||||
pair = _pair_for(obs, 1)
|
||||
assert pair["has_line_of_effect"] is True
|
||||
assert pair["can_charge"] is False
|
||||
assert pair["charge_reason"] == "no_charge_path"
|
||||
|
||||
|
||||
# (f) valid charge, and the ranged-only charge refusal.
|
||||
|
||||
|
||||
def test_charge_reason_none_for_valid_charge() -> None:
|
||||
"""Given: an armed actor at (1, 0) and a foe at (1, 4) on an open straight line
|
||||
When: the Observation is built
|
||||
Then: can_charge is True and charge_reason is None."""
|
||||
actor = make_state(_combatant("hero", [_sword("hero")]), "players", (1, 0))
|
||||
foe = make_state(_combatant("foe", [_sword("foe")]), "monsters", (1, 4))
|
||||
engine = _engine([actor, foe], _open_rows())
|
||||
|
||||
obs = _snapshot(engine, actor)
|
||||
pair = _pair_for(obs, 1)
|
||||
assert pair["can_charge"] is True
|
||||
assert pair["charge_reason"] is None
|
||||
|
||||
|
||||
def test_charge_reason_no_weapon_for_ranged_only_actor() -> None:
|
||||
"""Given: a bow-only actor facing a foe 20 ft away on open ground
|
||||
When: the Observation is built
|
||||
Then: the attack is valid (weapon_reason None) but the charge helper
|
||||
returns None (no melee weapon) -> charge_reason no_weapon."""
|
||||
archer = make_state(_combatant("archer", [_bow("archer")]), "players", (1, 0))
|
||||
foe = make_state(_combatant("foe", [_sword("foe")]), "monsters", (1, 4))
|
||||
engine = _engine([archer, foe], _open_rows())
|
||||
|
||||
obs = _snapshot(engine, archer)
|
||||
pair = _pair_for(obs, 1)
|
||||
assert pair["weapon_for"] == "short bow"
|
||||
assert pair["weapon_reason"] is None
|
||||
assert pair["can_charge"] is False
|
||||
assert pair["charge_reason"] == "no_weapon"
|
||||
|
||||
|
||||
# (g) JSON serializability of the extended snapshot.
|
||||
|
||||
|
||||
def test_pair_failure_reasons_snapshot_is_json_serializable() -> None:
|
||||
"""Given: a snapshot whose pairs carry weapon_reason and charge_reason
|
||||
When: to_dict() is dumped to JSON
|
||||
Then: serialization round-trips and keeps both new keys."""
|
||||
actor = make_state(_combatant("swordsman", [_sword("swordsman")]), "players", (1, 0))
|
||||
foe = make_state(_combatant("foe", [_sword("foe")]), "monsters", (1, 4))
|
||||
engine = _engine([actor, foe], _open_rows())
|
||||
|
||||
obs = _snapshot(engine, actor)
|
||||
data = json.loads(json.dumps(obs))
|
||||
pair = _pair_for(data, 1)
|
||||
assert pair["weapon_reason"] == "out_of_range"
|
||||
assert pair["charge_reason"] is None
|
||||
Reference in New Issue
Block a user