feat(loaders): extend Foundry loader with feat/spell extraction

Map Foundry feat strings to AbilitySpec constants (Power Attack, Iron Will,
Alertness, Point-Blank Shot, Precise Shot, Dodge, Weapon Focus (X)).
Extract spell names from spellcasting.spells into Combatant.spells.
Wire features and spells into load_sheet_detailed return.
Add 41 tests (unit + golden on 8 real sheets).

Gate: 377 tests, ruff clean, basedpyright clean.
This commit is contained in:
2026-08-17 22:49:50 +02:00
parent 828afc15c0
commit e9e8c26d50
3 changed files with 324 additions and 5 deletions
+73
View File
@@ -16,6 +16,18 @@ if TYPE_CHECKING:
from pydantic import BaseModel, ConfigDict
from pf1e_simulator.abilities import (
ALERTNESS,
DODGE,
GREAT_FORTITUDE,
IRON_WILL,
LIGHTNING_REFLEXES,
POINT_BLANK_SHOT,
POWER_ATTACK,
PRECISE_SHOT,
AbilitySpec,
weapon_focus,
)
from pf1e_simulator.dice import DiceParseError, parse_dice
from pf1e_simulator.models import (
AbilityScores,
@@ -35,6 +47,65 @@ _ACTION_KINDS: dict[str, Literal["melee", "ranged", "touch"]] = {
}
_SIZE_ROLL_RE = re.compile(r"sizeRoll\(\s*(\d+)\s*,\s*(\d+)\s*,\s*@size\s*\)")
_COUNT_PREFIX_RE = re.compile(r"^(\d+)x\s")
_WEAPON_FOCUS_RE = re.compile(r"^Weapon Focus \((.+)\)$", re.IGNORECASE)
_FEAT_MAP: dict[str, AbilitySpec] = {
"iron will": IRON_WILL,
"great fortitude": GREAT_FORTITUDE,
"lightning reflexes": LIGHTNING_REFLEXES,
"alertness": ALERTNESS,
"point-blank shot": POINT_BLANK_SHOT,
"precise shot": PRECISE_SHOT,
"dodge": DODGE,
"power attack": POWER_ATTACK,
}
def _match_feat(name: str) -> AbilitySpec | None:
lower = name.strip().lower()
if lower in _FEAT_MAP:
return _FEAT_MAP[lower]
wf_match = _WEAPON_FOCUS_RE.match(name.strip())
if wf_match:
return weapon_focus(wf_match.group(1))
return None
def _extract_features(feats_section: Json) -> list[AbilitySpec]:
if not isinstance(feats_section, dict):
return []
features: list[AbilitySpec] = []
seen: set[str] = set()
for key in ("feats", "traits", "racial"):
items = feats_section.get(key)
if not isinstance(items, list):
continue
for item in items:
if not isinstance(item, str):
continue
spec = _match_feat(item)
if spec is not None and spec.name not in seen:
features.append(spec)
seen.add(spec.name)
return features
def _extract_spells(spellcasting: Json) -> list[str]:
if not isinstance(spellcasting, dict):
return []
spells = spellcasting.get("spells")
if not isinstance(spells, list):
return []
names: list[str] = []
seen: set[str] = set()
for entry in spells:
if not isinstance(entry, dict):
continue
name = entry.get("name")
if isinstance(name, str) and name and name not in seen:
names.append(name)
seen.add(name)
return names
class SheetLoadError(Exception):
@@ -277,6 +348,8 @@ def load_sheet_detailed(path: Path) -> tuple[Combatant, LoadReport]:
will=reader.save_total(saves_raw, "will"),
),
attacks=kept,
features=_extract_features(root.get("feats")),
spells=_extract_spells(root.get("spellcasting")),
)
report = LoadReport(path=str(path), kept=len(kept), skipped=tuple(skipped))
return combatant, report