feat(combat): support partial and improved cover levels

This commit is contained in:
2026-09-10 11:32:08 +02:00
parent 2eed752f7f
commit e6125b04f6
6 changed files with 401 additions and 23 deletions
+14 -9
View File
@@ -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
+4 -5
View File
@@ -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
View File
@@ -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)
)
+12 -1
View File
@@ -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."""
+228
View File
@@ -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,
},
)
+43 -7
View File
@@ -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",
}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------