diff --git a/README.md b/README.md index aee13b2..ecf391e 100644 --- a/README.md +++ b/README.md @@ -164,7 +164,8 @@ legend: # un caractère = un type de terrain ".": { type: floor, move_cost: 1 } "T": { type: rubble, move_cost: 2 } # terrain difficile "~": { type: water, move_cost: 2 } - "C": { type: pillar, move_cost: null, cover: true } # bloque le pas, couvert + "C": { type: pillar, move_cost: null, cover: true } # bloque le pas, couvert (+4) + "W": { type: ruin, move_cost: null, cover_bonus: 2 } # couvert partiel (+2) ; 4 = standard, 8 = amélioré elevation: | # optionnel : chiffres = hauteur (appliqué : coût de pas + high ground) .................... @@ -189,7 +190,10 @@ deployment: # obligatoire si zones : nom de camp -> lettre de zone Valeur du `move_cost` : entier > 0 pour un terrain praticable, `null` pour un terrain infranchissable (mur, pilier). `blocks_los` et `cover` sont appliqués par la résolution des attaques (module `los.py`, règle des coins) : pas de -ligne d'effet ⇒ l'attaque est impossible ; couvert ⇒ bonus de +4 CA. +ligne d'effet ⇒ l'attaque est impossible ; couvert ⇒ bonus de CA. Le champ +optionnel `cover_bonus` (`2`, `4` ou `8`) encode le niveau de couvert PF1e +(partiel +2, standard +4, amélioré +8) ; `cover: true` sans `cover_bonus` +vaut couvert standard (+4), idem pour un terrain `blocks_los`. ### Monstre (JSON) @@ -331,7 +335,8 @@ Règles modélisées : - Ligne d'effet : une cible entièrement derrière un terrain `blocks_los` ne peut pas être attaquée — la politique se déplace jusqu'à gagner une ligne de visée. -- Couvert (règle des coins) : la cible gagne +4 CA sur la touche et la +- Couvert (règle des coins) : la cible gagne le bonus de couvert du terrain + (+2 partiel, +4 standard, +8 amélioré via `cover_bonus`) sur la touche et la confirmation de critique ; couvert mou : une créature active entre l'attaquant et la cible octroie aussi +4 CA aux attaques à distance (les attaques de mêlée ignorent les créatures). @@ -447,9 +452,9 @@ Règles modélisées : cases) ; la pénalité tombe à −2 si la cible ou l'adversaire menaçant est à ≥ 10 ft (2 cases) de l'attaquant. Le don « Precise Shot » annule la pénalité. Les attaques de mêlée ne sont pas concernées. -- Couvert : seul le couvert standard (+4, terrain et couvert mou des créatures) - est modélisé ; le couvert partiel (+2) et le couvert amélioré (+8) ne sont PAS - modélisés. +- Couvert : les trois niveaux PF1e sont modélisés via `cover_bonus` sur le + terrain — partiel +2, standard +4 (ou `cover: true`), amélioré +8 ; le + couvert mou des créatures reste +4 (couvert standard). - Invocation : les créatures invoquées agissent (calées sur la case d'initiative de leur conjurateur via `_build_round_order`) puis expirent après leur `summon_duration` (`_expire_summons` décrémente puis retire). @@ -549,7 +554,7 @@ Règles modélisées : - Top-level : round, acting_combatant, combatants, pairs, terrain, elevation, threatened (grille bool des cases menacées par les ennemis), terrain_semantics (un dict par type de terrain : type, move_cost, - blocks_los, cover), condition_definitions (C2 : tuple trié de (nom, + blocks_los, cover, cover_bonus), condition_definitions (C2 : tuple trié de (nom, {mods, flags, speed_mult, miss_chance}) — définitions complètes des 23 conditions de CONDITIONS_BY_NAME pour que la LLM interprète les drapeaux, les modificateurs et les chances de rater (concealment)). @@ -588,8 +593,8 @@ la lecture mais non appliquée dans la résolution. règle des coins (`diagonal_allowed`), Dijkstra `reachable` avec ou sans budget. - `los.py` — ligne d'effet et couvert par la règle des coins : gate de ligne - d'effet dans `weapon_for`, bonus de couvert +4 CA (terrain et couvert mou - des créatures) dans `resolve_attack`. + d'effet dans `weapon_for`, bonus de couvert par niveau (+2/+4/+8 terrain via + `cover_bonus`, +4 couvert mou des créatures) dans `resolve_attack`. - `combat.py` — `CombatEngine` déterministe : initiative, actions, résolution des attaques (couvert, ligne d'effet, pénalités de portée, CA de contact pour les attaques touch et firearms), tailles et allonges (occupation diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index ae2a119..19608ac 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -115,7 +115,7 @@ if TYPE_CHECKING: TrickCondition = Literal["dazzled", "blinded", "deafened", "entangled", "nauseated"] from pf1e_simulator.los import ( - has_cover_cells, + cover_bonus_cells, has_line_of_effect_cells, ) @@ -123,7 +123,6 @@ _SQUARE_FT = 5 # Phase 0 maps use 5-ft squares _DEATH_FLOOR = -10 # PF1e: dead when hp < -10 or -CON, whichever is lower _NATURAL_ONE = 1 # PF1e: natural 1 always misses _NATURAL_TWENTY = 20 # PF1e: natural 20 always hits and threatens -_COVER_AC_BONUS = 4 # PF1e: partial cover grants +4 AC _RANGE_INCREMENT_PENALTY = 2 # PF1e: -2 per full range increment beyond the first _MAX_RANGE_INCREMENTS = 10 # PF1e: projectile weapons shoot up to 10 increments _MAX_TOUCH_RANGE_INCREMENTS = 5 # PF1e: thrown/firearm touch weapons shoot up to 5 increments @@ -720,6 +719,7 @@ class CombatEngine: "move_cost": cell.move_cost, "blocks_los": cell.blocks_los, "cover": cell.cover, + "cover_bonus": cell.cover_bonus, } for cell in (seen[name] for name in sorted(seen)) ) @@ -1418,14 +1418,13 @@ class CombatEngine: if s.active and s is not attacker and s is not target for cell in s.occupied_cells() ) - if has_cover_cells( + ac += cover_bonus_cells( self._grid, attacker.occupied_cells(), target.occupied_cells(), ranged=self._is_ranged_attack(weapon), occupied=occupied, - ): - ac += _COVER_AC_BONUS + ) hit = roll == _NATURAL_TWENTY or (roll != _NATURAL_ONE and total >= ac) damage = 0 concealment_miss = False diff --git a/src/pf1e_simulator/los.py b/src/pf1e_simulator/los.py index ad21f18..59320e5 100644 --- a/src/pf1e_simulator/los.py +++ b/src/pf1e_simulator/los.py @@ -21,6 +21,8 @@ if TYPE_CHECKING: Pt = tuple[int, int] # lattice point (x, y) = (col, row) _Rat = tuple[int, int] # exact rational as (numerator, denominator), den > 0 +_STANDARD_COVER_BONUS = 4 # PF1e: standard cover, and soft cover from a creature + def _corners(pos: Pos) -> tuple[Pt, Pt, Pt, Pt]: row, col = pos @@ -75,11 +77,13 @@ class _LineGrid: exclude: frozenset[Pos], blocking: Callable[[TerrainType], bool], occupied: frozenset[Pos] = frozenset(), + bonus_of: Callable[[TerrainType], int] | None = None, ) -> None: self._grid = grid self._exclude = exclude self._blocking = blocking self._occupied = occupied + self._bonus_of = bonus_of def blocked(self, p1: Pt, p2: Pt) -> bool: min_x = min(p1[0], p2[0]) @@ -97,9 +101,49 @@ class _LineGrid: return True return False + def bonus(self, p1: Pt, p2: Pt) -> int: + """Highest cover bonus among the blocking cells the segment p1->p2 hits. + + 0 when the segment crosses no blocking cell (or when this view was + built without a bonus function, i.e. for plain line-of-effect). + Occupied cells always grant soft cover (+4, CRB). + """ + if self._bonus_of is None: + return 0 + min_x = min(p1[0], p2[0]) + max_x = max(p1[0], p2[0]) + min_y = min(p1[1], p2[1]) + max_y = max(p1[1], p2[1]) + best = 0 + for col in range(min_x - 1, max_x + 1): + for row in range(min_y - 1, max_y + 1): + pos = (row, col) + if pos in self._exclude or not self._grid.in_bounds(pos): + continue + terrain = self._grid.terrain_at(pos) + is_occupied = pos in self._occupied + if not (is_occupied or self._blocking(terrain)): + continue + if not _segment_hits_cell(p1, p2, row, col): + continue + value = self._bonus_of(terrain) + if is_occupied: + value = max(value, _STANDARD_COVER_BONUS) + best = max(best, value) + return best + + +def terrain_cover_bonus(terrain: TerrainType) -> int: + """Cover bonus a terrain square grants: its level, else legacy +4 for cover.""" + if terrain.cover_bonus > 0: + return terrain.cover_bonus + if terrain.cover or terrain.blocks_los: + return _STANDARD_COVER_BONUS + return 0 + def _grants_cover(terrain: TerrainType) -> bool: - return terrain.cover or terrain.blocks_los + return terrain_cover_bonus(terrain) > 0 def has_line_of_effect(grid: Grid, a: Pos, b: Pos) -> bool: @@ -179,3 +223,58 @@ def has_cover_cells( for cell_a in attacker_cells for corner_a in _corners(cell_a) ) + + +def cover_bonus( + grid: Grid, + attacker: Pos, + target: Pos, + *, + ranged: bool, + occupied: frozenset[Pos] = frozenset(), +) -> int: + """Effective cover bonus (0/2/4/8) for an attack between two single cells.""" + return cover_bonus_cells( + grid, + frozenset({attacker}), + frozenset({target}), + ranged=ranged, + occupied=occupied, + ) + + +def cover_bonus_cells( + grid: Grid, + attacker_cells: frozenset[Pos], + target_cells: frozenset[Pos], + *, + ranged: bool, + occupied: frozenset[Pos] = frozenset(), +) -> int: + """Effective cover bonus (0/2/4/8) for an attack between two sets of cells. + + 0 when there is no cover (see has_cover_cells for the corner rule); + otherwise the highest cover level among the cells blocking the + attacker-corner to target-corner lines. Interposed creatures grant soft + cover (+4) on ranged attacks only, exactly as in has_cover_cells. + """ + if not attacker_cells or not target_cells: + return 0 + if not has_cover_cells( + grid, attacker_cells, target_cells, ranged=ranged, occupied=occupied + ): + return 0 + lines = _LineGrid( + grid, + attacker_cells | target_cells, + _grants_cover, + occupied if ranged else frozenset(), + bonus_of=terrain_cover_bonus, + ) + return max( + lines.bonus(corner_a, corner_t) + for cell_a in attacker_cells + for corner_a in _corners(cell_a) + for cell_t in target_cells + for corner_t in _corners(cell_t) + ) diff --git a/src/pf1e_simulator/map.py b/src/pf1e_simulator/map.py index d4676c0..dcf87c9 100644 --- a/src/pf1e_simulator/map.py +++ b/src/pf1e_simulator/map.py @@ -11,7 +11,7 @@ from __future__ import annotations from typing import TYPE_CHECKING import yaml -from pydantic import BaseModel, ConfigDict, Field, ValidationError +from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator if TYPE_CHECKING: from pathlib import Path @@ -20,6 +20,8 @@ Pos = tuple[int, int] # (row, col), 0-indexed _ELEVATION_CHARS = frozenset("0123456789.") # digit = height, '.' = ground level +_COVER_BONUS_LEVELS = frozenset({0, 2, 4, 8}) # PF1e: none / partial / standard / improved + class MapValidationError(Exception): """Raised when a map YAML file fails validation; carries path and reason.""" @@ -38,8 +40,17 @@ class TerrainType(BaseModel): type: str move_cost: int | None = Field(default=None, ge=1) cover: bool = False + cover_bonus: int = 0 blocks_los: bool = False + @field_validator("cover_bonus", mode="after") + @classmethod + def _check_cover_bonus(cls, value: int) -> int: + if value not in _COVER_BONUS_LEVELS: + msg = f"cover_bonus must be one of {sorted(_COVER_BONUS_LEVELS)}, got {value}" + raise ValueError(msg) + return value + class MapSpec(BaseModel): """A fully validated map, ready for Grid construction.""" diff --git a/tests/test_cover_levels.py b/tests/test_cover_levels.py new file mode 100644 index 0000000..0975ac2 --- /dev/null +++ b/tests/test_cover_levels.py @@ -0,0 +1,228 @@ +"""Tests for PF1e cover LEVELS: partial +2, standard +4, improved +8. + +Cover is additive: the legacy boolean ``cover`` field and the boolean +has_cover/has_cover_cells API keep working unchanged; the new +``cover_bonus`` level (0/2/4/8) rides alongside them. +""" + +from __future__ import annotations + +import pytest +from pydantic import ValidationError + +from pf1e_simulator.combat import CombatEngine +from pf1e_simulator.grid import Grid +from pf1e_simulator.los import ( + cover_bonus, + cover_bonus_cells, + has_cover, + terrain_cover_bonus, +) +from pf1e_simulator.map import MapSpec, TerrainType +from pf1e_simulator.rng import ScriptedRng +from tests.test_combat import make_combatant, make_state + +_FLOOR = TerrainType(type="floor", move_cost=1) +_WALL = TerrainType(type="wall", move_cost=None, blocks_los=True) +_PILLAR = TerrainType(type="pillar", move_cost=None, cover=True) + + +def _grid(legend: dict[str, TerrainType], rows: tuple[str, ...]) -> Grid: + spec = MapSpec(name="cover-levels", terrain=tuple(rows), legend=legend) + return Grid.from_spec(spec) + + +def _level_grid(level: int) -> Grid: + """Open row with a cover_bonus obstacle at (1, 2) between (1,0) and (1,4).""" + legend = {".": _FLOOR, "X": TerrainType(type="ruin", move_cost=None, cover_bonus=level)} + return _grid(legend, (".....", "..X..", ".....")) + + +# --------------------------------------------------------------------------- +# (a) TerrainType.cover_bonus schema +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("level", [0, 2, 4, 8]) +def test_cover_bonus_accepts_pf1e_levels(level: int) -> None: + terrain = TerrainType(type="ruin", cover_bonus=level) + assert terrain.cover_bonus == level + assert terrain.cover is False # boolean field untouched + + +@pytest.mark.parametrize("level", [1, 3, 5, 6, 7, 10, -2]) +def test_cover_bonus_rejects_other_levels(level: int) -> None: + with pytest.raises(ValidationError): + TerrainType(type="ruin", cover_bonus=level) + + +def test_cover_bonus_defaults_to_zero() -> None: + assert TerrainType(type="floor").cover_bonus == 0 + + +# --------------------------------------------------------------------------- +# (b) terrain_cover_bonus mapping +# --------------------------------------------------------------------------- + + +def test_terrain_cover_bonus_legacy_boolean_cover_is_four() -> None: + assert terrain_cover_bonus(_PILLAR) == 4 + + +def test_terrain_cover_bonus_blocks_los_is_four() -> None: + assert terrain_cover_bonus(_WALL) == 4 + + +def test_terrain_cover_bonus_explicit_level_wins() -> None: + assert terrain_cover_bonus(TerrainType(type="ruin", cover_bonus=8)) == 8 + assert terrain_cover_bonus(TerrainType(type="ruin", cover_bonus=2)) == 2 + + +def test_terrain_cover_bonus_open_floor_is_zero() -> None: + assert terrain_cover_bonus(_FLOOR) == 0 + + +# --------------------------------------------------------------------------- +# (c) cover_bonus / cover_bonus_cells queries +# --------------------------------------------------------------------------- + + +def test_cover_bonus_zero_in_the_open() -> None: + grid = _grid({".": _FLOOR}, (".....", ".....", ".....")) + assert cover_bonus(grid, (1, 0), (1, 4), ranged=True) == 0 + assert cover_bonus_cells( + grid, frozenset({(1, 0)}), frozenset({(1, 4)}), ranged=True + ) == 0 + + +@pytest.mark.parametrize(("level", "expected"), [(2, 2), (4, 4), (8, 8)]) +def test_cover_bonus_returns_obstacle_level(level: int, expected: int) -> None: + grid = _level_grid(level) + assert has_cover(grid, (1, 0), (1, 4), ranged=True) is True + assert cover_bonus(grid, (1, 0), (1, 4), ranged=True) == expected + + +def test_cover_bonus_legacy_boolean_cover_is_four() -> None: + grid = _grid({".": _FLOOR, "C": _PILLAR}, (".....", "..C..", ".....")) + assert cover_bonus(grid, (1, 0), (1, 4), ranged=True) == 4 + + +def test_cover_bonus_blocks_los_terrain_is_four() -> None: + grid = _grid({".": _FLOOR, "#": _WALL}, (".....", "..#..", ".....")) + assert cover_bonus(grid, (1, 0), (1, 4), ranged=True) == 4 + + +def test_cover_bonus_improved_beats_standard_on_best_segment() -> None: + """Wall (standard +4) and improved ruin (+8) both on the corner lines.""" + legend = { + ".": _FLOOR, + "#": _WALL, + "X": TerrainType(type="tower", move_cost=None, cover_bonus=8), + } + grid = _grid(legend, (".....", ".#X..", ".....")) + assert has_cover(grid, (1, 0), (1, 4), ranged=True) is True + assert cover_bonus(grid, (1, 0), (1, 4), ranged=True) == 8 + + +def test_cover_bonus_soft_cover_creature_ranged_is_four() -> None: + grid = _grid({".": _FLOOR}, (".....", ".....", ".....")) + occupied = frozenset({(1, 2)}) + assert ( + cover_bonus(grid, (1, 0), (1, 4), ranged=True, occupied=occupied) == 4 + ) + + +def test_cover_bonus_soft_cover_melee_is_zero() -> None: + grid = _grid({".": _FLOOR}, (".....", ".....", ".....")) + occupied = frozenset({(1, 2)}) + assert ( + cover_bonus(grid, (1, 0), (1, 4), ranged=False, occupied=occupied) == 0 + ) + + +def test_cover_bonus_same_square_is_zero() -> None: + grid = _grid({".": _FLOOR}, (".",)) + assert cover_bonus(grid, (0, 0), (0, 0), ranged=True) == 0 + + +# --------------------------------------------------------------------------- +# (d) engine integration: resolve_attack applies the level +# --------------------------------------------------------------------------- + + +def _ranged_cover_engine(level: int) -> CombatEngine: + legend = { + ".": _FLOOR, + "X": TerrainType(type="ruin", move_cost=None, cover_bonus=level), + } + grid = _grid(legend, (".X...",)) + archer = make_combatant("archer", attack_bonus=4, kind="ranged", range_increment_ft=60) + target = make_combatant("target", ac=13) + states = [make_state(archer, "players", (0, 0)), make_state(target, "monsters", (0, 3))] + return CombatEngine(ScriptedRng([12, 3]), grid, states) + + +def test_engine_improved_cover_adds_eight_to_ac() -> None: + engine = _ranged_cover_engine(8) + result = engine.resolve_attack( + engine._states[0], engine._states[1], engine._states[0].combatant.attacks[0] + ) + assert result.ac == 21 # 13 + 8 improved cover + assert result.hit is False # 12 + 4 = 16 < 21 + + +def test_engine_partial_cover_adds_two_to_ac() -> None: + engine = _ranged_cover_engine(2) + result = engine.resolve_attack( + engine._states[0], engine._states[1], engine._states[0].combatant.attacks[0] + ) + assert result.ac == 15 # 13 + 2 partial cover + assert result.hit is True # 12 + 4 = 16 >= 15 + + +def test_engine_legacy_boolean_cover_still_adds_four() -> None: + legend = {".": _FLOOR, "C": _PILLAR} + grid = _grid(legend, (".C...",)) + archer = make_combatant("archer", attack_bonus=4, kind="ranged", range_increment_ft=60) + target = make_combatant("target", ac=13) + states = [make_state(archer, "players", (0, 0)), make_state(target, "monsters", (0, 3))] + engine = CombatEngine(ScriptedRng([12, 3]), grid, states) + result = engine.resolve_attack(states[0], states[1], archer.attacks[0]) + assert result.ac == 17 # 13 + 4, identical to the pre-levels behavior + + +# --------------------------------------------------------------------------- +# (e) snapshot: terrain_semantics exposes cover_bonus additively +# --------------------------------------------------------------------------- + + +def test_snapshot_terrain_semantics_exposes_cover_bonus() -> None: + legend = { + ".": _FLOOR, + "C": _PILLAR, + "X": TerrainType(type="tower", move_cost=None, cover_bonus=8), + } + grid = _grid(legend, ("..X.", ".C..")) + hero = make_combatant("hero") + foe = make_combatant("foe") + states = [make_state(hero, "players", (0, 0)), make_state(foe, "monsters", (1, 1))] + engine = CombatEngine(ScriptedRng([]), grid, states) + + obs = engine._build_observation(states[0]).to_dict() + assert obs["terrain_semantics"] == ( + {"type": "floor", "move_cost": 1, "blocks_los": False, "cover": False, "cover_bonus": 0}, + { + "type": "pillar", + "move_cost": None, + "blocks_los": False, + "cover": True, + "cover_bonus": 0, + }, + { + "type": "tower", + "move_cost": None, + "blocks_los": False, + "cover": False, + "cover_bonus": 8, + }, + ) diff --git a/tests/test_observation_enriched.py b/tests/test_observation_enriched.py index c3bc978..7da0338 100644 --- a/tests/test_observation_enriched.py +++ b/tests/test_observation_enriched.py @@ -921,7 +921,7 @@ def test_observation_enriched_terrain_semantics_all_types() -> None: When: an Observation is built Then: terrain_semantics has one entry per distinct terrain type (sorted by type for determinism), each carrying - type/move_cost/blocks_los/cover.""" + type/move_cost/blocks_los/cover/cover_bonus.""" hero = make_combatant("hero") foe = make_combatant("foe") hero_state = make_state(hero, "players", (0, 0)) @@ -932,9 +932,27 @@ def test_observation_enriched_terrain_semantics_all_types() -> None: obs = _snapshot(engine, hero_state) semantics = obs["terrain_semantics"] assert semantics == ( - {"type": "floor", "move_cost": 1, "blocks_los": False, "cover": False}, - {"type": "pillar", "move_cost": None, "blocks_los": False, "cover": True}, - {"type": "wall", "move_cost": None, "blocks_los": True, "cover": False}, + { + "type": "floor", + "move_cost": 1, + "blocks_los": False, + "cover": False, + "cover_bonus": 0, + }, + { + "type": "pillar", + "move_cost": None, + "blocks_los": False, + "cover": True, + "cover_bonus": 0, + }, + { + "type": "wall", + "move_cost": None, + "blocks_los": True, + "cover": False, + "cover_bonus": 0, + }, ) @@ -950,7 +968,13 @@ def test_observation_enriched_terrain_semantics_single_type() -> None: obs = _snapshot(engine, hero_state) assert obs["terrain_semantics"] == ( - {"type": "floor", "move_cost": 1, "blocks_los": False, "cover": False}, + { + "type": "floor", + "move_cost": 1, + "blocks_los": False, + "cover": False, + "cover_bonus": 0, + }, ) @@ -970,7 +994,13 @@ def test_observation_enriched_terrain_semantics_no_grid_iteration_needed() -> No obs = _snapshot(engine, hero_state) assert obs["terrain_semantics"] == ( - {"type": "wall", "move_cost": None, "blocks_los": True, "cover": False}, + { + "type": "wall", + "move_cost": None, + "blocks_los": True, + "cover": False, + "cover_bonus": 0, + }, ) @@ -990,7 +1020,13 @@ def test_observation_enriched_terrain_semantics_no_callable_no_share() -> None: data = obs.to_dict() json.dumps(data) # must not raise for entry in data["terrain_semantics"]: - assert set(entry) == {"type", "move_cost", "blocks_los", "cover"} + assert set(entry) == { + "type", + "move_cost", + "blocks_los", + "cover", + "cover_bonus", + } # ---------------------------------------------------------------------------