Files
pf1e-simulator/tests/test_feats.py
T

293 lines
11 KiB
Python

"""Feat resolution for the 8 PC data files.
Every feature name string in each ``data/pcs/*.json`` file must resolve to a
real ``AbilitySpec`` (real effects where modeled, otherwise an attested traced
no-op) — never a silently empty spec. This test proves no feat in the 8 PCs is
silently unimplemented.
"""
from __future__ import annotations
import json
from pathlib import Path
from pf1e_simulator.abilities import (
AXE_TO_GRIND,
BORN_DAMNED,
COMBAT_REFLEXES,
EXALTED_SOCIETY,
EXTREMELY_FASHIONABLE,
FORTIFIED_DRINKER,
FRIENDLY_FACE,
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",
"data/pcs/harvie.json",
"data/pcs/ierlieth.json",
"data/pcs/jeanne.json",
"data/pcs/misty.json",
"data/pcs/nairda.json",
"data/pcs/oni.json",
"data/pcs/tammara.json",
]
def _pcs_features() -> set[str]:
names: set[str] = set()
for path in PC_FILES:
with Path(path).open(encoding="utf-8") as handle:
data = json.load(handle)
for feat in data.get("features", []):
if isinstance(feat, str) and feat.strip():
names.add(feat.strip())
return names
def _all_feature_names() -> list[tuple[str, str, int]]:
rows: list[tuple[str, str, int]] = []
for path in PC_FILES:
with Path(path).open(encoding="utf-8") as handle:
data = json.load(handle)
for feat in data.get("features", []):
if isinstance(feat, str) and feat.strip():
level = data.get("level", 1)
rows.append((path, feat.strip(), level if isinstance(level, int) else 1))
return rows
def test_56_unique_feature_names_across_8_pcs() -> None:
assert len(_pcs_features()) == 56
def test_no_feat_in_8_pcs_is_silently_unresolved() -> None:
"""Every feature either maps to a known spec or is an attested traced-no-op.
This is the core acceptance: no feat in the 8 JSONs is silently
unimplemented (an unmatched name would be a silent empty spec).
"""
unresolved = []
for path, name, level in _all_feature_names():
spec = _resolve_feat(name, level)
lower = name.lower()
if lower in _KNOWN_FEATS:
expected = _KNOWN_FEATS[lower].name
elif lower == "toughness":
expected = "Toughness"
elif lower.startswith("weapon focus (") or lower in _NOOP_FEATS:
expected = name
else:
unresolved.append(name)
continue
assert spec.name == expected, f"{path}: {name!r} -> {spec.name!r} != {expected!r}"
assert unresolved == [], (
f"{len(unresolved)} feats silently unresolved: {sorted(set(unresolved))}"
)
def test_spot_check_real_effects() -> None:
assert _resolve_feat("Point-Blank Shot", 1) is POINT_BLANK_SHOT
assert POINT_BLANK_SHOT.effects != ()
assert _resolve_feat("Combat Reflexes", 1) is COMBAT_REFLEXES
assert _resolve_feat("Mounted Combat", 1) is MOUNTED_COMBAT
assert MOUNTED_COMBAT.effects != ()
def test_spot_check_weapon_focus_pistol() -> None:
spec = _resolve_feat("Weapon Focus (Pistol)", 1)
assert spec.name == "Weapon Focus (Pistol)"
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"
assert any(e.value == 5 for e in spec.effects)
def test_toughness_is_plus3_below_four_hd_and_plus_hd_above() -> None:
# CRB Toughness: "+3 hit points. If the character has 4 or more Hit
# Dice, she gains +1 hit point per Hit Die."
def hp_value(level: int) -> int:
spec = _resolve_feat("Toughness", level)
values = [e.value for e in spec.effects if e.target == "hp"]
assert len(values) == 1
return values[0]
assert hp_value(1) == 3
assert hp_value(2) == 3
assert hp_value(3) == 3
assert hp_value(4) == 4
# ── Expected mapping table (name -> resolved display name or traced-no-op) ─────
_EXPECTED_RESOLUTION: dict[str, str] = {
"6. Vert - Yes Mistress": "6. Vert - Yes Mistress",
"Adopted": "Adopted",
"Alertness": "Alertness",
"Ambitious": "Ambitious",
"Animal (race)": "Animal (race)",
"Armor Proficiency (Light)": "Armor Proficiency (Light)",
"Armor Proficiency (Medium)": "Armor Proficiency (Medium)",
"Axe to Grind (combat)": "Axe to Grind",
"Bitter": "Bitter",
"Born Damned (race: tiefling)": "Born Damned (race: tiefling)",
"Breeze-Kissed": "Breeze-Kissed",
"Brew Potion": "Brew Potion",
"Combat Reflexes": "Combat Reflexes",
"Craft Wondrous Item": "Craft Wondrous Item",
"Dazzling Display": "Dazzling Display",
"Drake (race)": "Drake (race)",
"Eschew Materials": "Eschew Materials",
"Exalted of the Society (Cleric, Pathfinder Society)": (
"Exalted of the Society (Cleric, Pathfinder Society)"
),
"Extra Hex": "Extra Hex",
"Extremely Fashionable": "Extremely Fashionable",
"Fate's Favored": "Fate's Favored",
"Fey-touched": "Fey-touched",
"Focused Study (trait racial humain — remplace le bonus feat)": "Focused Study",
"Fortified Drinker (Cayden Cailean)": "Fortified Drinker (Cayden Cailean)",
"Friendly Face": "Friendly Face",
"Gifted Adept (dispel magic; magic)": "Gifted Adept (dispel magic; magic)",
"Gunsmithing": "Gunsmithing",
"Half-Forgotten Secrets (Dhampir; Ajibachana)": "Half-Forgotten Secrets (Dhampir; Ajibachana)",
"Harmonic Spell": "Harmonic Spell",
"Larger Than Life": "Larger Than Life",
"Like the Wind": "Like the Wind",
"Lonely": "Lonely",
"Maw or Claw (Bite) (tiefling daemon-spawn)": "Maw or Claw (Bite) (tiefling daemon-spawn)",
"Mounted Combat": "Mounted Combat",
"Orange - Lazy Ass": "Orange - Lazy Ass",
"Pingre": "Pingre",
"Point-Blank Shot": "Point-Blank Shot",
"Plot Armor": "Plot Armor",
"Power Attack": "Power Attack",
"Precise Shot": "Precise Shot",
"Prehensile Tail (tiefling daemon-spawn)": "Prehensile Tail (tiefling daemon-spawn)",
"Rapid Reload (Pistol)": "Rapid Reload (Pistol)",
"Sacred Summons": "Sacred Summons",
"Scaled Skin (electricity) (tiefling daemon-spawn)": (
"Scaled Skin (electricity) (tiefling daemon-spawn)"
),
"Selective Channeling": "Selective Channeling",
"Skill Focus (Perception)": "Skill Focus (Perception)",
"Spell Vulnerability (divination; drawback)": "Spell Vulnerability (divination; drawback)",
"Stealth Synergy": "Stealth Synergy",
"Summon Good Monster": "Summon Good Monster",
"Thoroughbred": "Thoroughbred",
"Thoroughbred (Human; Keleshite)": "Thoroughbred",
"Toughness": "Toughness",
"Vagabond Child (Urban)": "Vagabond Child",
"Weapon Finesse": "Weapon Finesse",
"Weapon Focus (Pistol)": "Weapon Focus (Pistol)",
"Whispering Wind": "Whispering Wind",
}
def test_every_pc_feature_resolves_to_expected_spec() -> None:
"""The full 56-name mapping table is satisfied by the loader."""
for name, expected_name in _EXPECTED_RESOLUTION.items():
spec = _resolve_feat(name, 1)
assert spec.name == expected_name, (
f"{name!r} resolved to {spec.name!r}, expected {expected_name!r}"
)
def test_mapping_table_covers_exactly_the_8_pc_feats() -> None:
assert set(_EXPECTED_RESOLUTION) == _pcs_features()
# ── SRD conformance: corrected feat/trait effects ─────────────────────────────
def test_exalted_of_the_society_grants_one_extra_channel_use() -> None:
"""SRD (Faction Guide trait): "You may channel energy one additional time
per day." The grant rides resource_grants and the loader merges it into
the combatant's channel_energy pool (oni sheet base 6 -> 7)."""
assert EXALTED_SOCIETY.resource_grants == (("channel_energy", 1),)
assert not any(
e.target in ("knowledge_local", "knowledge_nobility")
for e in EXALTED_SOCIETY.effects
)
oni = load_combatant(Path("data/pcs/oni.json"))
assert oni.resources["channel_energy"] == 7
def test_fortified_drinker_is_inert_documented_noop() -> None:
"""SRD: +2 saves vs mind-affecting for 1 hour after drinking alcohol. The
alcohol trigger is not modeled -> inert no-op (the old dead "+2 Fort vs
poison, condition='alcohol'" modifier is gone)."""
assert FORTIFIED_DRINKER.effects == ()
assert "not modeled" in FORTIFIED_DRINKER.source_detail
def test_born_damned_is_inert_documented_noop() -> None:
"""SRD (Tiefling race trait): +2 trait bonus on saves against curses. No
'curse' save descriptor exists -> inert no-op (old +2 Fort vs poison was
the wrong bonus entirely)."""
assert BORN_DAMNED.effects == ()
assert "not modeled" in BORN_DAMNED.source_detail
def test_axe_to_grind_is_inert_documented_noop() -> None:
"""SRD (APG combat trait): +1 trait bonus on damage against foes
threatened by only you. The threat-map gate is not expressible in the
StatModifier condition system -> inert no-op (old +1 damage with axes was
the wrong effect; the SRD bonus is not tied to axes)."""
assert AXE_TO_GRIND.effects == ()
assert "not modeled" in AXE_TO_GRIND.source_detail
def test_friendly_face_is_inert_documented_noop() -> None:
"""SRD (Animal Archive feat): animals of the same kind have their starting
attitude toward you improved by two steps. No attitude model -> inert
no-op (old +1 Diplomacy was the wrong effect)."""
assert FRIENDLY_FACE.effects == ()
assert "not modeled" in FRIENDLY_FACE.source_detail
def test_extremely_fashionable_is_inert_documented_noop() -> None:
"""SRD (PFS faction trait): +1 trait bonus on Bluff, Diplomacy and
Intimidate while wearing fine clothing. Clothing state not modeled ->
inert no-op (old flat +4 Diplomacy was the wrong value and unconditional)."""
assert EXTREMELY_FASHIONABLE.effects == ()
assert "not modeled" in EXTREMELY_FASHIONABLE.source_detail