diff --git a/README.md b/README.md index 4932665..1641638 100644 --- a/README.md +++ b/README.md @@ -656,7 +656,11 @@ Règles modélisées : jets d'attaque et de confirmation jusqu'au prochain tour). - Par paire combattant actif × cible : target_index, distance_ft, weapon_for, 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, threatened (grille bool des cases menacées par les ennemis), 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 `resources_left` (vérifié par grep sur `spend_resource`/`resources_left[` : `channel_energy`, `hexes`, `grit`, `arcane_pool`, `bardic_rounds`, plus - `spell_slot_{level}` ajouté par le snapshot). Limites documentées : pas de - grille `reachable_ft` budget=speed (délibérément rejetée — - `movement_cost_ft` par paire + `terrain_semantics` + `speed_land_ft` - suffisent), pas de couvert par paire (déductible via `has_line_of_effect` + - `terrain_semantics` + positions). Les API de décision exposées (items L1-L8) + `spell_slot_{level}` ajouté par le snapshot). Limites documentées : pas de + grille `reachable_ft` budget=speed (délibérément rejetée — + `movement_cost_ft` par paire + `terrain_semantics` + `speed_land_ft` + suffisent). Le couvert par paire est exposé via la clé `cover_bonus` + (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`, `can_charge` → `ChargeResult`, kinds `retrieve_weapon`/`repair_weapon`. Le kind `activate_power_attack` (action libre) active le don Power Attack diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index 0520d43..4ed4b84 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -1025,6 +1025,21 @@ class CombatEngine: for cell in occupied: for enemy in self.enemies_threatening(state, cell): 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 { "target_index": self._states.index(target), "distance_ft": distance_ft, @@ -1039,6 +1054,7 @@ class CombatEngine: "movement_cost_ft": self.movement_cost_ft(state, target), "target_threatens_me": target_threatens_me, "enemies_threatening_me": len(threatening_enemies), + "cover_bonus": cover_bonus, } def _take_turn(self, state: CombatantState) -> bool: diff --git a/tests/test_observation_enriched.py b/tests/test_observation_enriched.py index 1b8bd59..a000f50 100644 --- a/tests/test_observation_enriched.py +++ b/tests/test_observation_enriched.py @@ -684,6 +684,7 @@ def test_observation_enriched_pair_existing_keys_preserved() -> None: "movement_cost_ft", "target_threatens_me", "enemies_threatening_me", + "cover_bonus", } diff --git a/tests/test_pair_cover.py b/tests/test_pair_cover.py new file mode 100644 index 0000000..2d8e5e3 --- /dev/null +++ b/tests/test_pair_cover.py @@ -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