From e9e8c26d50fed1be76a4b49fc0f7ace87818938c Mon Sep 17 00:00:00 2001 From: Thien An Date: Mon, 17 Aug 2026 22:49:50 +0200 Subject: [PATCH] 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. --- README.md | 12 +- src/pf1e_simulator/loaders/foundry.py | 73 ++++++++ tests/test_foundry_extraction.py | 244 ++++++++++++++++++++++++++ 3 files changed, 324 insertions(+), 5 deletions(-) create mode 100644 tests/test_foundry_extraction.py diff --git a/README.md b/README.md index 2b4af73..8b08e26 100644 --- a/README.md +++ b/README.md @@ -399,15 +399,16 @@ dans la résolution) : agrégation en `EncounterReport` avec attrition par combattant. - `cli.py` — front-end argparse `pf1e-sim`, rapport français, code de sortie 2 en cas d'erreur. -- `loaders/` — `foundry.py` (fiches Foundry `pf1-sheet/v1`) et `monster.py` - (JSON de monstre). +- `loaders/` — `foundry.py` (fiches Foundry `pf1-sheet/v1` avec extraction + automatique des dons → `AbilitySpec` et des sorts → `Combatant.spells`) et + `monster.py` (JSON de monstre). ## Développement La gate de validation complète (tests + lint + types) : ```bash -uv run pytest -q # 336 tests +uv run pytest -q # 377 tests uv run ruff check src tests uv run basedpyright src # mode strict ``` @@ -427,8 +428,9 @@ uv run basedpyright src # mode strict règles d'empilement des bonus, les jets de sauvegarde (`resolve_save`), le système de conditions (`conditions.py`), les dons passifs et actifs (`abilities.py`) et les sorts (`spells.py` + base JSON `data/spells/`) - sont en place. Extension du chargeur Foundry pour l'extraction des sorts et - dons en cours. + sont en place. Le chargeur Foundry extrait automatiquement les dons + (feats, traits, racial) vers des `AbilitySpec` pré-définis et les noms + de sorts vers `Combatant.spells`. - **Phase 2** — couche tactique LLM : stratégies en langage naturel traduites en politiques, balayage de matrices de positionnement. - **Phase 3** — rapporteur LLM local : agrégation des statistiques et diff --git a/src/pf1e_simulator/loaders/foundry.py b/src/pf1e_simulator/loaders/foundry.py index 46a602a..13dafcc 100644 --- a/src/pf1e_simulator/loaders/foundry.py +++ b/src/pf1e_simulator/loaders/foundry.py @@ -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 diff --git a/tests/test_foundry_extraction.py b/tests/test_foundry_extraction.py new file mode 100644 index 0000000..ed7c329 --- /dev/null +++ b/tests/test_foundry_extraction.py @@ -0,0 +1,244 @@ +"""Tests for feat and spell extraction from Foundry VTT sheets.""" + +from __future__ import annotations + +from pathlib import Path + +from pf1e_simulator.abilities import ( + ALERTNESS, + DODGE, + GREAT_FORTITUDE, + IRON_WILL, + LIGHTNING_REFLEXES, + POINT_BLANK_SHOT, + POWER_ATTACK, + PRECISE_SHOT, + AbilitySpec, +) +from pf1e_simulator.loaders.foundry import ( + _extract_features, + _extract_spells, + _match_feat, + load_sheet, + load_sheet_detailed, +) + +_SHEETS = Path(__file__).resolve().parent.parent / "fiches_personnages" + + +class TestMatchFeat: + """Given: a feat name string from a Foundry sheet + When: _match_feat is called + Then: returns the matching AbilitySpec or None.""" + + def test_power_attack(self) -> None: + assert _match_feat("Power Attack") == POWER_ATTACK + + def test_iron_will(self) -> None: + assert _match_feat("Iron Will") == IRON_WILL + + def test_great_fortitude(self) -> None: + assert _match_feat("Great Fortitude") == GREAT_FORTITUDE + + def test_lightning_reflexes(self) -> None: + assert _match_feat("Lightning Reflexes") == LIGHTNING_REFLEXES + + def test_alertness(self) -> None: + assert _match_feat("Alertness") == ALERTNESS + + def test_point_blank_shot(self) -> None: + assert _match_feat("Point-Blank Shot") == POINT_BLANK_SHOT + + def test_precise_shot(self) -> None: + assert _match_feat("Precise Shot") == PRECISE_SHOT + + def test_dodge(self) -> None: + assert _match_feat("Dodge") == DODGE + + def test_weapon_focus_pistol(self) -> None: + result = _match_feat("Weapon Focus (Pistol)") + assert result is not None + assert result.name == "Weapon Focus (Pistol)" + assert result.effects[0].weapon_filter == "Pistol" + + def test_weapon_focus_longbow(self) -> None: + result = _match_feat("Weapon Focus (Longbow)") + assert result is not None + assert result.name == "Weapon Focus (Longbow)" + assert result.effects[0].weapon_filter == "Longbow" + + def test_case_insensitive(self) -> None: + assert _match_feat("power attack") == POWER_ATTACK + assert _match_feat("IRON WILL") == IRON_WILL + + def test_unknown_feat_returns_none(self) -> None: + assert _match_feat("Summon Good Monster") is None + assert _match_feat("Sacred Summons") is None + assert _match_feat("Selective Channeling") is None + + def test_empty_string_returns_none(self) -> None: + assert _match_feat("") is None + assert _match_feat(" ") is None + + +class TestExtractFeatures: + """Given: a feats section from a Foundry sheet + When: _extract_features is called + Then: returns a deduplicated list of matched AbilitySpecs.""" + + def test_empty_dict(self) -> None: + assert _extract_features({}) == [] + + def test_none(self) -> None: + assert _extract_features(None) == [] + + def test_no_matching_feats(self) -> None: + feats = {"feats": ["Summon Good Monster", "Sacred Summons"], "traits": ["Bitter"]} + assert _extract_features(feats) == [] + + def test_matching_feats(self) -> None: + feats = {"feats": ["Power Attack", "Iron Will"], "traits": [], "racial": []} + result = _extract_features(feats) + assert len(result) == 2 + names = {f.name for f in result} + assert names == {"Power Attack", "Iron Will"} + + def test_deduplication(self) -> None: + feats = {"feats": ["Power Attack", "Power Attack"], "traits": ["Power Attack"]} + result = _extract_features(feats) + assert len(result) == 1 + assert result[0] == POWER_ATTACK + + def test_traits_and_racial_scanned(self) -> None: + feats = {"feats": [], "traits": ["Alertness"], "racial": ["Dodge"]} + result = _extract_features(feats) + assert len(result) == 2 + names = {f.name for f in result} + assert names == {"Alertness", "Dodge"} + + def test_non_string_items_skipped(self) -> None: + feats = {"feats": ["Power Attack", 42, None, True]} + result = _extract_features(feats) + assert len(result) == 1 + assert result[0] == POWER_ATTACK + + def test_non_list_values_skipped(self) -> None: + feats = {"feats": "Power Attack", "traits": 42, "racial": None} + assert _extract_features(feats) == [] + + +class TestExtractSpells: + """Given: a spellcasting section from a Foundry sheet + When: _extract_spells is called + Then: returns a deduplicated list of spell names.""" + + def test_empty_dict(self) -> None: + assert _extract_spells({}) == [] + + def test_none(self) -> None: + assert _extract_spells(None) == [] + + def test_no_spells_key(self) -> None: + assert _extract_spells({"spellbooks": {}}) == [] + + def test_extracts_spell_names(self) -> None: + sc = {"spells": [ + {"name": "Fireball", "level": 3}, + {"name": "Magic Missile", "level": 1}, + ]} + assert _extract_spells(sc) == ["Fireball", "Magic Missile"] + + def test_deduplication(self) -> None: + sc = {"spells": [ + {"name": "Fireball", "level": 3}, + {"name": "Fireball", "level": 3}, + ]} + assert _extract_spells(sc) == ["Fireball"] + + def test_skips_non_dict_entries(self) -> None: + sc = {"spells": ["Fireball", 42, None, {"name": "Cure Light Wounds", "level": 1}]} + assert _extract_spells(sc) == ["Cure Light Wounds"] + + def test_skips_empty_names(self) -> None: + sc = {"spells": [{"name": "", "level": 1}, {"name": "Fireball", "level": 3}]} + assert _extract_spells(sc) == ["Fireball"] + + +class TestRealSheetsFeatures: + """Given: the 8 real Foundry sheets + When: loaded with load_sheet_detailed + Then: features and spells are extracted correctly.""" + + def test_esha_has_three_features(self) -> None: + combatant, _ = load_sheet_detailed(_SHEETS / "esha_sheet.json") + names = {f.name for f in combatant.features} + assert "Point-Blank Shot" in names + assert "Precise Shot" in names + assert "Alertness" in names + assert len(combatant.features) == 3 + + def test_esha_has_one_spell(self) -> None: + combatant, _ = load_sheet_detailed(_SHEETS / "esha_sheet.json") + assert "Unseen Servant" in combatant.spells + assert len(combatant.spells) == 1 + + def test_harvie_has_no_features_no_spells(self) -> None: + combatant, _ = load_sheet_detailed(_SHEETS / "harvie_sheet.json") + assert combatant.features == [] + assert combatant.spells == [] + + def test_ierlieth_has_power_attack(self) -> None: + combatant, _ = load_sheet_detailed(_SHEETS / "ierlieth_sheet.json") + names = {f.name for f in combatant.features} + assert "Power Attack" in names + assert combatant.spells == [] + + def test_jeanne_has_alertness(self) -> None: + combatant, _ = load_sheet_detailed(_SHEETS / "jeanne_sheet.json") + names = {f.name for f in combatant.features} + assert "Alertness" in names + + def test_tammara_has_weapon_focus_pistol(self) -> None: + combatant, _ = load_sheet_detailed(_SHEETS / "tammara_sheet.json") + names = {f.name for f in combatant.features} + assert "Weapon Focus (Pistol)" in names + assert "Point-Blank Shot" in names + assert "Precise Shot" in names + assert len(combatant.features) == 3 + + def test_tammara_has_spells(self) -> None: + combatant, _ = load_sheet_detailed(_SHEETS / "tammara_sheet.json") + assert len(combatant.spells) > 0 + assert "Cure Light Wounds" in combatant.spells + + def test_oni_has_spells(self) -> None: + combatant, _ = load_sheet_detailed(_SHEETS / "oni_sheet.json") + assert len(combatant.spells) == 20 + assert "Cure Light Wounds" in combatant.spells + assert "Guidance" in combatant.spells + + def test_misty_has_spells(self) -> None: + combatant, _ = load_sheet_detailed(_SHEETS / "misty_sheet.json") + assert len(combatant.spells) == 24 + + def test_nairda_has_spells(self) -> None: + combatant, _ = load_sheet_detailed(_SHEETS / "nairda_sheet.json") + assert len(combatant.spells) == 32 + + def test_all_sheets_features_are_ability_specs(self) -> None: + for path in sorted(_SHEETS.glob("*.json")): + combatant, _ = load_sheet_detailed(path) + for feat in combatant.features: + assert isinstance(feat, AbilitySpec) + + def test_all_sheets_spells_are_strings(self) -> None: + for path in sorted(_SHEETS.glob("*.json")): + combatant, _ = load_sheet_detailed(path) + for spell in combatant.spells: + assert isinstance(spell, str) + assert spell + + def test_load_sheet_returns_features_and_spells(self) -> None: + combatant = load_sheet(_SHEETS / "esha_sheet.json") + assert len(combatant.features) == 3 + assert len(combatant.spells) == 1