feat(combat): support partial and improved cover levels
This commit is contained in:
@@ -164,7 +164,8 @@ legend: # un caractère = un type de terrain
|
|||||||
".": { type: floor, move_cost: 1 }
|
".": { type: floor, move_cost: 1 }
|
||||||
"T": { type: rubble, move_cost: 2 } # terrain difficile
|
"T": { type: rubble, move_cost: 2 } # terrain difficile
|
||||||
"~": { type: water, move_cost: 2 }
|
"~": { 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)
|
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
|
Valeur du `move_cost` : entier > 0 pour un terrain praticable, `null` pour un
|
||||||
terrain infranchissable (mur, pilier). `blocks_los` et `cover` sont appliqués
|
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
|
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)
|
### 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
|
- 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
|
peut pas être attaquée — la politique se déplace jusqu'à gagner une ligne de
|
||||||
visée.
|
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
|
confirmation de critique ; couvert mou : une créature active entre
|
||||||
l'attaquant et la cible octroie aussi +4 CA aux attaques à distance (les
|
l'attaquant et la cible octroie aussi +4 CA aux attaques à distance (les
|
||||||
attaques de mêlée ignorent les créatures).
|
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 à
|
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é.
|
≥ 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.
|
Les attaques de mêlée ne sont pas concernées.
|
||||||
- Couvert : seul le couvert standard (+4, terrain et couvert mou des créatures)
|
- Couvert : les trois niveaux PF1e sont modélisés via `cover_bonus` sur le
|
||||||
est modélisé ; le couvert partiel (+2) et le couvert amélioré (+8) ne sont PAS
|
terrain — partiel +2, standard +4 (ou `cover: true`), amélioré +8 ; le
|
||||||
modélisés.
|
couvert mou des créatures reste +4 (couvert standard).
|
||||||
- Invocation : les créatures invoquées agissent (calées sur la case d'initiative de
|
- 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`
|
leur conjurateur via `_build_round_order`) puis expirent après leur `summon_duration`
|
||||||
(`_expire_summons` décrémente puis retire).
|
(`_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,
|
- 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),
|
||||||
terrain_semantics (un dict par type de terrain : type, move_cost,
|
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
|
{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,
|
conditions de CONDITIONS_BY_NAME pour que la LLM interprète les drapeaux,
|
||||||
les modificateurs et les chances de rater (concealment)).
|
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
|
règle des coins (`diagonal_allowed`), Dijkstra `reachable` avec ou sans
|
||||||
budget.
|
budget.
|
||||||
- `los.py` — ligne d'effet et couvert par la règle des coins : gate de ligne
|
- `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
|
d'effet dans `weapon_for`, bonus de couvert par niveau (+2/+4/+8 terrain via
|
||||||
des créatures) dans `resolve_attack`.
|
`cover_bonus`, +4 couvert mou des créatures) dans `resolve_attack`.
|
||||||
- `combat.py` — `CombatEngine` déterministe : initiative, actions, résolution
|
- `combat.py` — `CombatEngine` déterministe : initiative, actions, résolution
|
||||||
des attaques (couvert, ligne d'effet, pénalités de portée, CA de contact
|
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
|
pour les attaques touch et firearms), tailles et allonges (occupation
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ if TYPE_CHECKING:
|
|||||||
TrickCondition = Literal["dazzled", "blinded", "deafened", "entangled", "nauseated"]
|
TrickCondition = Literal["dazzled", "blinded", "deafened", "entangled", "nauseated"]
|
||||||
|
|
||||||
from pf1e_simulator.los import (
|
from pf1e_simulator.los import (
|
||||||
has_cover_cells,
|
cover_bonus_cells,
|
||||||
has_line_of_effect_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
|
_DEATH_FLOOR = -10 # PF1e: dead when hp < -10 or -CON, whichever is lower
|
||||||
_NATURAL_ONE = 1 # PF1e: natural 1 always misses
|
_NATURAL_ONE = 1 # PF1e: natural 1 always misses
|
||||||
_NATURAL_TWENTY = 20 # PF1e: natural 20 always hits and threatens
|
_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
|
_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_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
|
_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,
|
"move_cost": cell.move_cost,
|
||||||
"blocks_los": cell.blocks_los,
|
"blocks_los": cell.blocks_los,
|
||||||
"cover": cell.cover,
|
"cover": cell.cover,
|
||||||
|
"cover_bonus": cell.cover_bonus,
|
||||||
}
|
}
|
||||||
for cell in (seen[name] for name in sorted(seen))
|
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
|
if s.active and s is not attacker and s is not target
|
||||||
for cell in s.occupied_cells()
|
for cell in s.occupied_cells()
|
||||||
)
|
)
|
||||||
if has_cover_cells(
|
ac += cover_bonus_cells(
|
||||||
self._grid,
|
self._grid,
|
||||||
attacker.occupied_cells(),
|
attacker.occupied_cells(),
|
||||||
target.occupied_cells(),
|
target.occupied_cells(),
|
||||||
ranged=self._is_ranged_attack(weapon),
|
ranged=self._is_ranged_attack(weapon),
|
||||||
occupied=occupied,
|
occupied=occupied,
|
||||||
):
|
)
|
||||||
ac += _COVER_AC_BONUS
|
|
||||||
hit = roll == _NATURAL_TWENTY or (roll != _NATURAL_ONE and total >= ac)
|
hit = roll == _NATURAL_TWENTY or (roll != _NATURAL_ONE and total >= ac)
|
||||||
damage = 0
|
damage = 0
|
||||||
concealment_miss = False
|
concealment_miss = False
|
||||||
|
|||||||
+100
-1
@@ -21,6 +21,8 @@ if TYPE_CHECKING:
|
|||||||
Pt = tuple[int, int] # lattice point (x, y) = (col, row)
|
Pt = tuple[int, int] # lattice point (x, y) = (col, row)
|
||||||
_Rat = tuple[int, int] # exact rational as (numerator, denominator), den > 0
|
_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]:
|
def _corners(pos: Pos) -> tuple[Pt, Pt, Pt, Pt]:
|
||||||
row, col = pos
|
row, col = pos
|
||||||
@@ -75,11 +77,13 @@ class _LineGrid:
|
|||||||
exclude: frozenset[Pos],
|
exclude: frozenset[Pos],
|
||||||
blocking: Callable[[TerrainType], bool],
|
blocking: Callable[[TerrainType], bool],
|
||||||
occupied: frozenset[Pos] = frozenset(),
|
occupied: frozenset[Pos] = frozenset(),
|
||||||
|
bonus_of: Callable[[TerrainType], int] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self._grid = grid
|
self._grid = grid
|
||||||
self._exclude = exclude
|
self._exclude = exclude
|
||||||
self._blocking = blocking
|
self._blocking = blocking
|
||||||
self._occupied = occupied
|
self._occupied = occupied
|
||||||
|
self._bonus_of = bonus_of
|
||||||
|
|
||||||
def blocked(self, p1: Pt, p2: Pt) -> bool:
|
def blocked(self, p1: Pt, p2: Pt) -> bool:
|
||||||
min_x = min(p1[0], p2[0])
|
min_x = min(p1[0], p2[0])
|
||||||
@@ -97,9 +101,49 @@ class _LineGrid:
|
|||||||
return True
|
return True
|
||||||
return False
|
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:
|
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:
|
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 cell_a in attacker_cells
|
||||||
for corner_a in _corners(cell_a)
|
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)
|
||||||
|
)
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ from __future__ import annotations
|
|||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
from pydantic import BaseModel, ConfigDict, Field, ValidationError
|
from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -20,6 +20,8 @@ Pos = tuple[int, int] # (row, col), 0-indexed
|
|||||||
|
|
||||||
_ELEVATION_CHARS = frozenset("0123456789.") # digit = height, '.' = ground level
|
_ELEVATION_CHARS = frozenset("0123456789.") # digit = height, '.' = ground level
|
||||||
|
|
||||||
|
_COVER_BONUS_LEVELS = frozenset({0, 2, 4, 8}) # PF1e: none / partial / standard / improved
|
||||||
|
|
||||||
|
|
||||||
class MapValidationError(Exception):
|
class MapValidationError(Exception):
|
||||||
"""Raised when a map YAML file fails validation; carries path and reason."""
|
"""Raised when a map YAML file fails validation; carries path and reason."""
|
||||||
@@ -38,8 +40,17 @@ class TerrainType(BaseModel):
|
|||||||
type: str
|
type: str
|
||||||
move_cost: int | None = Field(default=None, ge=1)
|
move_cost: int | None = Field(default=None, ge=1)
|
||||||
cover: bool = False
|
cover: bool = False
|
||||||
|
cover_bonus: int = 0
|
||||||
blocks_los: bool = False
|
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):
|
class MapSpec(BaseModel):
|
||||||
"""A fully validated map, ready for Grid construction."""
|
"""A fully validated map, ready for Grid construction."""
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -921,7 +921,7 @@ def test_observation_enriched_terrain_semantics_all_types() -> None:
|
|||||||
When: an Observation is built
|
When: an Observation is built
|
||||||
Then: terrain_semantics has one entry per distinct terrain type
|
Then: terrain_semantics has one entry per distinct terrain type
|
||||||
(sorted by type for determinism), each carrying
|
(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")
|
hero = make_combatant("hero")
|
||||||
foe = make_combatant("foe")
|
foe = make_combatant("foe")
|
||||||
hero_state = make_state(hero, "players", (0, 0))
|
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)
|
obs = _snapshot(engine, hero_state)
|
||||||
semantics = obs["terrain_semantics"]
|
semantics = obs["terrain_semantics"]
|
||||||
assert semantics == (
|
assert semantics == (
|
||||||
{"type": "floor", "move_cost": 1, "blocks_los": False, "cover": False},
|
{
|
||||||
{"type": "pillar", "move_cost": None, "blocks_los": False, "cover": True},
|
"type": "floor",
|
||||||
{"type": "wall", "move_cost": None, "blocks_los": True, "cover": False},
|
"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)
|
obs = _snapshot(engine, hero_state)
|
||||||
assert obs["terrain_semantics"] == (
|
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)
|
obs = _snapshot(engine, hero_state)
|
||||||
assert obs["terrain_semantics"] == (
|
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()
|
data = obs.to_dict()
|
||||||
json.dumps(data) # must not raise
|
json.dumps(data) # must not raise
|
||||||
for entry in data["terrain_semantics"]:
|
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",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user