diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index b9cf540..e0932a0 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -1112,13 +1112,13 @@ class CombatEngine: return active_sides.pop() 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. - Non-reach weapons (reach_ft == 5) threaten the disk of effective reach; - reach weapons (reach_ft > 5) threaten only the ring at exactly their - reach (PF1e reach dead zone). The union over usable melee weapons is - returned. + (API publique, lecture pour politique LLM.) Non-reach weapons + (reach_ft == 5) threaten the disk of effective reach; reach weapons + (reach_ft > 5) threaten only the ring at exactly their reach (PF1e + reach dead zone). The union over usable melee weapons is returned. """ if not state.active: return frozenset() @@ -1169,17 +1169,57 @@ class CombatEngine: and self._grid.distance(center, (r + dr, c + dc)) <= radius } - def _enemies_threatening(self, state: CombatantState, pos: Pos) -> list[CombatantState]: - """Active enemies whose threatened squares include pos (in self._states order).""" + def enemies_threatening(self, state: CombatantState, pos: Pos) -> list[CombatantState]: + """Active enemies whose threatened squares include pos (in self._states order). + + (API publique, lecture pour politique LLM.) + """ return [ s for s in self._states if s is not state and s.active 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: """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. """ 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): continue self._resolve_aoo(enemy, mover) @@ -1221,7 +1261,7 @@ class CombatEngine: if not self._is_ranged_attack(weapon): return False 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): continue self._resolve_aoo(enemy, attacker) @@ -1235,7 +1275,7 @@ class CombatEngine: Returns True if the caster dropped or failed concentration (spell fizzles). """ 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): continue hp_before = caster.hp diff --git a/tests/test_creature_sizes.py b/tests/test_creature_sizes.py index e45c2fe..f5d26a8 100644 --- a/tests/test_creature_sizes.py +++ b/tests/test_creature_sizes.py @@ -256,7 +256,7 @@ class TestThreatenedSquaresLarge: giant = make_combatant("g", size="Large") state = make_state(giant, "p", (2, 2)) 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 # e.g. (0,2) is at distance 2 from (2,2), should be in assert (0, 2) in threatened @@ -287,7 +287,7 @@ class TestThreatenedSquaresLarge: ) state = make_state(combatant, "p", (2, 2)) engine = make_engine([state]) - threatened = engine._threatened_squares(state) + threatened = engine.threatened_squares(state) # Ring at distance 2 from each occupied cell is included # (2,2) ring(2) includes (0,2) etc. # (3,3) ring(2) includes (1,3), (5,3), (3,1), (3,5) etc. diff --git a/tests/test_exposed_squares.py b/tests/test_exposed_squares.py new file mode 100644 index 0000000..13f0f36 --- /dev/null +++ b/tests/test_exposed_squares.py @@ -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() diff --git a/tests/test_reach.py b/tests/test_reach.py index 6a58e5f..681fb7d 100644 --- a/tests/test_reach.py +++ b/tests/test_reach.py @@ -121,7 +121,7 @@ def test_medium_5ft_weapon_threatens_adjacent_only() -> None: fighter = make_combatant("fighter") state = make_state(fighter, "players", (3, 3)) 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: @@ -129,7 +129,7 @@ def test_reach_weapon_threatens_ring_not_adjacent() -> None: spearman = make_combatant("spearman", reach_ft=10) state = make_state(spearman, "players", (3, 3)) engine = make_engine([state]) - threatened = engine._threatened_squares(state) + threatened = engine.threatened_squares(state) assert threatened == frozenset(_ring((3, 3), 2)) assert (3, 2) not in threatened # adjacent orthogonal 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)) engine = make_engine([state]) - assert engine._threatened_squares(state) == frozenset( + assert engine.threatened_squares(state) == frozenset( _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: expected |= _disk(cell, 2) expected -= footprint - assert engine._threatened_squares(state) == frozenset(expected) + assert engine.threatened_squares(state) == frozenset(expected) 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") state = make_state(weird, "players", (3, 3)) 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: