467 lines
18 KiB
Python
467 lines
18 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 (
|
|
AMBITIOUS,
|
|
AXE_TO_GRIND,
|
|
BITTER,
|
|
BORN_DAMNED,
|
|
BREEZE_KISSED,
|
|
COMBAT_REFLEXES,
|
|
EXALTED_SOCIETY,
|
|
EXTREMELY_FASHIONABLE,
|
|
FOCUSED_STUDY,
|
|
FORTIFIED_DRINKER,
|
|
FRIENDLY_FACE,
|
|
LARGER_THAN_LIFE,
|
|
LIKE_THE_WIND,
|
|
LONELY,
|
|
MOUNTED_COMBAT,
|
|
POINT_BLANK_SHOT,
|
|
WHISPERING_WIND,
|
|
AbilitySpec,
|
|
)
|
|
from pf1e_simulator.combat import CombatantState, CombatEngine
|
|
from pf1e_simulator.dice import parse_dice
|
|
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.models import (
|
|
AbilityScores,
|
|
ACProfile,
|
|
AttackSpec,
|
|
Combatant,
|
|
DamageComponent,
|
|
Saves,
|
|
)
|
|
from pf1e_simulator.rng import ScriptedRng
|
|
from pf1e_simulator.spells import HealEffect
|
|
|
|
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
|
|
|
|
|
|
# ── SRD conformance: feat/trait divergence batch (size/speed/skill gates) ─────
|
|
|
|
|
|
def test_larger_than_life_is_inert_documented_noop() -> None:
|
|
"""SRD (Basic Combat trait): considered one size larger for Intimidate
|
|
while wielding a firearm. Intimidate and size-for-skill are not modeled
|
|
-> inert no-op (the old flat +1 Intimidate was the wrong effect)."""
|
|
assert LARGER_THAN_LIFE.effects == ()
|
|
assert "not modeled" in LARGER_THAN_LIFE.source_detail
|
|
|
|
|
|
def test_ambitious_is_inert_documented_noop() -> None:
|
|
"""SRD (Basic Social trait): +4 trait bonus on Diplomacy vs creatures
|
|
with 5+ more HD. The HD-differential gate is not expressible -> inert."""
|
|
assert AMBITIOUS.effects == ()
|
|
assert "not modeled" in AMBITIOUS.source_detail
|
|
|
|
|
|
def test_focused_study_is_inert_documented_noop() -> None:
|
|
"""SRD (human alternate racial trait): +2 on one chosen skill (re-chosen
|
|
at 8th/16th). The chosen skill is never resolved in combat -> inert."""
|
|
assert FOCUSED_STUDY.effects == ()
|
|
assert "not modeled" in FOCUSED_STUDY.source_detail
|
|
|
|
|
|
def test_esha_sylph_traits_are_inert_documented_noops() -> None:
|
|
"""SRD (ARG sylph alternate racial traits; Esha's sheet declares race
|
|
Sylph so the picks are legal): Whispering Wind = +4 racial Stealth,
|
|
Like the Wind = +5 ft base speed, Breeze-Kissed = +2 racial AC vs
|
|
nonmagical ranged + 1/day gust. None is expressible (no stealth checks,
|
|
speed_ft modifiers unresolved, AC is a final sheet value) -> all inert;
|
|
the previously coded Fly/save/speed bonuses were fabricated."""
|
|
for spec in (WHISPERING_WIND, LIKE_THE_WIND, BREEZE_KISSED):
|
|
assert spec.effects == (), spec.name
|
|
assert "not modeled" in spec.source_detail, spec.name
|
|
|
|
|
|
def test_fey_touched_and_pingre_are_traced_noop_names() -> None:
|
|
"""Not PF1e features (no SRD entry under those names): Harvie's
|
|
"Fey-touched" and Nairda's joke trait "Pingre" join the decorative
|
|
traced-noop list; their old coded bonuses were fabricated."""
|
|
assert "fey-touched" in _NOOP_FEATS
|
|
assert "pingre" in _NOOP_FEATS
|
|
for name in ("Fey-touched", "Pingre"):
|
|
spec = _resolve_feat(name, 1)
|
|
assert spec.name == name
|
|
assert spec.effects == ()
|
|
|
|
|
|
def test_misty_scaled_skin_and_maw_or_claw_encoded_in_data() -> None:
|
|
"""SRD (ARG tiefling daemon-spawn): Scaled Skin (electricity) = resist
|
|
electricity 5 (replaces fiendish resistance - no cold/fire); Maw or Claw
|
|
= bite natural attack, 1d6 for a Medium tiefling (the Foundry sheet's
|
|
"1d4 estimé" is an author guess, discrepancy recorded in the JSON notes).
|
|
Both benefits are Combatant data, not stat modifiers."""
|
|
misty = load_combatant(Path("data/pcs/misty.json"))
|
|
assert misty.resistances == {"electricity": 5}
|
|
bites = [a for a in misty.attacks if a.name.startswith("Bite")]
|
|
assert len(bites) == 1
|
|
formula = bites[0].damage[0].formula
|
|
assert (formula.count, formula.sides, formula.bonus) == (1, 6, 0)
|
|
|
|
|
|
def _make_battle_combatant(
|
|
cid: str, *, features: tuple[AbilitySpec, ...] = (), hp_max: int = 30
|
|
) -> Combatant:
|
|
return Combatant(
|
|
id=cid,
|
|
name=cid,
|
|
level=1,
|
|
size="Medium",
|
|
abilities=AbilityScores(
|
|
str_score=12, dex_score=12, con_score=12,
|
|
int_score=10, wis_score=10, cha_score=10,
|
|
),
|
|
hp_max=hp_max,
|
|
ac=ACProfile(total=12, touch=12, flat_footed=10),
|
|
bab=1,
|
|
initiative_mod=0,
|
|
speed_land_ft=30,
|
|
saves=Saves(fort=0, ref=0, will=0),
|
|
attacks=[
|
|
AttackSpec(
|
|
id=f"{cid}-w",
|
|
name="club",
|
|
kind="melee",
|
|
attack_bonus=1,
|
|
damage=[DamageComponent(formula=parse_dice("1d6"), types=["bludgeoning"])],
|
|
)
|
|
],
|
|
features=list(features),
|
|
)
|
|
|
|
|
|
def _bitter_pair() -> tuple[CombatEngine, CombatantState, CombatantState]:
|
|
"""Two same-side states: a plain healer and a target carrying Bitter."""
|
|
healer_c = _make_battle_combatant("healer")
|
|
bitter_c = _make_battle_combatant("bitter", features=(BITTER,))
|
|
healer = CombatantState(combatant=healer_c, side="players", pos=(0, 0), hp=30)
|
|
target = CombatantState(combatant=bitter_c, side="players", pos=(1, 0), hp=10)
|
|
legend = {".": TerrainType(type="floor", move_cost=1)}
|
|
grid = Grid.from_spec(
|
|
MapSpec(name="t", terrain=tuple([".." for _ in range(4)]), legend=legend)
|
|
)
|
|
engine = CombatEngine(ScriptedRng([]), grid, [healer, target])
|
|
return engine, healer, target
|
|
|
|
|
|
def test_lonely_is_a_negative_drawback_not_a_bonus() -> None:
|
|
"""SRD (drawback): -2 Sense Motive and -2 saves vs charm (mind_affecting
|
|
stands in). No positive modifier may remain."""
|
|
assert LONELY.effects
|
|
assert all(e.value < 0 for e in LONELY.effects), LONELY.effects
|
|
assert any(e.target == "sense_motive" and e.value == -2 for e in LONELY.effects)
|
|
will = [e for e in LONELY.effects if e.target == "will"]
|
|
assert len(will) == 1
|
|
assert will[0].value == -2
|
|
assert will[0].condition == "mind_affecting"
|
|
|
|
|
|
def test_bitter_is_a_behavior_marker_without_stat_effects() -> None:
|
|
"""The drawback is applied by the engine at healing sites, not as a stat
|
|
modifier - and the old positive '+1 Will vs fear' is gone."""
|
|
assert BITTER.effects == ()
|
|
assert "engine behavior" in BITTER.source_detail
|
|
|
|
|
|
def test_bitter_reduces_ally_channel_healing_by_one() -> None:
|
|
"""SRD (drawback): ally class-feature healing received is reduced by 1."""
|
|
engine, healer, target = _bitter_pair()
|
|
engine._channel_heal(healer, target, 6)
|
|
assert target.hp == 15 # 10 + 6 - 1
|
|
|
|
|
|
def test_bitter_reduces_ally_spell_healing_by_one() -> None:
|
|
"""Ally spell healing is reduced by 1 as well (rolled 8 -> heals 7)."""
|
|
engine, healer, target = _bitter_pair()
|
|
engine._rng = ScriptedRng([8])
|
|
effect = HealEffect(formula=parse_dice("1d8"))
|
|
engine._apply_heal_effect(
|
|
effect, target, caster_level=1, empower=False, maximize=False,
|
|
saved=False, healer=healer,
|
|
)
|
|
assert target.hp == 17 # 10 + 8 - 1
|
|
|
|
|
|
def test_bitter_leaves_self_healing_untouched() -> None:
|
|
"""The drawback is about accepting help from allies, not self-healing."""
|
|
engine, healer, _ = _bitter_pair()
|
|
healer.combatant.features.append(BITTER)
|
|
healer.hp = 10
|
|
engine._channel_heal(healer, healer, 6)
|
|
assert healer.hp == 16 # no reduction
|