"""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()