4d89305881
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>
280 lines
9.6 KiB
Python
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
|