feat(combat): expose threatened squares and movement cost fields for LLM policy
Rename _threatened_squares -> threatened_squares and _enemies_threatening -> enemies_threatening (public read-only API for a future LLM policy), and add reachable_ft(state, budget) and movement_cost_ft(state, target) accessors returning movement costs in feet. Pure exposure: no behavior change, resolution engine untouched, goldens bit-identical.
This commit is contained in:
@@ -1112,13 +1112,13 @@ class CombatEngine:
|
|||||||
return active_sides.pop()
|
return active_sides.pop()
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _threatened_squares(self, state: CombatantState) -> frozenset[Pos]:
|
def threatened_squares(self, state: CombatantState) -> frozenset[Pos]:
|
||||||
"""Squares this combatant threatens with a melee weapon.
|
"""Squares this combatant threatens with a melee weapon.
|
||||||
|
|
||||||
Non-reach weapons (reach_ft == 5) threaten the disk of effective reach;
|
(API publique, lecture pour politique LLM.) Non-reach weapons
|
||||||
reach weapons (reach_ft > 5) threaten only the ring at exactly their
|
(reach_ft == 5) threaten the disk of effective reach; reach weapons
|
||||||
reach (PF1e reach dead zone). The union over usable melee weapons is
|
(reach_ft > 5) threaten only the ring at exactly their reach (PF1e
|
||||||
returned.
|
reach dead zone). The union over usable melee weapons is returned.
|
||||||
"""
|
"""
|
||||||
if not state.active:
|
if not state.active:
|
||||||
return frozenset()
|
return frozenset()
|
||||||
@@ -1169,17 +1169,57 @@ class CombatEngine:
|
|||||||
and self._grid.distance(center, (r + dr, c + dc)) <= radius
|
and self._grid.distance(center, (r + dr, c + dc)) <= radius
|
||||||
}
|
}
|
||||||
|
|
||||||
def _enemies_threatening(self, state: CombatantState, pos: Pos) -> list[CombatantState]:
|
def enemies_threatening(self, state: CombatantState, pos: Pos) -> list[CombatantState]:
|
||||||
"""Active enemies whose threatened squares include pos (in self._states order)."""
|
"""Active enemies whose threatened squares include pos (in self._states order).
|
||||||
|
|
||||||
|
(API publique, lecture pour politique LLM.)
|
||||||
|
"""
|
||||||
return [
|
return [
|
||||||
s
|
s
|
||||||
for s in self._states
|
for s in self._states
|
||||||
if s is not state
|
if s is not state
|
||||||
and s.active
|
and s.active
|
||||||
and s.side != state.side
|
and s.side != state.side
|
||||||
and pos in self._threatened_squares(s)
|
and pos in self.threatened_squares(s)
|
||||||
]
|
]
|
||||||
|
|
||||||
|
def reachable_ft(self, state: CombatantState, budget: int | None = None) -> dict[Pos, int]:
|
||||||
|
"""Movement cost in feet from state's anchor to each reachable cell.
|
||||||
|
|
||||||
|
(API publique, lecture pour politique LLM.) Same cost model as
|
||||||
|
``Grid.reachable``: 5-10-5 diagonals, terrain, elevation, blocked
|
||||||
|
creatures excluded. ``budget`` is in feet; None = full field.
|
||||||
|
"""
|
||||||
|
blocked = self._blocked(state)
|
||||||
|
if budget is None:
|
||||||
|
field = self._grid.reachable(state.pos, None, blocked)
|
||||||
|
else:
|
||||||
|
budget_cells = budget // _SQUARE_FT
|
||||||
|
if budget_cells < 1:
|
||||||
|
return {}
|
||||||
|
field = self._grid.reachable(state.pos, budget_cells, blocked)
|
||||||
|
return {pos: cost * _SQUARE_FT for pos, cost in field.items()}
|
||||||
|
|
||||||
|
def movement_cost_ft(self, state: CombatantState, target: CombatantState) -> int | None:
|
||||||
|
"""Minimal movement cost in feet for ``state`` to reach a cell from which
|
||||||
|
it can melee ``target``, or None if unreachable under its speed.
|
||||||
|
|
||||||
|
(API publique, lecture pour politique LLM.) Uses the same cost model as
|
||||||
|
``_move_path``: a full Dijkstra field from the target's anchor (other
|
||||||
|
active states' cells blocked), then the cheapest field value over
|
||||||
|
``state``'s footprint, compared against the combatant's land speed.
|
||||||
|
Read-only: never mutates state nor consumes RNG.
|
||||||
|
"""
|
||||||
|
blocked = self._blocked(state, target)
|
||||||
|
field = self._grid.reachable(target.pos, None, blocked)
|
||||||
|
cost_cells = self._footprint_cost(state.occupied_cells(), field)
|
||||||
|
if cost_cells is None:
|
||||||
|
return None
|
||||||
|
speed_cells = state.combatant.speed_land_ft // _SQUARE_FT
|
||||||
|
if cost_cells > speed_cells:
|
||||||
|
return None
|
||||||
|
return cost_cells * _SQUARE_FT
|
||||||
|
|
||||||
def _resolve_aoo(self, attacker: CombatantState, target: CombatantState) -> bool:
|
def _resolve_aoo(self, attacker: CombatantState, target: CombatantState) -> bool:
|
||||||
"""Resolve one attack of opportunity (single melee attack at normal bonus).
|
"""Resolve one attack of opportunity (single melee attack at normal bonus).
|
||||||
|
|
||||||
@@ -1203,7 +1243,7 @@ class CombatEngine:
|
|||||||
by the per-round AoO allowance). Returns True if the mover dropped.
|
by the per-round AoO allowance). Returns True if the mover dropped.
|
||||||
"""
|
"""
|
||||||
for cell in mover.occupied_cells():
|
for cell in mover.occupied_cells():
|
||||||
for enemy in self._enemies_threatening(mover, cell):
|
for enemy in self.enemies_threatening(mover, cell):
|
||||||
if self._aoo_used.get(enemy.combatant.id, 0) >= self._aoo_allowance(enemy):
|
if self._aoo_used.get(enemy.combatant.id, 0) >= self._aoo_allowance(enemy):
|
||||||
continue
|
continue
|
||||||
self._resolve_aoo(enemy, mover)
|
self._resolve_aoo(enemy, mover)
|
||||||
@@ -1221,7 +1261,7 @@ class CombatEngine:
|
|||||||
if not self._is_ranged_attack(weapon):
|
if not self._is_ranged_attack(weapon):
|
||||||
return False
|
return False
|
||||||
for cell in attacker.occupied_cells():
|
for cell in attacker.occupied_cells():
|
||||||
for enemy in self._enemies_threatening(attacker, cell):
|
for enemy in self.enemies_threatening(attacker, cell):
|
||||||
if self._aoo_used.get(enemy.combatant.id, 0) >= self._aoo_allowance(enemy):
|
if self._aoo_used.get(enemy.combatant.id, 0) >= self._aoo_allowance(enemy):
|
||||||
continue
|
continue
|
||||||
self._resolve_aoo(enemy, attacker)
|
self._resolve_aoo(enemy, attacker)
|
||||||
@@ -1235,7 +1275,7 @@ class CombatEngine:
|
|||||||
Returns True if the caster dropped or failed concentration (spell fizzles).
|
Returns True if the caster dropped or failed concentration (spell fizzles).
|
||||||
"""
|
"""
|
||||||
for cell in caster.occupied_cells():
|
for cell in caster.occupied_cells():
|
||||||
for enemy in self._enemies_threatening(caster, cell):
|
for enemy in self.enemies_threatening(caster, cell):
|
||||||
if self._aoo_used.get(enemy.combatant.id, 0) >= self._aoo_allowance(enemy):
|
if self._aoo_used.get(enemy.combatant.id, 0) >= self._aoo_allowance(enemy):
|
||||||
continue
|
continue
|
||||||
hp_before = caster.hp
|
hp_before = caster.hp
|
||||||
|
|||||||
@@ -256,7 +256,7 @@ class TestThreatenedSquaresLarge:
|
|||||||
giant = make_combatant("g", size="Large")
|
giant = make_combatant("g", size="Large")
|
||||||
state = make_state(giant, "p", (2, 2))
|
state = make_state(giant, "p", (2, 2))
|
||||||
engine = make_engine([state])
|
engine = make_engine([state])
|
||||||
threatened = engine._threatened_squares(state)
|
threatened = engine.threatened_squares(state)
|
||||||
# Must contain cells adjacent to any of the 4 occupied cells
|
# Must contain cells adjacent to any of the 4 occupied cells
|
||||||
# e.g. (0,2) is at distance 2 from (2,2), should be in
|
# e.g. (0,2) is at distance 2 from (2,2), should be in
|
||||||
assert (0, 2) in threatened
|
assert (0, 2) in threatened
|
||||||
@@ -287,7 +287,7 @@ class TestThreatenedSquaresLarge:
|
|||||||
)
|
)
|
||||||
state = make_state(combatant, "p", (2, 2))
|
state = make_state(combatant, "p", (2, 2))
|
||||||
engine = make_engine([state])
|
engine = make_engine([state])
|
||||||
threatened = engine._threatened_squares(state)
|
threatened = engine.threatened_squares(state)
|
||||||
# Ring at distance 2 from each occupied cell is included
|
# Ring at distance 2 from each occupied cell is included
|
||||||
# (2,2) ring(2) includes (0,2) etc.
|
# (2,2) ring(2) includes (0,2) etc.
|
||||||
# (3,3) ring(2) includes (1,3), (5,3), (3,1), (3,5) etc.
|
# (3,3) ring(2) includes (1,3), (5,3), (3,1), (3,5) etc.
|
||||||
|
|||||||
@@ -0,0 +1,323 @@
|
|||||||
|
"""Tests for L5: public exposure of threatened squares, enemies threatening,
|
||||||
|
and movement-cost fields (read-only API for a future LLM policy).
|
||||||
|
|
||||||
|
Covers:
|
||||||
|
- ``threatened_squares`` (public, renamed from ``_threatened_squares``): the
|
||||||
|
disk of effective reach for non-reach weapons and the ring (dead zone) for
|
||||||
|
reach weapons.
|
||||||
|
- ``enemies_threatening`` (public, renamed from ``_enemies_threatening``):
|
||||||
|
active enemies whose threatened squares include a given cell, in
|
||||||
|
``self._states`` order; allies never count.
|
||||||
|
- ``reachable_ft``: movement cost in feet from a state's anchor to each
|
||||||
|
reachable cell (same cost model as ``Grid.reachable``).
|
||||||
|
- ``movement_cost_ft``: minimal cost in feet to move ``state`` to a cell from
|
||||||
|
which it can melee ``target``, or ``None`` if unreachable under speed.
|
||||||
|
- Determinism + non-mutation: the public accessors never mutate state nor
|
||||||
|
consume RNG.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
def make_combatant(
|
||||||
|
cid: str,
|
||||||
|
*,
|
||||||
|
size: str = "Medium",
|
||||||
|
reach_ft: int = 5,
|
||||||
|
hp: int = 20,
|
||||||
|
ac: int = 15,
|
||||||
|
attack_bonus: int = 5,
|
||||||
|
speed: int = 30,
|
||||||
|
) -> Combatant:
|
||||||
|
"""Build a single-melee-weapon combatant."""
|
||||||
|
attack = AttackSpec(
|
||||||
|
id=f"{cid}-w",
|
||||||
|
name="longspear" if reach_ft > 5 else "short sword",
|
||||||
|
kind="melee",
|
||||||
|
attack_bonus=attack_bonus,
|
||||||
|
damage=[DamageComponent(formula=parse_dice("1d6"), types=["piercing"])],
|
||||||
|
reach_ft=reach_ft,
|
||||||
|
)
|
||||||
|
return Combatant(
|
||||||
|
id=cid,
|
||||||
|
name=cid,
|
||||||
|
level=1,
|
||||||
|
size=size,
|
||||||
|
abilities=AbilityScores(
|
||||||
|
str_score=10,
|
||||||
|
dex_score=10,
|
||||||
|
con_score=12,
|
||||||
|
int_score=10,
|
||||||
|
wis_score=10,
|
||||||
|
cha_score=10,
|
||||||
|
),
|
||||||
|
hp_max=hp,
|
||||||
|
ac=ACProfile(total=ac, touch=ac, flat_footed=ac),
|
||||||
|
bab=attack_bonus,
|
||||||
|
initiative_mod=0,
|
||||||
|
speed_land_ft=speed,
|
||||||
|
saves=Saves(fort=0, ref=0, will=0),
|
||||||
|
attacks=[attack],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def make_state(combatant: Combatant, side: str, pos: tuple[int, int]) -> CombatantState:
|
||||||
|
return CombatantState(combatant=combatant, side=side, pos=pos, hp=combatant.hp_max)
|
||||||
|
|
||||||
|
|
||||||
|
def make_grid() -> Grid:
|
||||||
|
legend = {".": TerrainType(type="floor", move_cost=1)}
|
||||||
|
spec = MapSpec(name="test", terrain=tuple(["." * 8] * 8), legend=legend)
|
||||||
|
return Grid.from_spec(spec)
|
||||||
|
|
||||||
|
|
||||||
|
def make_engine(states: list[CombatantState]) -> CombatEngine:
|
||||||
|
return CombatEngine(ScriptedRng([]), make_grid(), states)
|
||||||
|
|
||||||
|
|
||||||
|
def _adjacent(pos: tuple[int, int]) -> set[tuple[int, int]]:
|
||||||
|
"""The 8 cells adjacent to ``pos``."""
|
||||||
|
r, c = pos
|
||||||
|
return {
|
||||||
|
(r + dr, c + dc)
|
||||||
|
for dr in (-1, 0, 1)
|
||||||
|
for dc in (-1, 0, 1)
|
||||||
|
if (dr, dc) != (0, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _ring(pos: tuple[int, int], radius: int) -> set[tuple[int, int]]:
|
||||||
|
"""Cells at exactly ``radius`` squares from ``pos`` (5-10-5 distance)."""
|
||||||
|
r, c = pos
|
||||||
|
return {
|
||||||
|
(r + dr, c + dc)
|
||||||
|
for dr in range(-radius, radius + 1)
|
||||||
|
for dc in range(-radius, radius + 1)
|
||||||
|
if (dr, dc) != (0, 0)
|
||||||
|
and max(abs(dr), abs(dc)) + min(abs(dr), abs(dc)) // 2 == radius
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 1. threatened_squares (public)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_threatened_squares_public_non_reach_disk() -> None:
|
||||||
|
"""A Medium combatant with a 5 ft weapon threatens exactly the 8 adjacent squares."""
|
||||||
|
fighter = make_combatant("fighter")
|
||||||
|
state = make_state(fighter, "players", (3, 3))
|
||||||
|
engine = make_engine([state])
|
||||||
|
assert engine.threatened_squares(state) == frozenset(_adjacent((3, 3)))
|
||||||
|
|
||||||
|
|
||||||
|
def test_threatened_squares_public_reach_dead_zone() -> None:
|
||||||
|
"""A longspear (reach 10 ft) threatens the ring at distance 2, not adjacent squares."""
|
||||||
|
spearman = make_combatant("spearman", reach_ft=10)
|
||||||
|
state = make_state(spearman, "players", (3, 3))
|
||||||
|
engine = make_engine([state])
|
||||||
|
threatened = engine.threatened_squares(state)
|
||||||
|
assert threatened == frozenset(_ring((3, 3), 2))
|
||||||
|
assert (3, 2) not in threatened # adjacent orthogonal (dead zone)
|
||||||
|
assert (3, 4) not in threatened
|
||||||
|
assert (2, 2) not in threatened # adjacent diagonal (dead zone)
|
||||||
|
assert (4, 4) not in threatened
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 2. enemies_threatening (public)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_enemies_threatening_public_order_and_allies() -> None:
|
||||||
|
"""Only active enemies whose threatened squares include pos appear, in
|
||||||
|
self._states order; allies never count."""
|
||||||
|
hero = make_combatant("hero")
|
||||||
|
foe_near = make_combatant("foe_near")
|
||||||
|
foe_far = make_combatant("foe_far")
|
||||||
|
ally = make_combatant("ally")
|
||||||
|
|
||||||
|
hero_state = make_state(hero, "players", (3, 3))
|
||||||
|
# foe_near adjacent to (3,3) -> threatens it
|
||||||
|
foe_near_state = make_state(foe_near, "monsters", (3, 4))
|
||||||
|
# foe_far far away -> does not threaten (3,3)
|
||||||
|
foe_far_state = make_state(foe_far, "monsters", (6, 6))
|
||||||
|
# ally adjacent but same side -> never counts
|
||||||
|
ally_state = make_state(ally, "players", (4, 3))
|
||||||
|
|
||||||
|
engine = make_engine([hero_state, foe_near_state, foe_far_state, ally_state])
|
||||||
|
result = engine.enemies_threatening(hero_state, (3, 3))
|
||||||
|
assert result == [foe_near_state]
|
||||||
|
|
||||||
|
|
||||||
|
def test_enemies_threatening_public_empty_when_none() -> None:
|
||||||
|
"""No enemies threatening a cell -> empty list."""
|
||||||
|
hero = make_combatant("hero")
|
||||||
|
foe_far = make_combatant("foe_far")
|
||||||
|
hero_state = make_state(hero, "players", (3, 3))
|
||||||
|
foe_far_state = make_state(foe_far, "monsters", (6, 6))
|
||||||
|
engine = make_engine([hero_state, foe_far_state])
|
||||||
|
assert engine.enemies_threatening(hero_state, (3, 3)) == []
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 3. reachable_ft
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_reachable_ft_costs_and_budget() -> None:
|
||||||
|
"""reachable_ft returns movement cost in feet; budget is in feet."""
|
||||||
|
fighter = make_combatant("fighter")
|
||||||
|
state = make_state(fighter, "players", (0, 0))
|
||||||
|
engine = make_engine([state])
|
||||||
|
|
||||||
|
field = engine.reachable_ft(state)
|
||||||
|
assert field[(0, 0)] == 0
|
||||||
|
assert field[(0, 1)] == 5 # orthogonal 1 cell
|
||||||
|
assert field[(1, 1)] == 5 # first diagonal = 1 cell (5-10-5)
|
||||||
|
assert field[(0, 2)] == 10 # orthogonal 2 cells
|
||||||
|
|
||||||
|
# budget in feet: cells with cost <= 15 present, beyond absent
|
||||||
|
budgeted = engine.reachable_ft(state, budget=15)
|
||||||
|
assert (0, 1) in budgeted
|
||||||
|
assert (1, 1) in budgeted
|
||||||
|
assert (0, 2) in budgeted
|
||||||
|
assert (0, 3) in budgeted # 3 orthogonal cells = 15 ft, within budget
|
||||||
|
assert (0, 4) not in budgeted # 4 cells = 20 ft > 15
|
||||||
|
|
||||||
|
|
||||||
|
def test_reachable_ft_difficult_terrain() -> None:
|
||||||
|
"""A difficult-terrain cell (move_cost 2) costs 10 ft."""
|
||||||
|
legend = {".": TerrainType(type="floor", move_cost=1),
|
||||||
|
"D": TerrainType(type="rubble", move_cost=2)}
|
||||||
|
spec = MapSpec(
|
||||||
|
name="test",
|
||||||
|
terrain=(
|
||||||
|
".D......",
|
||||||
|
"........",
|
||||||
|
"........",
|
||||||
|
"........",
|
||||||
|
"........",
|
||||||
|
"........",
|
||||||
|
"........",
|
||||||
|
"........",
|
||||||
|
),
|
||||||
|
legend=legend,
|
||||||
|
)
|
||||||
|
grid = Grid.from_spec(spec)
|
||||||
|
fighter = make_combatant("fighter")
|
||||||
|
state = make_state(fighter, "players", (0, 0))
|
||||||
|
engine = CombatEngine(ScriptedRng([]), grid, [state])
|
||||||
|
|
||||||
|
field = engine.reachable_ft(state)
|
||||||
|
assert field[(0, 1)] == 10 # difficult terrain cell costs 2 cells = 10 ft
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 4. movement_cost_ft
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_movement_cost_ft_distance() -> None:
|
||||||
|
"""movement_cost_ft returns the minimal cost in feet to reach a cell from
|
||||||
|
which state can melee target (state 2 cells away -> ~10 ft)."""
|
||||||
|
fighter = make_combatant("fighter", speed=30)
|
||||||
|
target = make_combatant("target")
|
||||||
|
state = make_state(fighter, "players", (0, 0))
|
||||||
|
target_state = make_state(target, "monsters", (0, 2)) # 2 cells away
|
||||||
|
engine = make_engine([state, target_state])
|
||||||
|
|
||||||
|
cost = engine.movement_cost_ft(state, target_state)
|
||||||
|
assert cost == 10
|
||||||
|
|
||||||
|
|
||||||
|
def test_movement_cost_ft_unreachable_returns_none() -> None:
|
||||||
|
"""If the target is sealed behind impassable walls, movement_cost_ft is None."""
|
||||||
|
legend = {
|
||||||
|
".": TerrainType(type="floor", move_cost=1),
|
||||||
|
"#": TerrainType(type="wall", move_cost=None),
|
||||||
|
}
|
||||||
|
walled = MapSpec(
|
||||||
|
name="test",
|
||||||
|
terrain=(
|
||||||
|
"........",
|
||||||
|
"........",
|
||||||
|
"..####..",
|
||||||
|
"..#..#..",
|
||||||
|
"..#..#..",
|
||||||
|
"..####..",
|
||||||
|
"........",
|
||||||
|
"........",
|
||||||
|
),
|
||||||
|
legend=legend,
|
||||||
|
)
|
||||||
|
grid = Grid.from_spec(walled)
|
||||||
|
fighter = make_combatant("fighter", speed=30)
|
||||||
|
target = make_combatant("target")
|
||||||
|
# state outside the box, target inside -> no path
|
||||||
|
state = make_state(fighter, "players", (0, 0))
|
||||||
|
target_state = make_state(target, "monsters", (3, 3))
|
||||||
|
engine = CombatEngine(ScriptedRng([]), grid, [state, target_state])
|
||||||
|
|
||||||
|
assert engine.movement_cost_ft(state, target_state) is None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 5. Determinism + non-mutation
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_public_accessors_do_not_mutate_state() -> None:
|
||||||
|
"""Calling the public accessors leaves state and engine unchanged."""
|
||||||
|
fighter = make_combatant("fighter", speed=30)
|
||||||
|
target = make_combatant("target")
|
||||||
|
state = make_state(fighter, "players", (0, 0))
|
||||||
|
target_state = make_state(target, "monsters", (0, 2))
|
||||||
|
engine = make_engine([state, target_state])
|
||||||
|
|
||||||
|
pos_before = state.pos
|
||||||
|
hp_before = state.hp
|
||||||
|
states_before = list(engine._states)
|
||||||
|
|
||||||
|
engine.threatened_squares(state)
|
||||||
|
engine.enemies_threatening(state, (0, 1))
|
||||||
|
engine.reachable_ft(state)
|
||||||
|
engine.movement_cost_ft(state, target_state)
|
||||||
|
|
||||||
|
assert state.pos == pos_before
|
||||||
|
assert state.hp == hp_before
|
||||||
|
assert list(engine._states) == states_before
|
||||||
|
|
||||||
|
|
||||||
|
def test_public_accessors_deterministic_two_runs() -> None:
|
||||||
|
"""Two runs with the same seed and default policy produce identical
|
||||||
|
transcripts (the resolution engine still uses the renamed private logic)."""
|
||||||
|
|
||||||
|
def run() -> tuple:
|
||||||
|
fighter = make_combatant("fighter", speed=30)
|
||||||
|
target = make_combatant("target")
|
||||||
|
states = [
|
||||||
|
make_state(fighter, "players", (0, 0)),
|
||||||
|
make_state(target, "monsters", (0, 3)),
|
||||||
|
]
|
||||||
|
eng = CombatEngine(
|
||||||
|
ScriptedRng([10, 9, 8, 15, 3, 5]),
|
||||||
|
make_grid(), states, round_cap=1,
|
||||||
|
)
|
||||||
|
return eng.run().transcript
|
||||||
|
|
||||||
|
assert run() == run()
|
||||||
+5
-5
@@ -121,7 +121,7 @@ def test_medium_5ft_weapon_threatens_adjacent_only() -> None:
|
|||||||
fighter = make_combatant("fighter")
|
fighter = make_combatant("fighter")
|
||||||
state = make_state(fighter, "players", (3, 3))
|
state = make_state(fighter, "players", (3, 3))
|
||||||
engine = make_engine([state])
|
engine = make_engine([state])
|
||||||
assert engine._threatened_squares(state) == frozenset(_adjacent((3, 3)))
|
assert engine.threatened_squares(state) == frozenset(_adjacent((3, 3)))
|
||||||
|
|
||||||
|
|
||||||
def test_reach_weapon_threatens_ring_not_adjacent() -> None:
|
def test_reach_weapon_threatens_ring_not_adjacent() -> None:
|
||||||
@@ -129,7 +129,7 @@ def test_reach_weapon_threatens_ring_not_adjacent() -> None:
|
|||||||
spearman = make_combatant("spearman", reach_ft=10)
|
spearman = make_combatant("spearman", reach_ft=10)
|
||||||
state = make_state(spearman, "players", (3, 3))
|
state = make_state(spearman, "players", (3, 3))
|
||||||
engine = make_engine([state])
|
engine = make_engine([state])
|
||||||
threatened = engine._threatened_squares(state)
|
threatened = engine.threatened_squares(state)
|
||||||
assert threatened == frozenset(_ring((3, 3), 2))
|
assert threatened == frozenset(_ring((3, 3), 2))
|
||||||
assert (3, 2) not in threatened # adjacent orthogonal
|
assert (3, 2) not in threatened # adjacent orthogonal
|
||||||
assert (3, 4) not in threatened
|
assert (3, 4) not in threatened
|
||||||
@@ -178,7 +178,7 @@ def test_sword_plus_reach_weapon_union() -> None:
|
|||||||
)
|
)
|
||||||
state = make_state(combatant, "players", (3, 3))
|
state = make_state(combatant, "players", (3, 3))
|
||||||
engine = make_engine([state])
|
engine = make_engine([state])
|
||||||
assert engine._threatened_squares(state) == frozenset(
|
assert engine.threatened_squares(state) == frozenset(
|
||||||
_adjacent((3, 3)) | _ring((3, 3), 2)
|
_adjacent((3, 3)) | _ring((3, 3), 2)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -194,7 +194,7 @@ def test_large_creature_sword_threatens_disk_radius_2() -> None:
|
|||||||
for cell in footprint:
|
for cell in footprint:
|
||||||
expected |= _disk(cell, 2)
|
expected |= _disk(cell, 2)
|
||||||
expected -= footprint
|
expected -= footprint
|
||||||
assert engine._threatened_squares(state) == frozenset(expected)
|
assert engine.threatened_squares(state) == frozenset(expected)
|
||||||
|
|
||||||
|
|
||||||
def test_unknown_size_defaults_to_5ft() -> None:
|
def test_unknown_size_defaults_to_5ft() -> None:
|
||||||
@@ -202,7 +202,7 @@ def test_unknown_size_defaults_to_5ft() -> None:
|
|||||||
weird = make_combatant("weird", size="Weird")
|
weird = make_combatant("weird", size="Weird")
|
||||||
state = make_state(weird, "players", (3, 3))
|
state = make_state(weird, "players", (3, 3))
|
||||||
engine = make_engine([state])
|
engine = make_engine([state])
|
||||||
assert engine._threatened_squares(state) == frozenset(_adjacent((3, 3)))
|
assert engine.threatened_squares(state) == frozenset(_adjacent((3, 3)))
|
||||||
|
|
||||||
|
|
||||||
def test_weapon_for_reach_weapon_at_distance_2_not_1() -> None:
|
def test_weapon_for_reach_weapon_at_distance_2_not_1() -> None:
|
||||||
|
|||||||
Reference in New Issue
Block a user