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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user