feat(observation): expose per-pair cover bonus

This commit is contained in:
2026-09-10 13:46:05 +02:00
parent ed04052244
commit 8f3bd0d0e0
4 changed files with 306 additions and 6 deletions
+11 -6
View File
@@ -656,7 +656,11 @@ Règles modélisées :
jets d'attaque et de confirmation jusqu'au prochain tour). jets d'attaque et de confirmation jusqu'au prochain tour).
- Par paire combattant actif × cible : target_index, distance_ft, weapon_for, - Par paire combattant actif × cible : target_index, distance_ft, weapon_for,
range_penalty, can_charge, can_step5_to_attack, has_line_of_effect, range_penalty, can_charge, can_step5_to_attack, has_line_of_effect,
flanking, movement_cost_ft, target_threatens_me, enemies_threatening_me. flanking, movement_cost_ft, target_threatens_me, enemies_threatening_me,
cover_bonus (int — bonus de couvert que la cible gagne contre une attaque
de l'acteur, calculé comme dans `resolve_attack` : terrain +2/+4/+8 via la
règle des coins, couvert mou +4 des créatures interposées pour les seules
attaques à distance ; 0 si `weapon_for` est None).
- Top-level : round, acting_combatant, combatants, pairs, terrain, elevation, - Top-level : round, acting_combatant, combatants, pairs, terrain, elevation,
threatened (grille bool des cases menacées par les ennemis), threatened (grille bool des cases menacées par les ennemis),
initiative_order (U2 : tuple d'ids dans l'ordre d'initiative du round initiative_order (U2 : tuple d'ids dans l'ordre d'initiative du round
@@ -673,11 +677,12 @@ Règles modélisées :
pas appeler `engine.*`, elle lit leurs résultats. Mapping des clés pas appeler `engine.*`, elle lit leurs résultats. Mapping des clés
`resources_left` (vérifié par grep sur `spend_resource`/`resources_left[` : `resources_left` (vérifié par grep sur `spend_resource`/`resources_left[` :
`channel_energy`, `hexes`, `grit`, `arcane_pool`, `bardic_rounds`, plus `channel_energy`, `hexes`, `grit`, `arcane_pool`, `bardic_rounds`, plus
`spell_slot_{level}` ajouté par le snapshot). Limites documentées : pas de `spell_slot_{level}` ajouté par le snapshot). Limites documentées : pas de
grille `reachable_ft` budget=speed (délibérément rejetée — grille `reachable_ft` budget=speed (délibérément rejetée —
`movement_cost_ft` par paire + `terrain_semantics` + `speed_land_ft` `movement_cost_ft` par paire + `terrain_semantics` + `speed_land_ft`
suffisent), pas de couvert par paire (déductible via `has_line_of_effect` + suffisent). Le couvert par paire est exposé via la clé `cover_bonus`
`terrain_semantics` + positions). Les API de décision exposées (items L1-L8) (calcul identique à `resolve_attack`, couvert mou des créatures inclus).
Les API de décision exposées (items L1-L8)
s'y retrouvent : `direction`/`trick_condition`/`weapon_id` sur `Action`, s'y retrouvent : `direction`/`trick_condition`/`weapon_id` sur `Action`,
`can_charge` → `ChargeResult`, kinds `retrieve_weapon`/`repair_weapon`. `can_charge` → `ChargeResult`, kinds `retrieve_weapon`/`repair_weapon`.
Le kind `activate_power_attack` (action libre) active le don Power Attack Le kind `activate_power_attack` (action libre) active le don Power Attack
+16
View File
@@ -1025,6 +1025,21 @@ class CombatEngine:
for cell in occupied: for cell in occupied:
for enemy in self.enemies_threatening(state, cell): for enemy in self.enemies_threatening(state, cell):
threatening_enemies.add(enemy.combatant.id) threatening_enemies.add(enemy.combatant.id)
cover_bonus = 0
if weapon is not None:
others = frozenset(
cell
for s in self._states
if s.active and s is not state and s is not target
for cell in s.occupied_cells()
)
cover_bonus = cover_bonus_cells(
self._grid,
state.occupied_cells(),
target.occupied_cells(),
ranged=self._is_ranged_attack(weapon),
occupied=others,
)
return { return {
"target_index": self._states.index(target), "target_index": self._states.index(target),
"distance_ft": distance_ft, "distance_ft": distance_ft,
@@ -1039,6 +1054,7 @@ class CombatEngine:
"movement_cost_ft": self.movement_cost_ft(state, target), "movement_cost_ft": self.movement_cost_ft(state, target),
"target_threatens_me": target_threatens_me, "target_threatens_me": target_threatens_me,
"enemies_threatening_me": len(threatening_enemies), "enemies_threatening_me": len(threatening_enemies),
"cover_bonus": cover_bonus,
} }
def _take_turn(self, state: CombatantState) -> bool: def _take_turn(self, state: CombatantState) -> bool:
+1
View File
@@ -684,6 +684,7 @@ def test_observation_enriched_pair_existing_keys_preserved() -> None:
"movement_cost_ft", "movement_cost_ft",
"target_threatens_me", "target_threatens_me",
"enemies_threatening_me", "enemies_threatening_me",
"cover_bonus",
} }
+278
View File
@@ -0,0 +1,278 @@
"""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