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:
@@ -399,15 +399,16 @@ dans la résolution) :
|
|||||||
agrégation en `EncounterReport` avec attrition par combattant.
|
agrégation en `EncounterReport` avec attrition par combattant.
|
||||||
- `cli.py` — front-end argparse `pf1e-sim`, rapport français, code de sortie 2
|
- `cli.py` — front-end argparse `pf1e-sim`, rapport français, code de sortie 2
|
||||||
en cas d'erreur.
|
en cas d'erreur.
|
||||||
- `loaders/` — `foundry.py` (fiches Foundry `pf1-sheet/v1`) et `monster.py`
|
- `loaders/` — `foundry.py` (fiches Foundry `pf1-sheet/v1` avec extraction
|
||||||
(JSON de monstre).
|
automatique des dons → `AbilitySpec` et des sorts → `Combatant.spells`) et
|
||||||
|
`monster.py` (JSON de monstre).
|
||||||
|
|
||||||
## Développement
|
## Développement
|
||||||
|
|
||||||
La gate de validation complète (tests + lint + types) :
|
La gate de validation complète (tests + lint + types) :
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv run pytest -q # 336 tests
|
uv run pytest -q # 377 tests
|
||||||
uv run ruff check src tests
|
uv run ruff check src tests
|
||||||
uv run basedpyright src # mode strict
|
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
|
règles d'empilement des bonus, les jets de sauvegarde (`resolve_save`), le
|
||||||
système de conditions (`conditions.py`), les dons passifs et actifs
|
système de conditions (`conditions.py`), les dons passifs et actifs
|
||||||
(`abilities.py`) et les sorts (`spells.py` + base JSON `data/spells/`)
|
(`abilities.py`) et les sorts (`spells.py` + base JSON `data/spells/`)
|
||||||
sont en place. Extension du chargeur Foundry pour l'extraction des sorts et
|
sont en place. Le chargeur Foundry extrait automatiquement les dons
|
||||||
dons en cours.
|
(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
|
- **Phase 2** — couche tactique LLM : stratégies en langage naturel traduites
|
||||||
en politiques, balayage de matrices de positionnement.
|
en politiques, balayage de matrices de positionnement.
|
||||||
- **Phase 3** — rapporteur LLM local : agrégation des statistiques et
|
- **Phase 3** — rapporteur LLM local : agrégation des statistiques et
|
||||||
|
|||||||
@@ -16,6 +16,18 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict
|
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.dice import DiceParseError, parse_dice
|
||||||
from pf1e_simulator.models import (
|
from pf1e_simulator.models import (
|
||||||
AbilityScores,
|
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*\)")
|
_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")
|
_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):
|
class SheetLoadError(Exception):
|
||||||
@@ -277,6 +348,8 @@ def load_sheet_detailed(path: Path) -> tuple[Combatant, LoadReport]:
|
|||||||
will=reader.save_total(saves_raw, "will"),
|
will=reader.save_total(saves_raw, "will"),
|
||||||
),
|
),
|
||||||
attacks=kept,
|
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))
|
report = LoadReport(path=str(path), kept=len(kept), skipped=tuple(skipped))
|
||||||
return combatant, report
|
return combatant, report
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user