refactor(loaders): unify combatant loader, migrate PC features/spells
Rename monster.py → combatant.py with _KNOWN_FEATS lookup table that resolves feat name strings to AbilitySpec constants (Power Attack, Iron Will, Toughness, Weapon Focus, etc.). Unknown feats produce minimal AbilitySpec (data preserved for future implementation). Migrate all 8 PC JSONs (data/pcs/) with complete feat/spell lists extracted from Foundry sheets — including traits, racial abilities, and all spells. Monsters (goblin, orc) unchanged (no feats/spells). Update all imports, add backward-compatible aliases (load_monster, MonsterLoadError), update CLI to use load_combatant. Gate: 377 tests, ruff clean, basedpyright clean.
This commit is contained in:
@@ -399,9 +399,9 @@ 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` avec extraction
|
- `loaders/` — `combatant.py` (chargeur unifié PJ + monstres avec résolution
|
||||||
automatique des dons → `AbilitySpec` et des sorts → `Combatant.spells`) et
|
des dons → `AbilitySpec` et des sorts → `Combatant.spells`) et
|
||||||
`monster.py` (JSON de monstre).
|
`foundry.py` (outil de migration one-shot depuis les fiches Foundry).
|
||||||
|
|
||||||
## Développement
|
## Développement
|
||||||
|
|
||||||
|
|||||||
+17
-1
@@ -81,5 +81,21 @@
|
|||||||
"count": 1
|
"count": 1
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"source": "fiches_personnages/esha_sheet.json"
|
"source": "fiches_personnages/esha_sheet.json",
|
||||||
|
"features": [
|
||||||
|
"Mounted Combat",
|
||||||
|
"Stealth Synergy",
|
||||||
|
"Point-Blank Shot",
|
||||||
|
"Precise Shot",
|
||||||
|
"Alertness",
|
||||||
|
"Adopted",
|
||||||
|
"Thoroughbred (Human; Keleshite)",
|
||||||
|
"Vagabond Child (Urban)",
|
||||||
|
"Whispering Wind",
|
||||||
|
"Breeze-Kissed",
|
||||||
|
"Like the Wind"
|
||||||
|
],
|
||||||
|
"spells": [
|
||||||
|
"Unseen Servant"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,5 +63,11 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"source": "fiches_personnages/harvie_sheet.json",
|
"source": "fiches_personnages/harvie_sheet.json",
|
||||||
"speed_fly_ft": 80
|
"speed_fly_ft": 80,
|
||||||
|
"features": [
|
||||||
|
"Friendly Face",
|
||||||
|
"Fey-touched",
|
||||||
|
"Animal (race)"
|
||||||
|
],
|
||||||
|
"spells": []
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,5 +62,11 @@
|
|||||||
"count": 1
|
"count": 1
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"source": "fiches_personnages/ierlieth_sheet.json"
|
"source": "fiches_personnages/ierlieth_sheet.json",
|
||||||
|
"features": [
|
||||||
|
"Toughness",
|
||||||
|
"Power Attack",
|
||||||
|
"Drake (race)"
|
||||||
|
],
|
||||||
|
"spells": []
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-1
@@ -62,5 +62,14 @@
|
|||||||
"count": 1
|
"count": 1
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"source": "fiches_personnages/jeanne_sheet.json"
|
"source": "fiches_personnages/jeanne_sheet.json",
|
||||||
|
"features": [
|
||||||
|
"Combat Reflexes",
|
||||||
|
"Alertness",
|
||||||
|
"Armor Proficiency (Light)",
|
||||||
|
"Armor Proficiency (Medium)",
|
||||||
|
"Thoroughbred (Human; Keleshite)",
|
||||||
|
"Animal (race)"
|
||||||
|
],
|
||||||
|
"spells": []
|
||||||
}
|
}
|
||||||
|
|||||||
+39
-1
@@ -117,5 +117,43 @@
|
|||||||
"count": 1
|
"count": 1
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"source": "fiches_personnages/misty_sheet.json"
|
"source": "fiches_personnages/misty_sheet.json",
|
||||||
|
"features": [
|
||||||
|
"Plot Armor",
|
||||||
|
"Harmonic Spell",
|
||||||
|
"Weapon Finesse",
|
||||||
|
"Axe to Grind (combat)",
|
||||||
|
"Spell Vulnerability (divination; drawback)",
|
||||||
|
"Born Damned (race: tiefling)",
|
||||||
|
"Gifted Adept (dispel magic; magic)",
|
||||||
|
"Prehensile Tail (tiefling daemon-spawn)",
|
||||||
|
"Scaled Skin (electricity) (tiefling daemon-spawn)",
|
||||||
|
"Maw or Claw (Bite) (tiefling daemon-spawn)"
|
||||||
|
],
|
||||||
|
"spells": [
|
||||||
|
"Prestidigitation",
|
||||||
|
"Oath of Anonymity",
|
||||||
|
"Detect Magic",
|
||||||
|
"Read Magic",
|
||||||
|
"Disrupt Undead",
|
||||||
|
"Ray of Frost",
|
||||||
|
"Summon Instrument",
|
||||||
|
"Mage Hand",
|
||||||
|
"Open/Close",
|
||||||
|
"Arcane Mark",
|
||||||
|
"Acid Splash",
|
||||||
|
"Grasp",
|
||||||
|
"Shield",
|
||||||
|
"Cure Light Wounds",
|
||||||
|
"Color Spray",
|
||||||
|
"Saving Finale",
|
||||||
|
"Vanish",
|
||||||
|
"Shocking Grasp",
|
||||||
|
"Burning Hands",
|
||||||
|
"Alarm",
|
||||||
|
"Scorching Ray",
|
||||||
|
"Bladed Dash",
|
||||||
|
"Mirror Image",
|
||||||
|
"Heroism"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
+44
-1
@@ -63,5 +63,48 @@
|
|||||||
"range_increment_ft": 10
|
"range_increment_ft": 10
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"source": "fiches_personnages/nairda_sheet.json"
|
"source": "fiches_personnages/nairda_sheet.json",
|
||||||
|
"features": [
|
||||||
|
"Brew Potion",
|
||||||
|
"Craft Wondrous Item",
|
||||||
|
"Extra Hex",
|
||||||
|
"Eschew Materials",
|
||||||
|
"Half-Forgotten Secrets (Dhampir; Ajibachana)",
|
||||||
|
"Orange - Lazy Ass",
|
||||||
|
"Pingre"
|
||||||
|
],
|
||||||
|
"spells": [
|
||||||
|
"Arcane Mark",
|
||||||
|
"Bleed",
|
||||||
|
"Dancing Lights",
|
||||||
|
"Daze",
|
||||||
|
"Detect Fiendish Presence",
|
||||||
|
"Detect Magic",
|
||||||
|
"Detect Poison",
|
||||||
|
"Grasp",
|
||||||
|
"Guidance",
|
||||||
|
"Light",
|
||||||
|
"Mending",
|
||||||
|
"Message",
|
||||||
|
"Putrefy Food and Drink",
|
||||||
|
"Read Magic",
|
||||||
|
"Resistance",
|
||||||
|
"Spark",
|
||||||
|
"Stabilize",
|
||||||
|
"Touch of Fatigue",
|
||||||
|
"Comprehend Languages",
|
||||||
|
"Charm Person",
|
||||||
|
"Ill Omen",
|
||||||
|
"Inflict Light Wounds",
|
||||||
|
"Sleep",
|
||||||
|
"Hex Vulnerability",
|
||||||
|
"Deja Vu",
|
||||||
|
"Unseen Servant",
|
||||||
|
"Ray of Enfeeblement",
|
||||||
|
"Web",
|
||||||
|
"Blindness/Deafness",
|
||||||
|
"Ice Slick",
|
||||||
|
"Darkness",
|
||||||
|
"Flurry of Snowballs"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
+32
-1
@@ -83,5 +83,36 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"source": "fiches_personnages/oni_sheet.json",
|
"source": "fiches_personnages/oni_sheet.json",
|
||||||
"notes": "Armes placeholder Foundry sans dégâts encodés omises : 'une Pioche' (non maîtrisée) et 'Weapon' (à remplacer par la vraie arme)."
|
"notes": "Armes placeholder Foundry sans dégâts encodés omises : 'une Pioche' (non maîtrisée) et 'Weapon' (à remplacer par la vraie arme).",
|
||||||
|
"features": [
|
||||||
|
"Summon Good Monster",
|
||||||
|
"Sacred Summons",
|
||||||
|
"Selective Channeling",
|
||||||
|
"Extremely Fashionable",
|
||||||
|
"Exalted of the Society (Cleric, Pathfinder Society)",
|
||||||
|
"Bitter",
|
||||||
|
"Ambitious"
|
||||||
|
],
|
||||||
|
"spells": [
|
||||||
|
"Guidance",
|
||||||
|
"Detect Magic",
|
||||||
|
"Stabilize",
|
||||||
|
"Read Magic",
|
||||||
|
"Detect Poison",
|
||||||
|
"Purify Food and Drink",
|
||||||
|
"Summon Monster I",
|
||||||
|
"Shield of Faith",
|
||||||
|
"Forbid Action",
|
||||||
|
"Cure Light Wounds",
|
||||||
|
"Hidden Diplomacy",
|
||||||
|
"Protection from Evil",
|
||||||
|
"Bless",
|
||||||
|
"Summon Monster II",
|
||||||
|
"Cure Moderate Wounds",
|
||||||
|
"Sound Burst",
|
||||||
|
"Suppress Charms and Compulsions",
|
||||||
|
"Delay Poison",
|
||||||
|
"Ironskin",
|
||||||
|
"Restoration, Lesser"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
+74
-1
@@ -119,5 +119,78 @@
|
|||||||
"count": 1
|
"count": 1
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"source": "fiches_personnages/tammara_sheet.json"
|
"source": "fiches_personnages/tammara_sheet.json",
|
||||||
|
"features": [
|
||||||
|
"Weapon Focus (Pistol)",
|
||||||
|
"Rapid Reload (Pistol)",
|
||||||
|
"Gunsmithing",
|
||||||
|
"Skill Focus (Perception)",
|
||||||
|
"Point-Blank Shot",
|
||||||
|
"Precise Shot",
|
||||||
|
"Dazzling Display",
|
||||||
|
"Fortified Drinker (Cayden Cailean)",
|
||||||
|
"Fate's Favored",
|
||||||
|
"Lonely",
|
||||||
|
"Larger Than Life",
|
||||||
|
"6. Vert - Yes Mistress",
|
||||||
|
"Focused Study (trait racial humain — remplace le bonus feat)"
|
||||||
|
],
|
||||||
|
"spells": [
|
||||||
|
"Create Water",
|
||||||
|
"Light",
|
||||||
|
"Detect Magic",
|
||||||
|
"Bleed",
|
||||||
|
"Detect Poison",
|
||||||
|
"Enhanced Diplomacy",
|
||||||
|
"Guidance",
|
||||||
|
"Mending",
|
||||||
|
"Purify Food and Drink",
|
||||||
|
"Read Magic",
|
||||||
|
"Spark",
|
||||||
|
"Resistance",
|
||||||
|
"Stabilize",
|
||||||
|
"Virtue",
|
||||||
|
"Create Alcool",
|
||||||
|
"Abundant Ammunition",
|
||||||
|
"Air Bubble",
|
||||||
|
"Bane",
|
||||||
|
"Bless",
|
||||||
|
"Cure Light Wounds",
|
||||||
|
"Divine Favor",
|
||||||
|
"Doom",
|
||||||
|
"Endure Elements",
|
||||||
|
"Enhance Water",
|
||||||
|
"Face of the Devourer",
|
||||||
|
"Forbid Action",
|
||||||
|
"Hide from Undead",
|
||||||
|
"Protection from Evil",
|
||||||
|
"Protection from Law",
|
||||||
|
"Sanctuary",
|
||||||
|
"Shield of Faith",
|
||||||
|
"Obscuring Mist",
|
||||||
|
"Weapon of Awe",
|
||||||
|
"Tears to Wine",
|
||||||
|
"Surmount Affliction",
|
||||||
|
"Suppress Charms and Compulsions",
|
||||||
|
"Stave Off Corruption",
|
||||||
|
"Status",
|
||||||
|
"Restoration, Lesser",
|
||||||
|
"Cure Moderate Wounds",
|
||||||
|
"Spear of Purity",
|
||||||
|
"Silence",
|
||||||
|
"Shard of Chaos",
|
||||||
|
"Protection from Law, Communal",
|
||||||
|
"Protection from Evil, Communal",
|
||||||
|
"Owl's Wisdom",
|
||||||
|
"Bull's Strength",
|
||||||
|
"Bear's Endurance",
|
||||||
|
"Masterwork Transformation",
|
||||||
|
"Spiritual Weapon",
|
||||||
|
"Make Whole",
|
||||||
|
"Ironskin",
|
||||||
|
"Delay Disease",
|
||||||
|
"Delay Poison",
|
||||||
|
"Aid",
|
||||||
|
"Drunkard's Breath"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ from typing import TYPE_CHECKING
|
|||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from pf1e_simulator.runner import EncounterReport, EncounterSpec
|
from pf1e_simulator.runner import EncounterReport, EncounterSpec
|
||||||
|
|
||||||
from pf1e_simulator.loaders import MonsterLoadError, SheetLoadError, load_monster
|
from pf1e_simulator.loaders import CombatantLoadError, SheetLoadError, load_combatant
|
||||||
from pf1e_simulator.map import MapValidationError, load_map
|
from pf1e_simulator.map import MapValidationError, load_map
|
||||||
from pf1e_simulator.runner import EncounterSpec, Side, run_encounter
|
from pf1e_simulator.runner import EncounterSpec, Side, run_encounter
|
||||||
|
|
||||||
@@ -44,7 +44,7 @@ def _build_spec(args: argparse.Namespace) -> EncounterSpec:
|
|||||||
if not files:
|
if not files:
|
||||||
msg = f"side {name!r}: at least one monster JSON file is required"
|
msg = f"side {name!r}: at least one monster JSON file is required"
|
||||||
raise CliError(msg)
|
raise CliError(msg)
|
||||||
combatants = tuple(load_monster(Path(path)) for path in files)
|
combatants = tuple(load_combatant(Path(path)) for path in files)
|
||||||
sides.append(Side(name=name, combatants=combatants, zone=map_spec.deployment[name]))
|
sides.append(Side(name=name, combatants=combatants, zone=map_spec.deployment[name]))
|
||||||
return EncounterSpec(map=map_spec, sides=tuple(sides), round_cap=args.round_cap)
|
return EncounterSpec(map=map_spec, sides=tuple(sides), round_cap=args.round_cap)
|
||||||
|
|
||||||
@@ -107,7 +107,7 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
except CliError as exc:
|
except CliError as exc:
|
||||||
print(f"pf1e-sim: {exc}", file=sys.stderr)
|
print(f"pf1e-sim: {exc}", file=sys.stderr)
|
||||||
return 2
|
return 2
|
||||||
except (MapValidationError, MonsterLoadError, SheetLoadError, OSError) as exc:
|
except (MapValidationError, CombatantLoadError, SheetLoadError, OSError) as exc:
|
||||||
print(f"pf1e-sim: {exc}", file=sys.stderr)
|
print(f"pf1e-sim: {exc}", file=sys.stderr)
|
||||||
return 2
|
return 2
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
"""Sheet and monster loaders — the parsing boundary of the simulator."""
|
"""Sheet and combatant loaders — the parsing boundary of the simulator."""
|
||||||
|
|
||||||
|
from pf1e_simulator.loaders.combatant import (
|
||||||
|
CombatantLoadError,
|
||||||
|
load_combatant,
|
||||||
|
load_monster,
|
||||||
|
)
|
||||||
from pf1e_simulator.loaders.foundry import (
|
from pf1e_simulator.loaders.foundry import (
|
||||||
LoadReport,
|
LoadReport,
|
||||||
SheetLoadError,
|
SheetLoadError,
|
||||||
@@ -7,14 +12,17 @@ from pf1e_simulator.loaders.foundry import (
|
|||||||
load_sheet,
|
load_sheet,
|
||||||
load_sheet_detailed,
|
load_sheet_detailed,
|
||||||
)
|
)
|
||||||
from pf1e_simulator.loaders.monster import MonsterLoadError, load_monster
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
"CombatantLoadError",
|
||||||
"LoadReport",
|
"LoadReport",
|
||||||
"MonsterLoadError",
|
"MonsterLoadError",
|
||||||
"SheetLoadError",
|
"SheetLoadError",
|
||||||
"SkippedEntry",
|
"SkippedEntry",
|
||||||
|
"load_combatant",
|
||||||
"load_monster",
|
"load_monster",
|
||||||
"load_sheet",
|
"load_sheet",
|
||||||
"load_sheet_detailed",
|
"load_sheet_detailed",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
MonsterLoadError = CombatantLoadError
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
"""Unified loader for combatant JSON files (PCs and monsters).
|
||||||
|
|
||||||
|
Both player characters (``data/pcs/*.json``) and monsters
|
||||||
|
(``data/monsters/*.json``) share the same ``Combatant`` Pydantic schema.
|
||||||
|
This loader resolves ``features`` (list of feat name strings) to
|
||||||
|
``AbilitySpec`` objects via a lookup table and passes ``spells`` through
|
||||||
|
as-is.
|
||||||
|
|
||||||
|
Feat names are matched case-insensitively. Known feats (Power Attack,
|
||||||
|
Iron Will, Weapon Focus, Toughness, etc.) map to their ``AbilitySpec``
|
||||||
|
constants. Unknown feat names produce a minimal ``AbilitySpec`` with no
|
||||||
|
mechanical effects — the data is preserved for future implementation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from pf1e_simulator.loaders.foundry import Json
|
||||||
|
|
||||||
|
from pf1e_simulator.abilities import (
|
||||||
|
ALERTNESS,
|
||||||
|
DODGE,
|
||||||
|
GREAT_FORTITUDE,
|
||||||
|
IRON_WILL,
|
||||||
|
LIGHTNING_REFLEXES,
|
||||||
|
POINT_BLANK_SHOT,
|
||||||
|
POWER_ATTACK,
|
||||||
|
PRECISE_SHOT,
|
||||||
|
AbilitySpec,
|
||||||
|
toughness,
|
||||||
|
weapon_focus,
|
||||||
|
)
|
||||||
|
from pf1e_simulator.models import Combatant
|
||||||
|
|
||||||
|
# ── Feat lookup ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_WEAPON_FOCUS_RE = re.compile(r"^Weapon Focus \((.+)\)$", re.IGNORECASE)
|
||||||
|
|
||||||
|
_KNOWN_FEATS: dict[str, AbilitySpec] = {
|
||||||
|
"power attack": POWER_ATTACK,
|
||||||
|
"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,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_feat(name: str, level: int) -> AbilitySpec:
|
||||||
|
"""Resolve a feat name string to an AbilitySpec.
|
||||||
|
|
||||||
|
Known feats → their constant. Toughness → factory with level.
|
||||||
|
Weapon Focus (X) → factory with weapon name. Unknown feats → minimal
|
||||||
|
AbilitySpec with no effects (data preserved for future implementation).
|
||||||
|
"""
|
||||||
|
stripped = name.strip()
|
||||||
|
lower = stripped.lower()
|
||||||
|
if lower in _KNOWN_FEATS:
|
||||||
|
return _KNOWN_FEATS[lower]
|
||||||
|
if lower == "toughness":
|
||||||
|
return toughness(level)
|
||||||
|
wf_match = _WEAPON_FOCUS_RE.match(stripped)
|
||||||
|
if wf_match:
|
||||||
|
return weapon_focus(wf_match.group(1))
|
||||||
|
return AbilitySpec(name=stripped, category="feat")
|
||||||
|
|
||||||
|
|
||||||
|
# ── Loader ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class CombatantLoadError(Exception):
|
||||||
|
"""Raised when a combatant JSON file cannot be loaded."""
|
||||||
|
|
||||||
|
def __init__(self, path: Path, reason: str) -> None:
|
||||||
|
super().__init__(f"{path}: {reason}")
|
||||||
|
self.path = path
|
||||||
|
self.reason = reason
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_features(raw_features: list[Json], level: int) -> list[AbilitySpec]:
|
||||||
|
"""Resolve a list of feat name strings to AbilitySpec objects."""
|
||||||
|
return [
|
||||||
|
_resolve_feat(item, level)
|
||||||
|
for item in raw_features
|
||||||
|
if isinstance(item, str) and item.strip()
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _auto_attack_ids(data: dict[str, Json], monster_id: str) -> None:
|
||||||
|
"""Auto-assign attack ids from file stem if missing."""
|
||||||
|
attacks = data.get("attacks")
|
||||||
|
if isinstance(attacks, list):
|
||||||
|
for index, attack in enumerate(attacks):
|
||||||
|
if isinstance(attack, dict) and "id" not in attack:
|
||||||
|
attack["id"] = f"{monster_id}:{index}"
|
||||||
|
|
||||||
|
|
||||||
|
def load_combatant(path: Path) -> Combatant:
|
||||||
|
"""Load a combatant JSON file (PC or monster) into a ``Combatant``.
|
||||||
|
|
||||||
|
``features`` in the JSON are a list of feat name strings resolved via
|
||||||
|
``_resolve_feat``. ``spells`` are passed through as-is (list of strings).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
raw = path.read_text(encoding="utf-8")
|
||||||
|
except OSError as exc:
|
||||||
|
msg = f"cannot read file: {exc}"
|
||||||
|
raise CombatantLoadError(path, msg) from exc
|
||||||
|
try:
|
||||||
|
data: Json = json.loads(raw)
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
msg = f"invalid JSON: {exc}"
|
||||||
|
raise CombatantLoadError(path, msg) from exc
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
msg = "combatant file must contain a JSON object"
|
||||||
|
raise CombatantLoadError(path, msg)
|
||||||
|
|
||||||
|
# Auto-assign monster/PC id from file stem if missing
|
||||||
|
monster_id = data.get("id")
|
||||||
|
if not isinstance(monster_id, str) or not monster_id:
|
||||||
|
monster_id = path.stem
|
||||||
|
data["id"] = monster_id
|
||||||
|
|
||||||
|
_auto_attack_ids(data, monster_id)
|
||||||
|
|
||||||
|
# Resolve features (list of strings → list of AbilitySpec)
|
||||||
|
level = data.get("level", 1) if isinstance(data.get("level"), int) else 1
|
||||||
|
raw_features = data.pop("features", None)
|
||||||
|
if isinstance(raw_features, list):
|
||||||
|
# Type ignore: we assign AbilitySpec list into a Json dict before Pydantic validation
|
||||||
|
data["features"] = _resolve_features(raw_features, level) # type: ignore[assignment]
|
||||||
|
|
||||||
|
# spells stays as list[str] — Pydantic validates it directly
|
||||||
|
|
||||||
|
try:
|
||||||
|
return Combatant.model_validate(data)
|
||||||
|
except ValidationError as exc:
|
||||||
|
raise CombatantLoadError(path, str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
# ── Backward-compatible alias ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def load_monster(path: Path) -> Combatant:
|
||||||
|
"""Alias for ``load_combatant`` (backward compatibility)."""
|
||||||
|
return load_combatant(path)
|
||||||
|
|
||||||
|
|
||||||
|
MonsterLoadError = CombatantLoadError
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
"""Loader for hand-authored monster JSON files (same Combatant schema).
|
|
||||||
|
|
||||||
These files are the future output of the LLM stat-block extractor, so
|
|
||||||
validation is strict: unknown keys are rejected and extraction errors surface
|
|
||||||
at load time, not mid-simulation. Damage formulas use plain dice notation
|
|
||||||
("2d6+3"); attack ids and the monster id default from the file stem when
|
|
||||||
omitted.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
from pydantic import ValidationError
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from pf1e_simulator.loaders.foundry import Json
|
|
||||||
|
|
||||||
from pf1e_simulator.models import Combatant
|
|
||||||
|
|
||||||
|
|
||||||
class MonsterLoadError(Exception):
|
|
||||||
"""Raised when a monster JSON file cannot be loaded."""
|
|
||||||
|
|
||||||
def __init__(self, path: Path, reason: str) -> None:
|
|
||||||
super().__init__(f"{path}: {reason}")
|
|
||||||
self.path = path
|
|
||||||
self.reason = reason
|
|
||||||
|
|
||||||
|
|
||||||
def load_monster(path: Path) -> Combatant:
|
|
||||||
try:
|
|
||||||
raw = path.read_text(encoding="utf-8")
|
|
||||||
except OSError as exc:
|
|
||||||
msg = f"cannot read file: {exc}"
|
|
||||||
raise MonsterLoadError(path, msg) from exc
|
|
||||||
try:
|
|
||||||
data: Json = json.loads(raw)
|
|
||||||
except json.JSONDecodeError as exc:
|
|
||||||
msg = f"invalid JSON: {exc}"
|
|
||||||
raise MonsterLoadError(path, msg) from exc
|
|
||||||
if not isinstance(data, dict):
|
|
||||||
msg = "monster file must contain a JSON object"
|
|
||||||
raise MonsterLoadError(path, msg)
|
|
||||||
|
|
||||||
monster_id = data.get("id")
|
|
||||||
if not isinstance(monster_id, str) or not monster_id:
|
|
||||||
monster_id = path.stem
|
|
||||||
data["id"] = monster_id
|
|
||||||
attacks = data.get("attacks")
|
|
||||||
if isinstance(attacks, list):
|
|
||||||
for index, attack in enumerate(attacks):
|
|
||||||
if isinstance(attack, dict) and "id" not in attack:
|
|
||||||
attack["id"] = f"{monster_id}:{index}"
|
|
||||||
|
|
||||||
try:
|
|
||||||
return Combatant.model_validate(data)
|
|
||||||
except ValidationError as exc:
|
|
||||||
raise MonsterLoadError(path, str(exc)) from exc
|
|
||||||
@@ -8,7 +8,7 @@ from pathlib import Path
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from pf1e_simulator.dice import DiceExpr
|
from pf1e_simulator.dice import DiceExpr
|
||||||
from pf1e_simulator.loaders.monster import MonsterLoadError, load_monster
|
from pf1e_simulator.loaders.combatant import CombatantLoadError, load_combatant
|
||||||
|
|
||||||
MONSTERS_DIR = Path(__file__).resolve().parents[1] / "data" / "monsters"
|
MONSTERS_DIR = Path(__file__).resolve().parents[1] / "data" / "monsters"
|
||||||
PCS_DIR = Path(__file__).resolve().parents[1] / "data" / "pcs"
|
PCS_DIR = Path(__file__).resolve().parents[1] / "data" / "pcs"
|
||||||
@@ -20,7 +20,7 @@ PCS_DIR = Path(__file__).resolve().parents[1] / "data" / "pcs"
|
|||||||
)
|
)
|
||||||
def test_pc_sheet_loads(stem: str) -> None:
|
def test_pc_sheet_loads(stem: str) -> None:
|
||||||
"""Every transposed PC sheet loads through the strict monster JSON path."""
|
"""Every transposed PC sheet loads through the strict monster JSON path."""
|
||||||
combatant = load_monster(PCS_DIR / f"{stem}.json")
|
combatant = load_combatant(PCS_DIR / f"{stem}.json")
|
||||||
assert combatant.attacks
|
assert combatant.attacks
|
||||||
assert combatant.hp_max >= 1
|
assert combatant.hp_max >= 1
|
||||||
assert combatant.ac.total >= 10
|
assert combatant.ac.total >= 10
|
||||||
@@ -28,7 +28,7 @@ def test_pc_sheet_loads(stem: str) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_pc_sheets_golden_spots() -> None:
|
def test_pc_sheets_golden_spots() -> None:
|
||||||
esha = load_monster(PCS_DIR / "esha.json")
|
esha = load_combatant(PCS_DIR / "esha.json")
|
||||||
assert esha.name == "Esha Randu"
|
assert esha.name == "Esha Randu"
|
||||||
assert [a.name for a in esha.attacks] == [
|
assert [a.name for a in esha.attacks] == [
|
||||||
"Lance",
|
"Lance",
|
||||||
@@ -38,15 +38,15 @@ def test_pc_sheets_golden_spots() -> None:
|
|||||||
assert esha.attacks[0].reach_ft == 10
|
assert esha.attacks[0].reach_ft == 10
|
||||||
assert esha.attacks[1].range_increment_ft == 70
|
assert esha.attacks[1].range_increment_ft == 70
|
||||||
|
|
||||||
harvie = load_monster(PCS_DIR / "harvie.json")
|
harvie = load_combatant(PCS_DIR / "harvie.json")
|
||||||
assert harvie.size == "Small"
|
assert harvie.size == "Small"
|
||||||
assert harvie.speed_fly_ft == 80
|
assert harvie.speed_fly_ft == 80
|
||||||
|
|
||||||
oni = load_monster(PCS_DIR / "oni.json")
|
oni = load_combatant(PCS_DIR / "oni.json")
|
||||||
assert "Pioche" in oni.notes
|
assert "Pioche" in oni.notes
|
||||||
assert "Weapon" in oni.notes
|
assert "Weapon" in oni.notes
|
||||||
|
|
||||||
tammara = load_monster(PCS_DIR / "tammara.json")
|
tammara = load_combatant(PCS_DIR / "tammara.json")
|
||||||
assert tammara.initiative_mod == 7
|
assert tammara.initiative_mod == 7
|
||||||
|
|
||||||
|
|
||||||
@@ -86,7 +86,7 @@ def _valid_payload() -> dict[str, object]:
|
|||||||
|
|
||||||
|
|
||||||
def test_goblin_golden() -> None:
|
def test_goblin_golden() -> None:
|
||||||
goblin = load_monster(MONSTERS_DIR / "goblin.json")
|
goblin = load_combatant(MONSTERS_DIR / "goblin.json")
|
||||||
assert goblin.name == "Goblin"
|
assert goblin.name == "Goblin"
|
||||||
assert goblin.size == "Small"
|
assert goblin.size == "Small"
|
||||||
assert goblin.cr == "1/3"
|
assert goblin.cr == "1/3"
|
||||||
@@ -118,7 +118,7 @@ def test_goblin_golden() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_orc_golden() -> None:
|
def test_orc_golden() -> None:
|
||||||
orc = load_monster(MONSTERS_DIR / "orc.json")
|
orc = load_combatant(MONSTERS_DIR / "orc.json")
|
||||||
assert orc.name == "Orc"
|
assert orc.name == "Orc"
|
||||||
assert orc.size == "Medium"
|
assert orc.size == "Medium"
|
||||||
assert orc.hp_max == 6
|
assert orc.hp_max == 6
|
||||||
@@ -139,12 +139,12 @@ def test_orc_golden() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_monster_attack_ids_auto_assigned() -> None:
|
def test_monster_attack_ids_auto_assigned() -> None:
|
||||||
goblin = load_monster(MONSTERS_DIR / "goblin.json")
|
goblin = load_combatant(MONSTERS_DIR / "goblin.json")
|
||||||
assert [a.id for a in goblin.attacks] == ["goblin:0", "goblin:1"]
|
assert [a.id for a in goblin.attacks] == ["goblin:0", "goblin:1"]
|
||||||
|
|
||||||
|
|
||||||
def test_valid_minimal_monster_loads(tmp_path: Path) -> None:
|
def test_valid_minimal_monster_loads(tmp_path: Path) -> None:
|
||||||
monster = load_monster(_write(tmp_path, _valid_payload()))
|
monster = load_combatant(_write(tmp_path, _valid_payload()))
|
||||||
assert monster.name == "Testling"
|
assert monster.name == "Testling"
|
||||||
assert monster.attacks[0].id == "monster:0" # falls back to file stem
|
assert monster.attacks[0].id == "monster:0" # falls back to file stem
|
||||||
|
|
||||||
@@ -160,22 +160,22 @@ def test_rejects_crit_mult_one(tmp_path: Path) -> None:
|
|||||||
"crit_mult": 1,
|
"crit_mult": 1,
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
with pytest.raises(MonsterLoadError):
|
with pytest.raises(CombatantLoadError):
|
||||||
load_monster(_write(tmp_path, payload))
|
load_combatant(_write(tmp_path, payload))
|
||||||
|
|
||||||
|
|
||||||
def test_rejects_zero_hp(tmp_path: Path) -> None:
|
def test_rejects_zero_hp(tmp_path: Path) -> None:
|
||||||
payload = _valid_payload()
|
payload = _valid_payload()
|
||||||
payload["hp_max"] = 0
|
payload["hp_max"] = 0
|
||||||
with pytest.raises(MonsterLoadError):
|
with pytest.raises(CombatantLoadError):
|
||||||
load_monster(_write(tmp_path, payload))
|
load_combatant(_write(tmp_path, payload))
|
||||||
|
|
||||||
|
|
||||||
def test_rejects_unknown_key(tmp_path: Path) -> None:
|
def test_rejects_unknown_key(tmp_path: Path) -> None:
|
||||||
payload = _valid_payload()
|
payload = _valid_payload()
|
||||||
payload["bogus_field"] = 1
|
payload["bogus_field"] = 1
|
||||||
with pytest.raises(MonsterLoadError):
|
with pytest.raises(CombatantLoadError):
|
||||||
load_monster(_write(tmp_path, payload))
|
load_combatant(_write(tmp_path, payload))
|
||||||
|
|
||||||
|
|
||||||
def test_rejects_bad_formula(tmp_path: Path) -> None:
|
def test_rejects_bad_formula(tmp_path: Path) -> None:
|
||||||
@@ -188,5 +188,5 @@ def test_rejects_bad_formula(tmp_path: Path) -> None:
|
|||||||
"damage": [{"formula": "banana", "types": ["piercing"]}],
|
"damage": [{"formula": "banana", "types": ["piercing"]}],
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
with pytest.raises(MonsterLoadError):
|
with pytest.raises(CombatantLoadError):
|
||||||
load_monster(_write(tmp_path, payload))
|
load_combatant(_write(tmp_path, payload))
|
||||||
|
|||||||
Reference in New Issue
Block a user