Files
pf1e-simulator/tests/test_pair_cover.py
T
ctan 4d89305881 fix(rules): distinguish natural reach from reach weapons
PF1e CRB 'Big and Little Creatures in Combat': a creature's natural
reach is a disk (a Large creature still threatens and strikes adjacent
squares), while a reach weapon doubles the wielder's natural reach but
'can't strike at their natural reach or less' (a ring whose dead zone is
the natural reach). The engine keyed the ring off reach_ft > 5, so
reach-10 natural attacks (giant belostomatid bite/claw, mounted
hooves) wrongly got a 5-ft dead zone.

AttackSpec gains reach_weapon (parsed from the combatant JSON); the
disk-vs-ring rule is centralized in _melee_reach_zone_cells and applied
consistently to weapon selection, melee usability, threatened squares
and charge end placement. Esha's Lance is the only reach weapon in the
shipped data; Large natural attackers keep the default (disk).

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-11 22:06:46 +02:00

280 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,
reach_weapon=True,
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