diff --git a/README.md b/README.md index fd602a6..2e42afe 100644 --- a/README.md +++ b/README.md @@ -269,7 +269,13 @@ jusqu'à 10 incréments, une arme `touch` jusqu'à 5 (jet/firearm), au-delà ell n'est pas sélectionnable. `damage_bonus` et `dr` sont optionnels. Le booléen `two_handed` (optionnel, défaut `false`) marque une arme tenue à deux mains : Power Attack donne alors +3X (et non +2X) aux dégâts (conformance -B16). `spell_resistance` (entier), `resistances` (dict type → montant) et +B16). Le booléen `reach_weapon` (optionnel, défaut `false`) marque une arme +dotée de la qualité « reach » du CRB (lance, glaive, longspear, etc.) : elle +double l'allonge naturelle du porteur mais ne frappe pas à cette allonge +naturelle ou moins — anneau avec dead zone (conformance R1, voir « Mêlée »). +Le `reach_ft` d'une attaque naturelle (morsure, sabots) n'est **pas** une arme +à allonge : il encode l'allonge naturelle de la créature, qui reste un disque. +`spell_resistance` (entier), `resistances` (dict type → montant) et `immunities` (liste de types) sont optionnels. Deux monstres d'exemple sont fournis : gobelin et orc. @@ -565,11 +571,19 @@ Règles modélisées : aucun jet n'est effectué. Sans `weapon_id`, la sélection automatique est inchangée. `default_policy` ne choisit pas d'arme (disponible via une politique personnalisée). -- Mêlée : allonge effective = max(allonge naturelle du gabarit (Small/Medium - 5 ft, Large 10 ft, Huge 15 ft, Gargantuan 20 ft, Colossal 30 ft), `reach_ft` - de l'arme). Une arme à allonge (`reach_ft` > 5) menace l'anneau de cases à - distance égale à son allonge — les cases à 5 ft sont une « dead zone » où - elle ne frappe pas ; une arme sans allonge menace les cases adjacentes. +- Mêlée (conformance R1, CRB « Big and Little Creatures in Combat ») : + l'**allonge naturelle** du gabarit (Small/Medium 5 ft, Large 10 ft, Huge + 15 ft, Gargantuan 20 ft, Colossal 30 ft) est un **disque** : la créature + frappe et menace toutes les cases jusqu'à son allonge, l'adjacence + comprise — une créature Large dont l'attaque naturelle a `reach_ft` 10 + menace donc aussi les cases adjacentes (le `reach_ft` d'une attaque + naturelle EST son allonge naturelle). Une **arme à allonge** + (`reach_weapon: true` dans le JSON) est un **anneau** : elle double + l'allonge naturelle du porteur mais ne frappe pas à l'allonge naturelle ou + moins — la zone utilisable est `allonge naturelle < distance ≤ 2 × + allonge naturelle`, la dead zone vaut donc 5 ft pour un porteur + Small/Medium et 10 ft pour un porteur Large. Une arme ordinaire sans + allonge menace les cases adjacentes (disque de 5 ft). Mouvement : un déplacement parcourt jusqu'à la vitesse du combattant en cases le long du plus court chemin réel (champ de coût Dijkstra depuis la cible, diagonales 5-10-5), en s'arrêtant adjacent à @@ -647,9 +661,9 @@ Règles modélisées : (étourdi/paralysé ne flanque pas). Les attaques à distance ignorent le flanquement ; un allié désarmé ou à distance ne compte pas. - Attaques d'opportunité : un combattant actif doté d'une arme de mêlée menace - les cases à distance de son allonge effective (les 8 cases adjacentes pour - une arme sans allonge ; l'anneau à son allonge pour une arme reach — la - dead zone de 5 ft n'est pas menacée). Deux déclencheurs sont modélisés : (1) quitter une + les cases de sa zone d'allonge (voir « Mêlée » : le disque de l'allonge + naturelle — les 8 cases adjacentes pour un Small/Medium sans allonge — ou + l'anneau d'une arme à allonge, dont la dead zone n'est pas menacée). Deux déclencheurs sont modélisés : (1) quitter une case menacée — le mouvement est résolu pas à pas, et chaque ennemi qui menace la case actuelle porte une AoO avant que le mouvant ne la quitte ; (2) tirer avec une arme à distance depuis une case menacée — chaque ennemi menaçant diff --git a/data/pcs/esha.json b/data/pcs/esha.json index 97734ef..1e9cd1a 100644 --- a/data/pcs/esha.json +++ b/data/pcs/esha.json @@ -44,6 +44,7 @@ "crit_range": 20, "crit_mult": 3, "reach_ft": 10, + "reach_weapon": true, "count": 1 }, { diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index c3f735e..6f72c6d 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -395,8 +395,36 @@ _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) + """Outer edge, in feet, of a melee attack's usable reach zone. + + PF1e distinguishes two cases (CRB "Big and Little Creatures in Combat"): + a creature's NATURAL reach is a disk, while a reach weapon doubles that + natural reach but cannot strike at the wielder's natural reach or less + (a ring). See ``_melee_reach_zone_cells`` for the full zone. + """ + natural_ft = _SIZE_NATURAL_REACH_FT.get(state.combatant.size, 5) + if weapon.reach_weapon: + return 2 * natural_ft + return max(natural_ft, weapon.reach_ft) + + +def _melee_reach_zone_cells(state: CombatantState, weapon: AttackSpec) -> tuple[int, int]: + """Inclusive grid-cell reach zone ``(inner, outer)`` of one melee attack. + + PF1e, CRB "Big and Little Creatures in Combat": "Unlike when someone + uses a reach weapon, a creature with greater than normal natural reach + (more than 5 feet) still threatens squares adjacent to it" — natural + reach is a DISK, so the zone is ``0 .. max(natural, weapon.reach_ft)`` + (a natural attack's ``reach_ft`` IS the creature's natural reach). A + reach weapon instead "can strike up to double their natural reach but + can't strike at their natural reach or less" — the zone is the RING + ``natural+1 .. 2 * natural``, whose dead zone is the wielder's natural + reach. + """ + natural_cells = _SIZE_NATURAL_REACH_FT.get(state.combatant.size, 5) // _SQUARE_FT + inner_cells = natural_cells + 1 if weapon.reach_weapon else 0 + outer_cells = _effective_reach_ft(state, weapon) // _SQUARE_FT + return inner_cells, outer_cells def _speed_ft(state: CombatantState) -> int: @@ -1961,11 +1989,8 @@ class CombatEngine: return False dist_ft = self.creature_distance(attacker, target) * _SQUARE_FT 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 True - elif dist_ft <= effective_reach_ft: + inner_cells, outer_cells = _melee_reach_zone_cells(attacker, weapon) + if inner_cells * _SQUARE_FT <= dist_ft <= outer_cells * _SQUARE_FT: return True max_increments = ( _MAX_TOUCH_RANGE_INCREMENTS @@ -2782,9 +2807,10 @@ class CombatEngine: def threatened_squares(self, state: CombatantState) -> frozenset[Pos]: """Squares this combatant threatens with a melee weapon. - (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 + (API publique, lecture pour politique LLM.) Natural reach (plain + weapons and natural attacks) is a disk: every square up to the + effective reach, adjacency included. A reach weapon threatens only + the ring beyond the wielder's natural reach up to double it (PF1e reach dead zone). The union over usable melee weapons is returned. """ if not state.active: @@ -2798,42 +2824,31 @@ class CombatEngine: 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. + """Squares one melee weapon threatens: disk for natural reach, ring for reach weapons. The union over every occupied cell of the creature's footprint; the creature's own cells are never threatened. """ + inner_cells, outer_cells = _melee_reach_zone_cells(state, weapon) threatened: set[Pos] = set() - if weapon.reach_ft > _SQUARE_FT: - ring = weapon.reach_ft // _SQUARE_FT - for r, c in state.occupied_cells(): - threatened |= self._ring_cells((r, c), ring) - else: - reach_cells = _effective_reach_ft(state, weapon) // _SQUARE_FT - for r, c in state.occupied_cells(): - threatened |= self._disk_cells((r, c), reach_cells) + for r, c in state.occupied_cells(): + threatened |= self._reach_band_cells((r, c), inner_cells, outer_cells) threatened -= state.occupied_cells() return threatened - def _ring_cells(self, center: Pos, ring: int) -> set[Pos]: - """Cells at exactly ``ring`` squares from ``center`` (5-10-5 distance).""" - r, c = center - return { - (r + dr, c + dc) - for dr in range(-ring, ring + 1) - for dc in range(-ring, ring + 1) - if self._grid.distance(center, (r + dr, c + dc)) == ring - } + def _reach_band_cells(self, center: Pos, inner: int, outer: int) -> set[Pos]: + """Cells whose 5-10-5 distance from ``center`` lies in ``[inner, outer]``. - def _disk_cells(self, center: Pos, radius: int) -> set[Pos]: - """Cells within ``radius`` squares of ``center`` (5-10-5 distance).""" + ``inner == 0`` gives the plain disk (the center itself is never + included); ``inner > 0`` gives the reach-weapon ring dead-zone band. + """ r, c = center return { (r + dr, c + dc) - for dr in range(-radius, radius + 1) - for dc in range(-radius, radius + 1) + for dr in range(-outer, outer + 1) + for dc in range(-outer, outer + 1) if (dr, dc) != (0, 0) - and self._grid.distance(center, (r + dr, c + dc)) <= radius + and inner <= self._grid.distance(center, (r + dr, c + dc)) <= outer } def enemies_threatening(self, state: CombatantState, pos: Pos) -> list[CombatantState]: @@ -3042,10 +3057,9 @@ class CombatEngine: if not melee_weapons: return None for weapon in melee_weapons: - 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 + inner_cells, reach_cells = _melee_reach_zone_cells(state, weapon) end = self._charge_end( - state, target, reach_cells, max_cells, blocked, min_reach_cells=min_reach_cells + state, target, reach_cells, max_cells, blocked, min_reach_cells=inner_cells ) if end is not None: distance_ft = self._grid.distance(state.pos, end) * _SQUARE_FT @@ -3144,13 +3158,12 @@ class CombatEngine: """ if attacker.moved_this_turn: return - 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 + inner_cells, reach_cells = _melee_reach_zone_cells(attacker, weapon) speed_cells = _speed_ft(attacker) // _SQUARE_FT max_cells = 2 * speed_cells blocked = self._blocked(attacker, target) end = self._charge_end( - attacker, target, reach_cells, max_cells, blocked, min_reach_cells=min_reach_cells + attacker, target, reach_cells, max_cells, blocked, min_reach_cells=inner_cells ) if end is None: return @@ -5568,10 +5581,8 @@ class CombatEngine: ): return False dist_ft = self.creature_distance(attacker, target) * _SQUARE_FT - effective_reach_ft = _effective_reach_ft(attacker, weapon) - if weapon.reach_ft > _SQUARE_FT: - return _SQUARE_FT < dist_ft <= effective_reach_ft - return dist_ft <= effective_reach_ft + inner_cells, outer_cells = _melee_reach_zone_cells(attacker, weapon) + return inner_cells * _SQUARE_FT <= dist_ft <= outer_cells * _SQUARE_FT def _step5(self, state: CombatantState, target: CombatantState) -> None: if state.moved_this_turn: diff --git a/src/pf1e_simulator/models.py b/src/pf1e_simulator/models.py index 3beaca8..f376ec0 100644 --- a/src/pf1e_simulator/models.py +++ b/src/pf1e_simulator/models.py @@ -242,7 +242,14 @@ class AttackSpec(_FrozenModel): increment and against full AC beyond it. `two_handed` marks a weapon swung in two hands (two-handed weapon, one-handed weapon used two hands, or a primary natural with 1.5x Strength) — PF1e Power Attack rolls +50% damage - with such a weapon (CRB "Power Attack"). + with such a weapon (CRB "Power Attack"). `reach_weapon` marks a weapon + with the reach weapon quality (CRB "Big and Little Creatures in Combat"): + it doubles the wielder's NATURAL reach but cannot strike at the wielder's + natural reach or less — the usable zone is a RING (dead zone = natural + reach). Without the flag a melee attack follows the creature's natural + reach, which is a DISK: a Large creature's natural attack (its JSON + `reach_ft` IS its natural reach, 10 ft) still strikes and threatens + adjacent squares. """ id: str @@ -254,6 +261,7 @@ class AttackSpec(_FrozenModel): crit_range: int = Field(default=20, ge=2, le=20) crit_mult: int = Field(default=2, ge=2, le=4) reach_ft: int = 5 + reach_weapon: bool = False range_increment_ft: int | None = None count: int = Field(default=1, ge=1) firearm: bool = False diff --git a/tests/test_conformance_p2.py b/tests/test_conformance_p2.py index 0a3d7a9..7294dac 100644 --- a/tests/test_conformance_p2.py +++ b/tests/test_conformance_p2.py @@ -71,6 +71,7 @@ def _reach_sword(cid: str) -> AttackSpec: attack_bonus=5, damage=[DamageComponent(formula=parse_dice("1d6"), types=["slashing"])], reach_ft=10, + reach_weapon=True, ) diff --git a/tests/test_creature_sizes.py b/tests/test_creature_sizes.py index f5d26a8..453bdc1 100644 --- a/tests/test_creature_sizes.py +++ b/tests/test_creature_sizes.py @@ -47,6 +47,7 @@ def make_combatant( *, size: str = "Medium", reach_ft: int = 5, + reach_weapon: bool = False, hp: int = 20, ac: int = 15, attack_bonus: int = 5, @@ -61,6 +62,7 @@ def make_combatant( attack_bonus=attack_bonus, damage=[DamageComponent(formula=parse_dice("1d6"), types=["piercing"])], reach_ft=reach_ft, + reach_weapon=reach_weapon, ) return Combatant( id=cid, @@ -266,12 +268,23 @@ class TestThreatenedSquaresLarge: assert (5, 5) not in threatened def test_large_reach_weapon_threatens_union_of_rings(self) -> None: - """A Large at (2,2) with a reach weapon (10ft) threatens ring(2) from each cell.""" + """A Large at (2,2) wielding a reach weapon threatens the ring beyond its + 10 ft natural reach, up to 20 ft (5-10-5 cells 3..4). + + R1 conformance: CRB "Big and Little Creatures in Combat" — "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." The + previous expectation (ring at exactly 2 cells, i.e. 10 ft) encoded the + old bug where a reach weapon's ring was keyed off ``reach_ft`` instead + of the wielder's natural reach, so it put the whole natural disk in the + dead zone. + """ 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="g", name="g", level=1, size="Large", @@ -288,11 +301,18 @@ class TestThreatenedSquaresLarge: state = make_state(combatant, "p", (2, 2)) engine = make_engine([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. - assert (1, 3) in threatened - assert (3, 5) in threatened + # Dead zone: within the 10 ft natural reach of every occupied cell. + # (1, 3) is adjacent to (2, 3); (1, 2) is at most 2 cells from each. + assert (1, 3) not in threatened + assert (1, 2) not in threatened + # Ring: 15 ft (3 cells) and 20 ft (4 cells) out are threatened. + # (5, 2) is 3 cells from the occupied cell (2, 2). + assert (5, 2) in threatened + # (6, 2) is 4 cells from (2, 2). + assert (6, 2) in threatened + # Beyond double natural reach: (8, 8) is 5+ cells from every + # occupied cell ((3, 3) -> d_row=5, d_col=5 -> 5+2=7). + assert (8, 8) not in threatened # --------------------------------------------------------------------------- @@ -401,9 +421,10 @@ class TestLargeAoO: get AoOs.""" giant = make_combatant("g", size="Large", speed=30, hp=30, ac=12) g_state = make_state(giant, "p", (3, 3)) - # enemy with reach weapon at (3,6) — threatens ring(2) = dist 2 from (3,6) + # enemy with a longspear (reach weapon) at (3,6) — Medium natural reach + # 5 ft, so it threatens the ring at dist 2 from (3,6) # (3,4) is at dist 2 from (3,6), so it's in the threatened zone - enemy = make_combatant("e", reach_ft=10, hp=100, speed=0) + enemy = make_combatant("e", reach_ft=10, reach_weapon=True, hp=100, speed=0) e_state = make_state(enemy, "m", (3, 6)) dest = make_combatant("d", hp=100, speed=0) d_state = make_state(dest, "e", (0, 0)) diff --git a/tests/test_exposed_squares.py b/tests/test_exposed_squares.py index 13f0f36..be8d826 100644 --- a/tests/test_exposed_squares.py +++ b/tests/test_exposed_squares.py @@ -3,8 +3,8 @@ 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. + disk of natural reach for plain/natural attacks 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. @@ -38,6 +38,7 @@ def make_combatant( *, size: str = "Medium", reach_ft: int = 5, + reach_weapon: bool = False, hp: int = 20, ac: int = 15, attack_bonus: int = 5, @@ -46,11 +47,12 @@ def make_combatant( """Build a single-melee-weapon combatant.""" attack = AttackSpec( id=f"{cid}-w", - name="longspear" if reach_ft > 5 else "short sword", + 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, @@ -126,8 +128,8 @@ def test_threatened_squares_public_non_reach_disk() -> None: 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) + """A longspear (reach weapon) threatens the ring at distance 2, not adjacent squares.""" + 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) diff --git a/tests/test_monster_loader.py b/tests/test_monster_loader.py index bac0fa4..08d79ce 100644 --- a/tests/test_monster_loader.py +++ b/tests/test_monster_loader.py @@ -36,6 +36,7 @@ def test_pc_sheets_golden_spots() -> None: "Earth Breaker", ] assert esha.attacks[0].reach_ft == 10 + assert esha.attacks[0].reach_weapon is True assert esha.attacks[1].range_increment_ft == 70 harvie = load_combatant(PCS_DIR / "harvie.json") @@ -138,6 +139,43 @@ def test_orc_golden() -> None: assert javelin.damage_bonus == 3 +def test_reach_weapon_flag_parses_and_defaults(tmp_path: Path) -> None: + """R1: ``"reach_weapon": true`` parses through the strict JSON loader and + defaults to False when absent (natural attacks stay a disk).""" + payload = _valid_payload() + attacks = payload["attacks"] + assert isinstance(attacks, list) + lance = attacks[0] + assert isinstance(lance, dict) + lance["reach_ft"] = 10 + lance["reach_weapon"] = True + combatant = load_combatant(_write(tmp_path, payload)) + assert combatant.attacks[0].reach_weapon is True + + natural = load_combatant(_write(tmp_path, _valid_payload())) + assert natural.attacks[0].reach_weapon is False + + +def test_data_reach_flags_match_raw() -> None: + """R1 data audit: the only reach weapon in the shipped data is Esha's + Lance; every Large natural attacker (bite/claw/hooves, reach_ft 10) keeps + the default False so its natural reach stays a disk.""" + flagged: list[tuple[str, str]] = [] + for path in sorted(MONSTERS_DIR.glob("*.json")) + sorted(PCS_DIR.glob("*.json")): + flagged.extend( + (path.name, attack.name) + for attack in load_combatant(path).attacks + if attack.reach_weapon + ) + assert flagged == [("esha.json", "Lance")] + belostomatid = load_combatant(MONSTERS_DIR / "giant_belostomatid.json") + assert {a.name for a in belostomatid.attacks if a.reach_ft > 5} == {"bite", "claw"} + assert all(not a.reach_weapon for a in belostomatid.attacks) + for mount in ("esha_mount.json", "jeanne.json"): + hooves = load_combatant(PCS_DIR / mount) + assert any(a.reach_ft == 10 and not a.reach_weapon for a in hooves.attacks) + + def test_monster_attack_ids_auto_assigned() -> None: goblin = load_combatant(MONSTERS_DIR / "goblin.json") assert [a.id for a in goblin.attacks] == ["goblin:0", "goblin:1"] diff --git a/tests/test_observation_enriched.py b/tests/test_observation_enriched.py index cb9037f..5a52ae2 100644 --- a/tests/test_observation_enriched.py +++ b/tests/test_observation_enriched.py @@ -702,9 +702,10 @@ def test_observation_enriched_pair_existing_keys_preserved() -> None: def make_reach_combatant(cid: str, *, reach_ft: int = 10) -> Combatant: """Build a combatant whose melee weapon has the requested reach. - ``reach_ft=10`` is a typical reach weapon (e.g. longspear): threatens - the ring at 2 squares (5-10-5) and NOT the 1-square dead zone. - ``reach_ft=5`` is a non-reach melee weapon: threatens the disk of 1 cell. + ``reach_ft=10`` is a typical reach weapon (e.g. longspear, flagged + ``reach_weapon=True``): threatens the ring at 2 squares (5-10-5) and + NOT the 1-square dead zone. ``reach_ft=5`` is a non-reach melee weapon: + threatens the disk of 1 cell. """ attack = AttackSpec( id=f"{cid}-w", @@ -713,6 +714,7 @@ def make_reach_combatant(cid: str, *, reach_ft: int = 10) -> Combatant: attack_bonus=5, damage=[DamageComponent(formula=parse_dice("1d8"), types=["piercing"])], reach_ft=reach_ft, + reach_weapon=reach_ft > 5, ) return Combatant( id=cid, diff --git a/tests/test_pair_cover.py b/tests/test_pair_cover.py index 2d8e5e3..61c51b8 100644 --- a/tests/test_pair_cover.py +++ b/tests/test_pair_cover.py @@ -82,6 +82,7 @@ def _spear(cid: str) -> AttackSpec: kind="melee", attack_bonus=5, reach_ft=10, + reach_weapon=True, damage=[DamageComponent(formula=parse_dice("1d8"), types=["piercing"])], ) diff --git a/tests/test_reach.py b/tests/test_reach.py index 681fb7d..00681ba 100644 --- a/tests/test_reach.py +++ b/tests/test_reach.py @@ -1,10 +1,14 @@ """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; +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. """ @@ -31,6 +35,7 @@ def make_combatant( *, size: str = "Medium", reach_ft: int = 5, + reach_weapon: bool = False, hp: int = 20, ac: int = 15, attack_bonus: int = 5, @@ -39,11 +44,12 @@ def make_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", + 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, @@ -125,8 +131,11 @@ def test_medium_5ft_weapon_threatens_adjacent_only() -> None: 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) + """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) @@ -154,6 +163,7 @@ def test_sword_plus_reach_weapon_union() -> None: attack_bonus=5, damage=[DamageComponent(formula=parse_dice("1d6"), types=["piercing"])], reach_ft=10, + reach_weapon=True, ) combatant = Combatant( id="dual", @@ -205,9 +215,108 @@ def test_unknown_size_defaults_to_5ft() -> None: 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) + spearman = make_combatant("spearman", reach_ft=10, reach_weapon=True) target = make_combatant("target") attacker = make_state(spearman, "players", (3, 3)) @@ -242,7 +351,9 @@ def _move_to_dest(engine: CombatEngine, state: CombatantState) -> tuple[Action, 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) + 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)), @@ -269,7 +380,9 @@ def test_aoo_from_reach_ring() -> None: 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) + 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)),