Files
pf1e-simulator/tests/test_pair_cover.py
T

279 lines
9.6 KiB
Python

"""U6a: per-pair ``cover_bonus`` in the Observation snapshot.
The pair dict now exposes the cover bonus the target would gain against an
attack from the acting combatant, computed with the same
``cover_bonus_cells`` call ``resolve_attack`` applies to AC (terrain cover
+2/+4/+8 via the corner rule, soft cover +4 from interposed creatures on
ranged attacks only). ``weapon_for`` decides the ranged flag; no usable
weapon means ``cover_bonus`` 0.
Covers:
- (a) pillar (``cover: true``) between actor and target -> 4;
- (b) improved-cover terrain (``cover_bonus: 8``) -> 8;
- (c) partial-cover terrain (``cover_bonus: 2``) -> 2;
- (d) creature interposed on a ranged line -> soft cover 4 for a ranged
weapon, 0 for a melee-only actor (melee ignores creatures);
- (e) open field -> 0;
- (f) pairs exist only for the acting combatant, each carrying the key;
- (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),
"C": TerrainType(type="pillar", move_cost=None, cover=True),
"W": TerrainType(type="ruin", move_cost=None, cover_bonus=2),
"I": TerrainType(type="thicket", move_cost=1, cover_bonus=8),
}
def _grid(rows: list[str]) -> Grid:
spec = MapSpec(name="cover-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 _spear(cid: str) -> AttackSpec:
return AttackSpec(
id=f"{cid}-spear",
name="longspear",
kind="melee",
attack_bonus=5,
reach_ft=10,
damage=[DamageComponent(formula=parse_dice("1d8"), types=["piercing"])],
)
def _combatant(cid: str, attack: 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=[attack],
)
def _engine(states: list[CombatantState], rows: list[str]) -> CombatEngine:
return CombatEngine(ScriptedRng([]), _grid(rows), states)
def _row_with(marker: str, col: int) -> str:
return "." * col + marker + "." * (7 - col)
# (a) standard terrain cover: pillar between the archer and the target.
def test_pair_cover_pillar_grants_four() -> None:
"""Given: an archer at (1,0), a pillar at (1,2), a target at (1,4)
When: the Observation is built for the archer
Then: the pair exposes cover_bonus 4 (standard terrain cover)."""
archer = make_state(_combatant("archer", _bow("archer")), "players", (1, 0))
foe = make_state(_combatant("foe", _sword("foe")), "monsters", (1, 4))
rows = _open_rows()
rows[1] = _row_with("C", 2)
engine = _engine([archer, foe], rows)
obs = _snapshot(engine, archer)
pair = _pair_for(obs, 1)
assert pair["weapon_for"] == "short bow"
assert pair["has_line_of_effect"] is True
assert pair["cover_bonus"] == 4
# (b) improved cover terrain.
def test_pair_cover_improved_grants_eight() -> None:
"""Given: an archer shooting through a thicket (cover_bonus: 8)
When: the Observation is built
Then: the pair exposes cover_bonus 8."""
archer = make_state(_combatant("archer", _bow("archer")), "players", (1, 0))
foe = make_state(_combatant("foe", _sword("foe")), "monsters", (1, 4))
rows = _open_rows()
rows[1] = _row_with("I", 2)
engine = _engine([archer, foe], rows)
obs = _snapshot(engine, archer)
assert _pair_for(obs, 1)["cover_bonus"] == 8
# (c) partial cover terrain.
def test_pair_cover_partial_grants_two() -> None:
"""Given: an archer shooting past low ruins (cover_bonus: 2)
When: the Observation is built
Then: the pair exposes cover_bonus 2."""
archer = make_state(_combatant("archer", _bow("archer")), "players", (1, 0))
foe = make_state(_combatant("foe", _sword("foe")), "monsters", (1, 4))
rows = _open_rows()
rows[1] = _row_with("W", 2)
engine = _engine([archer, foe], rows)
obs = _snapshot(engine, archer)
assert _pair_for(obs, 1)["cover_bonus"] == 2
# (d) soft cover from an interposed creature.
def test_pair_cover_soft_creature_grants_four_for_ranged() -> None:
"""Given: an archer at (1,0), a creature at (1,2), a target at (1,4)
When: the Observation is built for the archer (ranged weapon)
Then: the interposed creature grants soft cover 4."""
archer = make_state(_combatant("archer", _bow("archer")), "players", (1, 0))
foe = make_state(_combatant("foe", _sword("foe")), "monsters", (1, 4))
bystander = make_state(_combatant("guard", _sword("guard")), "monsters", (1, 2))
engine = _engine([archer, foe, bystander], _open_rows())
obs = _snapshot(engine, archer)
assert _pair_for(obs, 1)["cover_bonus"] == 4
def test_pair_cover_soft_creature_ignored_for_melee() -> None:
"""Given: a reach spear user at (1,0), a creature at (1,1), a target at (1,2)
When: the Observation is built for the spear user (melee attack)
Then: melee ignores creatures — cover_bonus 0."""
striker = make_state(_combatant("spear", _spear("spear")), "players", (1, 0))
foe = make_state(_combatant("foe", _sword("foe")), "monsters", (1, 2))
bystander = make_state(_combatant("guard", _sword("guard")), "monsters", (1, 1))
engine = _engine([striker, foe, bystander], _open_rows())
obs = _snapshot(engine, striker)
pair = _pair_for(obs, 1)
assert pair["weapon_for"] == "longspear"
assert pair["distance_ft"] == 10
assert pair["cover_bonus"] == 0
# (e) open field.
def test_pair_cover_open_field_is_zero() -> None:
"""Given: an archer and a target with nothing between them
When: the Observation is built
Then: cover_bonus is 0."""
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)
assert _pair_for(obs, 1)["cover_bonus"] == 0
# (f) scope: pairs only exist for the acting combatant, each with the key.
def test_pair_cover_only_actors_pairs_present() -> None:
"""Given: three combatants, the observation built for the bystander
When: the pairs are inspected
Then: there is one pair per other combatant (none for the actor itself)
and every pair carries cover_bonus; the archer's own pairs are not
exposed from the bystander's point of view."""
archer = make_state(_combatant("archer", _bow("archer")), "players", (1, 0))
foe = make_state(_combatant("foe", _sword("foe")), "monsters", (1, 4))
bystander = make_state(_combatant("guard", _sword("guard")), "monsters", (1, 2))
engine = _engine([archer, foe, bystander], _open_rows())
obs = _snapshot(engine, bystander)
indices = [p["target_index"] for p in obs["pairs"]]
assert indices == [0, 1]
for pair in obs["pairs"]:
assert "cover_bonus" in pair
# From the bystander's seat the archer is 10 ft left and the foe 10 ft
# right; nothing interposes on either line (the bystander itself is
# excluded from the occupied set of its own pairs).
assert _pair_for(obs, 0)["cover_bonus"] == 0
assert _pair_for(obs, 1)["cover_bonus"] == 0
def test_pair_cover_zero_when_no_usable_weapon() -> None:
"""Given: a melee-only actor facing a target 20 ft away behind a pillar
When: the Observation is built
Then: weapon_for is None and cover_bonus falls back to 0."""
striker = make_state(_combatant("swordsman", _sword("swordsman")), "players", (1, 0))
foe = make_state(_combatant("foe", _bow("foe")), "monsters", (1, 4))
rows = _open_rows()
rows[1] = _row_with("C", 2)
engine = _engine([striker, foe], rows)
obs = _snapshot(engine, striker)
pair = _pair_for(obs, 1)
assert pair["weapon_for"] is None
assert pair["cover_bonus"] == 0
# (g) JSON serializability of the extended snapshot.
def test_pair_cover_snapshot_is_json_serializable() -> None:
"""Given: a snapshot whose pairs carry cover_bonus
When: to_dict() is dumped to JSON
Then: serialization round-trips and keeps the new key."""
archer = make_state(_combatant("archer", _bow("archer")), "players", (1, 0))
foe = make_state(_combatant("foe", _sword("foe")), "monsters", (1, 4))
rows = _open_rows()
rows[1] = _row_with("C", 2)
engine = _engine([archer, foe], rows)
obs = _snapshot(engine, archer)
data = json.loads(json.dumps(obs))
assert _pair_for(data, 1)["cover_bonus"] == 4