279 lines
9.6 KiB
Python
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
|