fix(rules): Weapon Focus filter matches weapon names case-insensitively (substring)
The filter 'Pistol' now matches 'Braises d'Automne (pistolet ?)' via case-insensitive substring matching, so the feat is no longer dead. Golden test_tammara_vs_goblin updated: round-4 pistol shot gains the +1 feat bonus (total 15 -> 16 vs AC 16), turning the MISS into a lethal HIT (17 damage, goblin 6 -> -11); the goblin dies on round 4 so the old rounds 4-10 exchange and the draw are gone.
This commit is contained in:
@@ -307,6 +307,19 @@ def _size_rank(size: str) -> int:
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
return _SIZE_ORDER.index("medium")
|
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
|
# PF1e natural reach for tall creatures (simplification: no long/quadruped
|
||||||
# distinction). Tiny, Diminutive and Fine have a natural reach of 0 ft (SRD
|
# 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
|
# "Big and Little Creatures in Combat"): "Creatures that take up less than 1
|
||||||
@@ -2568,7 +2581,10 @@ class CombatEngine:
|
|||||||
m
|
m
|
||||||
for m in feat.effects
|
for m in feat.effects
|
||||||
if m.target == target
|
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
|
# PF1e: prone and helpless (paralyzed/asleep/unconscious) AC
|
||||||
# adjustments depend on the weapon kind and are applied in
|
# adjustments depend on the weapon kind and are applied in
|
||||||
|
|||||||
@@ -16,11 +16,16 @@ from pf1e_simulator.abilities import (
|
|||||||
MOUNTED_COMBAT,
|
MOUNTED_COMBAT,
|
||||||
POINT_BLANK_SHOT,
|
POINT_BLANK_SHOT,
|
||||||
)
|
)
|
||||||
|
from pf1e_simulator.combat import CombatantState, CombatEngine
|
||||||
|
from pf1e_simulator.grid import Grid
|
||||||
from pf1e_simulator.loaders.combatant import (
|
from pf1e_simulator.loaders.combatant import (
|
||||||
_KNOWN_FEATS,
|
_KNOWN_FEATS,
|
||||||
_NOOP_FEATS,
|
_NOOP_FEATS,
|
||||||
_resolve_feat,
|
_resolve_feat,
|
||||||
|
load_combatant,
|
||||||
)
|
)
|
||||||
|
from pf1e_simulator.map import MapSpec, TerrainType
|
||||||
|
from pf1e_simulator.rng import ScriptedRng
|
||||||
|
|
||||||
PC_FILES = [
|
PC_FILES = [
|
||||||
"data/pcs/esha.json",
|
"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)
|
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:
|
def test_spot_check_toughness_level_tied() -> None:
|
||||||
spec = _resolve_feat("Toughness", 5)
|
spec = _resolve_feat("Toughness", 5)
|
||||||
assert spec.name == "Toughness"
|
assert spec.name == "Toughness"
|
||||||
|
|||||||
@@ -57,10 +57,21 @@ class TestGoldenCombats:
|
|||||||
"""Golden tests for each PC vs goblin - deterministic combats with pinned transcripts."""
|
"""Golden tests for each PC vs goblin - deterministic combats with pinned transcripts."""
|
||||||
|
|
||||||
def test_tammara_vs_goblin(self):
|
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")
|
result, _ = run_golden("tammara")
|
||||||
assert result.winner is None
|
assert result.winner == "players"
|
||||||
assert result.rounds == 10
|
assert result.rounds == 4
|
||||||
assert result.transcript == (
|
assert result.transcript == (
|
||||||
"initiative: tammara d20=15+7=22",
|
"initiative: tammara d20=15+7=22",
|
||||||
"initiative: goblin d20=10+6=16",
|
"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) "
|
("round 3 tammara: cast Bull's Strength vs tammara DC15 SAVED -> buff 3 mods (60r) "
|
||||||
"(36->36)"),
|
"(36->36)"),
|
||||||
"round 3 goblin: short bow vs tammara d20=15+4-2=17 AC 20 -> MISS",
|
"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 tammara: Braises d'Automne (pistolet ?) vs goblin d20=10+9-6=16 "
|
||||||
"round 4 goblin: short bow vs tammara d20=15+4-2=17 AC 20 -> MISS",
|
"AC 16 -> HIT 17 damage (6->-11)"),
|
||||||
"round 5 tammara: Braises d'Automne (pistolet ?) vs goblin d20=10+9-6=15 AC 16 -> MISS",
|
"round 4 tammara: goblin down",
|
||||||
"round 5 goblin: short bow vs tammara d20=15+4-2=17 AC 20 -> MISS",
|
"battle over: players win in 4 rounds",
|
||||||
"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",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_tammara_vs_lich(self):
|
def test_tammara_vs_lich(self):
|
||||||
|
|||||||
Reference in New Issue
Block a user