diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index b7cd42b..c68ce63 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -138,6 +138,17 @@ _SIZE_MODIFIERS: dict[str, int] = { "medium": 0, "large": 1, "huge": 2, "gargantuan": 4, "colossal": 8, } +# PF1e natural reach for tall creatures (simplification: no long/quadruped +# distinction). Unknown sizes fall back to 5 ft (the historical behavior). +_SIZE_NATURAL_REACH_FT: dict[str, int] = { + "Small": 5, + "Medium": 5, + "Large": 10, + "Huge": 15, + "Gargantuan": 20, + "Colossal": 30, +} + # Row-major scan order of the 8 adjacent cells, used by reposition. _NEIGHBOR_OFFSETS: tuple[tuple[int, int], ...] = ( (-1, -1), (-1, 0), (-1, 1), (0, -1), (0, 1), (1, -1), (1, 0), (1, 1), @@ -162,6 +173,11 @@ _STEP_DELTAS: tuple[Pos, ...] = ( ) +def _effective_reach_ft(state: CombatantState, weapon: AttackSpec) -> int: + """Effective melee reach in feet: max of natural size reach and weapon reach.""" + return max(_SIZE_NATURAL_REACH_FT.get(state.combatant.size, 5), weapon.reach_ft) + + def _bresenham_line(start: Pos, end: Pos) -> list[Pos]: """Integer Bresenham line from start to end (exclusive of start, inclusive of end).""" r0, c0 = start @@ -547,8 +563,13 @@ class CombatEngine: return None dist_ft = self._grid.distance(attacker.pos, target.pos) * _SQUARE_FT for weapon in attacker.usable_attacks(): - if weapon.kind in ("melee", "touch") and dist_ft <= weapon.reach_ft: - return weapon + if weapon.kind in ("melee", "touch"): + effective_reach_ft = _effective_reach_ft(attacker, weapon) + if weapon.reach_ft > _SQUARE_FT: + if _SQUARE_FT < dist_ft <= effective_reach_ft: + return weapon + elif dist_ft <= effective_reach_ft: + return weapon max_increments = ( _MAX_TOUCH_RANGE_INCREMENTS if weapon.kind == "touch" @@ -964,13 +985,42 @@ class CombatEngine: return None def _threatened_squares(self, state: CombatantState) -> frozenset[Pos]: - """Squares this combatant threatens with a melee weapon (8 adjacent for 5 ft reach).""" + """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. + """ if not state.active: return frozenset() - if not any(w.kind == "melee" for w in state.usable_attacks()): + melee = [w for w in state.usable_attacks() if w.kind == "melee"] + if not melee: return frozenset() + threatened: set[Pos] = set() + for weapon in melee: + threatened |= self._weapon_threatened_squares(state, weapon) + return frozenset(threatened) + + def _weapon_threatened_squares(self, state: CombatantState, weapon: AttackSpec) -> set[Pos]: + """Squares one melee weapon threatens: disk for non-reach, ring for reach.""" r, c = state.pos - return frozenset((r + dr, c + dc) for dr, dc in _STEP_DELTAS) + threatened: set[Pos] = set() + if weapon.reach_ft > _SQUARE_FT: + ring = weapon.reach_ft // _SQUARE_FT + for dr in range(-ring, ring + 1): + for dc in range(-ring, ring + 1): + if self._grid.distance(state.pos, (r + dr, c + dc)) == ring: + threatened.add((r + dr, c + dc)) + else: + reach_cells = _effective_reach_ft(state, weapon) // _SQUARE_FT + for dr in range(-reach_cells, reach_cells + 1): + for dc in range(-reach_cells, reach_cells + 1): + if (dr, dc) == (0, 0): + continue + if self._grid.distance(state.pos, (r + dr, c + dc)) <= reach_cells: + threatened.add((r + dr, c + dc)) + return threatened def _enemies_threatening(self, state: CombatantState, pos: Pos) -> list[CombatantState]: """Active enemies whose threatened squares include pos (in self._states order).""" @@ -1111,8 +1161,11 @@ class CombatEngine: for weapon in state.usable_attacks(): if weapon.kind != "melee": continue - reach_cells = weapon.reach_ft // _SQUARE_FT - end = self._charge_end(state, target, reach_cells, max_cells, blocked) + reach_cells = _effective_reach_ft(state, weapon) // _SQUARE_FT + min_reach_cells = weapon.reach_ft // _SQUARE_FT if weapon.reach_ft > _SQUARE_FT else 0 + end = self._charge_end( + state, target, reach_cells, max_cells, blocked, min_reach_cells=min_reach_cells + ) if end is not None: return weapon return None @@ -1124,6 +1177,8 @@ class CombatEngine: reach_cells: int, max_cells: int, blocked: frozenset[Pos], + *, + min_reach_cells: int = 0, ) -> Pos | None: """Find the closest square from which ``state`` can melee ``target`` via a charge. @@ -1133,7 +1188,9 @@ class CombatEngine: Ties break toward lower distance from state. """ candidates: list[tuple[int, Pos]] = [] - for end in self._charge_candidates(target, reach_cells, blocked, state.pos): + for end in self._charge_candidates( + target, reach_cells, blocked, state.pos, min_reach_cells + ): path = _bresenham_line(state.pos, end) if len(path) < _CHARGE_MIN_CELLS or len(path) > max_cells: continue @@ -1151,6 +1208,7 @@ class CombatEngine: reach_cells: int, blocked: frozenset[Pos], start: Pos, + min_reach_cells: int = 0, ) -> list[Pos]: """Squares within reach of target, passable, unoccupied, not the start.""" tr, tc = target.pos @@ -1168,7 +1226,8 @@ class CombatEngine: continue if end in blocked: continue - if self._grid.distance(end, target.pos) > reach_cells: + dist = self._grid.distance(end, target.pos) + if dist > reach_cells or dist < min_reach_cells: continue result.append(end) return result @@ -1194,13 +1253,16 @@ class CombatEngine: (cleared in ``_take_turn``). Movement during a charge provokes AoOs step-by-step as for any move action. """ - reach_cells = weapon.reach_ft // _SQUARE_FT + reach_cells = _effective_reach_ft(attacker, weapon) // _SQUARE_FT + min_reach_cells = weapon.reach_ft // _SQUARE_FT if weapon.reach_ft > _SQUARE_FT else 0 speed_cells = attacker.combatant.speed_land_ft // _SQUARE_FT max_cells = 2 * speed_cells blocked = frozenset( s.pos for s in self._states if s is not attacker and s is not target and s.active ) - end = self._charge_end(attacker, target, reach_cells, max_cells, blocked) + end = self._charge_end( + attacker, target, reach_cells, max_cells, blocked, min_reach_cells=min_reach_cells + ) if end is None: return path = _bresenham_line(attacker.pos, end) diff --git a/tests/test_reach.py b/tests/test_reach.py new file mode 100644 index 0000000..bbd1fa2 --- /dev/null +++ b/tests/test_reach.py @@ -0,0 +1,286 @@ +"""Tests for B5.3: melee reach depends on weapon reach and creature size. + +Covers the PF1e reach rules implemented in this phase: +- 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 (the PF1e reach dead zone: a longspear cannot hit an adjacent target); +- a Large creature's natural reach (10 ft) extends its non-reach weapons; +- ``weapon_for`` respects the reach dead zone; +- AoOs trigger from ring squares threatened by a reach weapon. +""" + +from __future__ import annotations + +from pf1e_simulator.combat import Action, 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 (used by most reach tests).""" + 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 _grid_distance(a: tuple[int, int], b: tuple[int, int]) -> int: + """5-10-5 diagonal distance in squares (mirrors Grid.distance).""" + d_row = abs(a[0] - b[0]) + d_col = abs(a[1] - b[1]) + return max(d_row, d_col) + min(d_row, d_col) // 2 + + +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 _grid_distance(pos, (r + dr, c + dc)) == radius + } + + +def _disk(pos: tuple[int, int], radius: int) -> set[tuple[int, int]]: + """Cells within ``radius`` squares of ``pos`` (5-10-5 distance), excluding pos.""" + 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 _grid_distance(pos, (r + dr, c + dc)) <= radius + } + + +def _adjacent(pos: tuple[int, int]) -> set[tuple[int, int]]: + """The 8 cells adjacent to ``pos``.""" + return _disk(pos, 1) + + +def test_medium_5ft_weapon_threatens_adjacent_only() -> 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_reach_weapon_threatens_ring_not_adjacent() -> 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 + assert (3, 4) not in threatened + assert (2, 2) not in threatened # adjacent diagonal + assert (4, 4) not in threatened + + +def test_sword_plus_reach_weapon_union() -> None: + """A combatant with a 5 ft sword and a reach weapon threatens disk(1) plus ring(2).""" + sword = AttackSpec( + id="sword", + name="short sword", + kind="melee", + attack_bonus=5, + damage=[DamageComponent(formula=parse_dice("1d6"), types=["slashing"])], + reach_ft=5, + ) + spear = AttackSpec( + id="spear", + name="longspear", + kind="melee", + attack_bonus=5, + damage=[DamageComponent(formula=parse_dice("1d6"), types=["piercing"])], + reach_ft=10, + ) + combatant = Combatant( + id="dual", + name="dual", + level=1, + size="Medium", + abilities=AbilityScores( + str_score=10, + dex_score=10, + con_score=12, + int_score=10, + wis_score=10, + cha_score=10, + ), + hp_max=20, + ac=ACProfile(total=15, touch=15, flat_footed=15), + bab=5, + initiative_mod=0, + speed_land_ft=30, + saves=Saves(fort=0, ref=0, will=0), + attacks=[sword, spear], + ) + state = make_state(combatant, "players", (3, 3)) + engine = make_engine([state]) + assert engine._threatened_squares(state) == frozenset( + _adjacent((3, 3)) | _ring((3, 3), 2) + ) + + +def test_large_creature_sword_threatens_disk_radius_2() -> None: + """A Large creature (natural reach 10 ft) with a 5 ft sword threatens the disk of radius 2.""" + giant = make_combatant("giant", size="Large") + state = make_state(giant, "players", (3, 3)) + engine = make_engine([state]) + assert engine._threatened_squares(state) == frozenset(_disk((3, 3), 2)) + + +def test_unknown_size_defaults_to_5ft() -> None: + """An unlisted size falls back to the current 5 ft natural reach.""" + 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))) + + +def test_weapon_for_reach_weapon_at_distance_2_not_1() -> None: + """weapon_for selects a reach weapon at distance 2 but not at distance 1 (dead zone).""" + spearman = make_combatant("spearman", reach_ft=10) + target = make_combatant("target") + attacker = make_state(spearman, "players", (3, 3)) + + foe_at_ring = make_state(target, "monsters", (3, 5)) # distance 2 + engine_ring = make_engine([attacker, foe_at_ring]) + weapon = engine_ring.weapon_for(attacker, foe_at_ring) + assert weapon is not None + assert weapon.reach_ft == 10 + + foe_adjacent = make_state(target, "monsters", (3, 4)) # distance 1 + engine_adjacent = make_engine([attacker, foe_adjacent]) + assert engine_adjacent.weapon_for(attacker, foe_adjacent) is None + + +def test_weapon_for_large_creature_sword_at_distance_2() -> None: + """A Large creature's 5 ft sword is selectable at distance 2 (natural reach 10 ft).""" + giant = make_combatant("giant", size="Large") + target = make_combatant("target") + attacker = make_state(giant, "players", (3, 3)) + foe = make_state(target, "monsters", (3, 5)) # distance 2 + engine = make_engine([attacker, foe]) + weapon = engine.weapon_for(attacker, foe) + assert weapon is not None + assert weapon.reach_ft == 5 + + +def _move_to_dest(engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]: + """Test policy: always move toward the 'dest' combatant.""" + return (Action(kind="move", target_id="dest"),) + + +def test_aoo_from_reach_ring() -> None: + """Moving through a ring square threatened by a longspear provokes an AoO.""" + runner = make_combatant("runner", hp=20, ac=12, speed=30) + spearman = make_combatant("spearman", hp=20, ac=15, attack_bonus=5, speed=0, reach_ft=10) + dest = make_combatant("dest", hp=20, ac=15, attack_bonus=0, speed=0) + states = [ + make_state(runner, "players", (4, 3)), + make_state(spearman, "monsters", (3, 1)), + make_state(dest, "monsters", (4, 5)), + ] + engine = CombatEngine( + ScriptedRng([10, 9, 8, 15, 3, 5]), + make_grid(), states, round_cap=1, policy=_move_to_dest, + ) + result = engine.run() + assert result.transcript == ( + "initiative: runner d20=10+0=10", + "initiative: spearman d20=9+0=9", + "initiative: dest d20=8+0=8", + "round 1 spearman: AoO vs runner d20=15+5=20 AC 12 -> HIT 3 damage (20->17)", + "round 1 runner: move (4,3)->(3,4)", + "round 1 spearman: wait", + "round 1 dest: wait", + "battle over: draw after 1 rounds", + ) + + +def test_no_aoo_from_adjacent_to_reach_weapon() -> None: + """A longspear does not threaten adjacent squares: no AoO when leaving one.""" + runner = make_combatant("runner", hp=20, ac=12, speed=30) + spearman = make_combatant("spearman", hp=20, ac=15, attack_bonus=5, speed=0, reach_ft=10) + dest = make_combatant("dest", hp=20, ac=15, attack_bonus=0, speed=0) + states = [ + make_state(runner, "players", (4, 1)), + make_state(spearman, "monsters", (3, 1)), + make_state(dest, "monsters", (4, 3)), + ] + engine = CombatEngine( + ScriptedRng([10, 9, 8, 15, 3, 5]), + make_grid(), states, round_cap=1, policy=_move_to_dest, + ) + result = engine.run() + assert result.transcript == ( + "initiative: runner d20=10+0=10", + "initiative: spearman d20=9+0=9", + "initiative: dest d20=8+0=8", + "round 1 runner: move (4,1)->(3,2)", + "round 1 spearman: wait", + "round 1 dest: wait", + "battle over: draw after 1 rounds", + )