"""Tests for B5.3: melee reach depends on weapon reach and creature size. Covers the PF1e reach rules implemented in this phase (R1 conformance: CRB "Big and Little Creatures in Combat" — natural reach is a disk, a reach weapon is a ring): - natural reach (plain weapons and natural attacks) threatens the DISK of effective reach: a Large creature's 10 ft bite still strikes and threatens ADJACENT squares; - reach weapons (``reach_weapon=True``) threaten only the RING beyond the wielder's natural reach up to double it (the PF1e reach dead zone: a longspear cannot hit an adjacent target); - ``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, reach_weapon: bool = False, 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_weapon else "short sword", kind="melee", attack_bonus=attack_bonus, damage=[DamageComponent(formula=parse_dice("1d6"), types=["piercing"])], reach_ft=reach_ft, reach_weapon=reach_weapon, ) 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 weapon) threatens the ring at distance 2, not adjacent squares. R1: the ring is now driven by ``reach_weapon=True``, not by ``reach_ft > 5``. """ spearman = make_combatant("spearman", reach_ft=10, reach_weapon=True) 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, reach_weapon=True, ) 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 (2x2 footprint, natural reach 10 ft) with a 5 ft sword threatens the union of radius-2 disks around each occupied cell.""" giant = make_combatant("giant", size="Large") state = make_state(giant, "players", (3, 3)) engine = make_engine([state]) footprint = state.occupied_cells() expected = set() for cell in footprint: expected |= _disk(cell, 2) expected -= footprint assert engine.threatened_squares(state) == frozenset(expected) 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))) # ── R1 conformance: natural reach is a disk, a reach weapon is a ring ──────── def _natural_attack_combatant( cid: str, *, size: str, reach_ft: int, reach_weapon: bool ) -> Combatant: """Combatant with one melee attack carrying the given reach flags.""" attack = AttackSpec( id=f"{cid}-w", name="bite" if not reach_weapon else "longspear", kind="melee", attack_bonus=5, damage=[DamageComponent(formula=parse_dice("1d6"), types=["piercing"])], reach_ft=reach_ft, reach_weapon=reach_weapon, ) 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=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=[attack], ) def test_large_natural_attack_threatens_adjacent_disk() -> None: """R1 (CRB "Big and Little Creatures in Combat"): a Large creature's natural 10 ft bite is a DISK — it threatens adjacent squares too, unlike a reach weapon's ring. The old model keyed the ring off ``reach_ft > 5`` and wrongly gave the bite a 5 ft dead zone (giant_belostomatid.json, esha_mount.json, jeanne.json all have reach-10 natural attacks).""" bug = _natural_attack_combatant("bug", size="Large", reach_ft=10, reach_weapon=False) state = make_state(bug, "players", (3, 3)) engine = make_engine([state]) threatened = engine.threatened_squares(state) footprint = state.occupied_cells() expected: set[tuple[int, int]] = set() for cell in footprint: expected |= _disk(cell, 2) expected -= footprint assert threatened == frozenset(expected) # Adjacent to the footprint (1 cell) is threatened: the disk, not a ring. assert (3, 2) in threatened assert (2, 2) in threatened def _bug_attack_policy(engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]: if state.combatant.id == "bug": return (Action(kind="attack", target_id="rider-target"),) return (Action(kind="wait"),) def test_large_natural_attack_hits_adjacent_target() -> None: """R1: the reach-10 natural attack of a Large creature STRIKES an adjacent target (disk), which the old ring model refused.""" bug = _natural_attack_combatant("bug", size="Large", reach_ft=10, reach_weapon=False) target = make_combatant("rider-target", hp=20, ac=12) bug_state = make_state(bug, "players", (3, 3)) target_state = make_state(target, "monsters", (3, 5)) # adjacent to (3, 4) engine = CombatEngine( ScriptedRng([20, 1, 15, 3]), make_grid(), [bug_state, target_state], round_cap=1, policy=_bug_attack_policy, ) result = engine.run() assert any("bite vs rider-target" in line and "-> HIT" in line for line in result.transcript), result.transcript assert target_state.hp == 17 # 20 - 1d6(3) def test_large_reach_weapon_ring_beyond_natural_reach() -> None: """R1 (CRB): "Large or larger creatures using reach weapons can strike up to double their natural reach but can't strike at their natural reach or less" — for a Large longspear wielder the usable band is 15..20 ft: dead at 10 ft (natural reach), live at 15 ft, dead beyond 20 ft.""" giant = _natural_attack_combatant( "giant", size="Large", reach_ft=10, reach_weapon=True ) target = make_combatant("target") attacker = make_state(giant, "players", (3, 3)) # Footprint spans cols 3-4; distance = foe_col - 4 cells (x5 ft). # (3, 6): 10 ft = natural reach -> dead zone. (3, 7): 15 ft -> in ring. for foe_col, in_ring in ((6, False), (7, True)): foe = make_state(target, "monsters", (3, foe_col)) engine = make_engine([attacker, foe]) weapon = engine.weapon_for(attacker, foe) assert (weapon is not None) is in_ring, f"distance {foe_col - 4} cells" 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, reach_weapon=True) 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, reach_weapon=True, ) 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, reach_weapon=True, ) 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", ) # ── S4c: Tiny/Diminutive/Fine natural reach 0 and co-occupancy ─────────────── def test_tiny_reach_zero_threatens_nothing() -> None: """S4c: a Tiny creature's natural reach is 0 — it threatens no squares (no AoO) and cannot strike adjacent squares.""" tiny = make_combatant("tiny", size="Tiny", reach_ft=0) state = make_state(tiny, "players", (3, 3)) engine = make_engine([state]) assert engine.threatened_squares(state) == frozenset() assert (3, 2) not in engine.threatened_squares(state) def test_tiny_reach_zero_attacks_own_cell_only() -> None: """S4c: a 0-reach Tiny can attack a creature in its OWN cell (distance 0) but not an adjacent one (distance 1).""" tiny = make_combatant("tiny", size="Tiny", reach_ft=0) target = make_combatant("target", hp=20, ac=12) tiny_state = make_state(tiny, "players", (3, 3)) # Same cell: distance 0 -> the 0-reach attack is usable. foe_same = make_state(target, "monsters", (3, 3)) engine_same = make_engine([tiny_state, foe_same]) assert engine_same.weapon_for(tiny_state, foe_same) is not None # Adjacent cell: distance 1 (5 ft) -> out of the 0-reach zone. foe_adj = make_state(target, "monsters", (3, 4)) engine_adj = make_engine([tiny_state, foe_adj]) assert engine_adj.weapon_for(tiny_state, foe_adj) is None def _tiny_attack_policy(engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]: if state.combatant.id == "tiny": return (Action(kind="attack", target_id="target"),) return (Action(kind="wait"),) def test_tiny_reach_zero_hits_creature_in_own_cell() -> None: """S4c: a 0-reach Tiny strikes a creature sharing its cell (the 'entered the square' abstraction) — the attack resolves at distance 0.""" tiny = make_combatant("tiny", size="Tiny", reach_ft=0) target = make_combatant("target", hp=20, ac=12) tiny_state = make_state(tiny, "players", (3, 3)) target_state = make_state(target, "monsters", (3, 3)) engine = CombatEngine( ScriptedRng([20, 1, 15, 3]), make_grid(), [tiny_state, target_state], round_cap=1, policy=_tiny_attack_policy, ) result = engine.run() assert any( "short sword vs target" in line and "-> HIT" in line for line in result.transcript ), result.transcript assert target_state.hp == 17 # 20 - 1d6(3) def _tiny_move_policy(engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]: if state.combatant.id == "tiny": return (Action(kind="move", destination=(3, 3)),) return (Action(kind="wait"),) def test_tiny_ends_move_in_medium_cell() -> None: """S4c: a Tiny creature may END its move in a cell occupied by a larger creature (co-occupancy).""" tiny = make_combatant("tiny", size="Tiny", reach_ft=0) medium = make_combatant("medium", hp=20, ac=15, speed=0) tiny_state = make_state(tiny, "players", (3, 2)) medium_state = make_state(medium, "monsters", (3, 3)) engine = CombatEngine( ScriptedRng([20, 1, 5]), make_grid(), [tiny_state, medium_state], round_cap=1, policy=_tiny_move_policy, ) result = engine.run() assert any("move -> (3,3)" in line for line in result.transcript), result.transcript assert tiny_state.pos == (3, 3) def _medium_move_policy(engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]: if state.combatant.id == "a": return (Action(kind="move", destination=(3, 3)),) return (Action(kind="wait"),) def test_two_medium_cannot_share_cell() -> None: """S4c: two Medium creatures still cannot share a cell — the move onto the occupied cell is rejected.""" a = make_combatant("a", speed=30) b = make_combatant("b", hp=20, ac=15, speed=0) a_state = make_state(a, "players", (3, 2)) b_state = make_state(b, "monsters", (3, 3)) engine = CombatEngine( ScriptedRng([20, 1]), make_grid(), [a_state, b_state], round_cap=1, policy=_medium_move_policy, ) result = engine.run() assert any( "move rejected (unreachable_destination)" in line for line in result.transcript ), result.transcript assert a_state.pos == (3, 2) def test_observation_exposes_natural_reach_ft() -> None: """S4c: the combatant snapshot exposes the size's natural reach.""" tiny = make_combatant("tiny", size="Tiny", reach_ft=0) medium = make_combatant("medium") tiny_state = make_state(tiny, "players", (3, 3)) medium_state = make_state(medium, "monsters", (3, 5)) engine = make_engine([tiny_state, medium_state]) obs = engine._build_observation(tiny_state).to_dict() combatants = {c["id"]: c for c in obs["combatants"]} assert combatants["tiny"]["natural_reach_ft"] == 0 assert combatants["medium"]["natural_reach_ft"] == 5