diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index 630edf2..9d1eff0 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -307,6 +307,19 @@ def _size_rank(size: str) -> int: except ValueError: return _SIZE_ORDER.index("medium") + +def _weapon_filter_matches(weapon_filter: str, weapon_name: str | None) -> bool: + """True when a feat's weapon_filter names the weapon being used. + + PF1e Weapon Focus is taken by weapon GROUP ("Pistol"), while sheets name + individual weapons ("Braises d'Automne (pistolet ?)"), so the match is a + case-insensitive substring test of the filter against the weapon name. + A None weapon name (no weapon context) never matches a filtered modifier. + """ + if weapon_name is None: + return False + return weapon_filter.lower() in weapon_name.lower() + # PF1e natural reach for tall creatures (simplification: no long/quadruped # distinction). Tiny, Diminutive and Fine have a natural reach of 0 ft (SRD # "Big and Little Creatures in Combat"): "Creatures that take up less than 1 @@ -2568,7 +2581,10 @@ class CombatEngine: m for m in feat.effects if m.target == target - and (m.weapon_filter is None or m.weapon_filter == weapon_name) + and ( + m.weapon_filter is None + or _weapon_filter_matches(m.weapon_filter, weapon_name) + ) ) # PF1e: prone and helpless (paralyzed/asleep/unconscious) AC # adjustments depend on the weapon kind and are applied in diff --git a/tests/test_feats.py b/tests/test_feats.py index 4040f01..00baa28 100644 --- a/tests/test_feats.py +++ b/tests/test_feats.py @@ -16,11 +16,16 @@ from pf1e_simulator.abilities import ( MOUNTED_COMBAT, POINT_BLANK_SHOT, ) +from pf1e_simulator.combat import CombatantState, CombatEngine +from pf1e_simulator.grid import Grid from pf1e_simulator.loaders.combatant import ( _KNOWN_FEATS, _NOOP_FEATS, _resolve_feat, + load_combatant, ) +from pf1e_simulator.map import MapSpec, TerrainType +from pf1e_simulator.rng import ScriptedRng PC_FILES = [ "data/pcs/esha.json", @@ -100,6 +105,30 @@ def test_spot_check_weapon_focus_pistol() -> None: assert any("Pistol" in str(e.weapon_filter) for e in spec.effects) +def test_weapon_focus_applies_to_real_tammara_pistols() -> None: + # The feat filter "Pistol" must match tammara's real weapon names + # ("Braises d'Automne (pistolet ?)", "Adieux du Printemps + # (pistolet, masterwork)") via case-insensitive substring matching, + # and must NOT match her rapier or alchemist's fire. + tammara = load_combatant(Path("data/pcs/tammara.json")) + state = CombatantState( + combatant=tammara, side="players", pos=(0, 0), hp=tammara.hp_max + ) + legend = {".": TerrainType(type="floor", move_cost=1)} + grid = Grid.from_spec(MapSpec(name="t", terrain=tuple([".." for _ in range(2)]), legend=legend)) + engine = CombatEngine(ScriptedRng([]), grid, [state]) + + def attack_total(weapon_name: str | None) -> int: + mods = engine._stat_modifiers(state, "attack", weapon_name=weapon_name) + return sum(m.value for m in mods if m.weapon_filter is not None) + + assert attack_total("Braises d'Automne (pistolet ?)") == 1 + assert attack_total("Adieux du Printemps (pistolet, masterwork)") == 1 + assert attack_total("Rapier (masterwork)") == 0 + assert attack_total("Alchemist's Fire") == 0 + assert attack_total(None) == 0 + + def test_spot_check_toughness_level_tied() -> None: spec = _resolve_feat("Toughness", 5) assert spec.name == "Toughness" diff --git a/tests/test_pc_combat_golden.py b/tests/test_pc_combat_golden.py index c2d2770..554f5d2 100644 --- a/tests/test_pc_combat_golden.py +++ b/tests/test_pc_combat_golden.py @@ -57,10 +57,21 @@ class TestGoldenCombats: """Golden tests for each PC vs goblin - deterministic combats with pinned transcripts.""" def test_tammara_vs_goblin(self): - """Tammara (Gunslinger/Warpriest) vs Goblin — full pinned transcript.""" + """Tammara (Gunslinger/Warpriest) vs Goblin — full pinned transcript. + + Golden updated by the Weapon Focus conformance fix: the feat filter + "Pistol" now matches her pistol names ("... (pistolet ?)"), so every + pistol attack gains the +1 feat bonus. Line-by-line: + - rounds 1-3: unchanged (Bull's Strength casts, goblin misses). + - round 4 tammara: total 15 -> 16 (the +1 Weapon Focus), AC 16 is now + met: MISS -> HIT 17 damage, goblin 6 -> -11 (below its -CON death + threshold). + - "goblin down" + "players win in 4 rounds": the goblin dies on round + 4, so the old rounds 4-10 goblin shots and the draw are gone. + """ result, _ = run_golden("tammara") - assert result.winner is None - assert result.rounds == 10 + assert result.winner == "players" + assert result.rounds == 4 assert result.transcript == ( "initiative: tammara d20=15+7=22", "initiative: goblin d20=10+6=16", @@ -73,22 +84,10 @@ class TestGoldenCombats: ("round 3 tammara: cast Bull's Strength vs tammara DC15 SAVED -> buff 3 mods (60r) " "(36->36)"), "round 3 goblin: short bow vs tammara d20=15+4-2=17 AC 20 -> MISS", - "round 4 tammara: Braises d'Automne (pistolet ?) vs goblin d20=10+9-6=15 AC 16 -> MISS", - "round 4 goblin: short bow vs tammara d20=15+4-2=17 AC 20 -> MISS", - "round 5 tammara: Braises d'Automne (pistolet ?) vs goblin d20=10+9-6=15 AC 16 -> MISS", - "round 5 goblin: short bow vs tammara d20=15+4-2=17 AC 20 -> MISS", - "round 6 tammara: Braises d'Automne (pistolet ?) vs goblin d20=10+9-6=15 AC 16 -> MISS", - "round 6 goblin: short bow vs tammara d20=15+4-2=17 AC 20 -> MISS", - "round 7 tammara: Braises d'Automne (pistolet ?) vs goblin d20=10+9-6=15 AC 16 -> MISS", - "round 7 goblin: short bow vs tammara d20=15+4-2=17 AC 20 -> MISS", - "round 8 tammara: Braises d'Automne (pistolet ?) vs goblin d20=10+9-6=15 AC 16 -> MISS", - "round 8 goblin: short bow vs tammara d20=15+4-2=17 AC 20 -> MISS", - "round 9 tammara: Braises d'Automne (pistolet ?) vs goblin d20=10+9-6=15 AC 16 -> MISS", - "round 9 goblin: short bow vs tammara d20=15+4-2=17 AC 20 -> MISS", - ("round 10 tammara: Braises d'Automne (pistolet ?) vs goblin d20=10+9-6=15 AC 16 " - "-> MISS"), - "round 10 goblin: short bow vs tammara d20=15+4-2=17 AC 20 -> MISS", - "battle over: draw after 10 rounds", + ("round 4 tammara: Braises d'Automne (pistolet ?) vs goblin d20=10+9-6=16 " + "AC 16 -> HIT 17 damage (6->-11)"), + "round 4 tammara: goblin down", + "battle over: players win in 4 rounds", ) def test_tammara_vs_lich(self):