feat(observation): expose per-pair cover bonus
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user