diff --git a/README.md b/README.md index 8b08e26..8390ff6 100644 --- a/README.md +++ b/README.md @@ -399,9 +399,9 @@ 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` avec extraction - automatique des dons → `AbilitySpec` et des sorts → `Combatant.spells`) et - `monster.py` (JSON de monstre). +- `loaders/` — `combatant.py` (chargeur unifié PJ + monstres avec résolution + des dons → `AbilitySpec` et des sorts → `Combatant.spells`) et + `foundry.py` (outil de migration one-shot depuis les fiches Foundry). ## Développement diff --git a/data/pcs/esha.json b/data/pcs/esha.json index bca89b4..be07283 100644 --- a/data/pcs/esha.json +++ b/data/pcs/esha.json @@ -81,5 +81,21 @@ "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" + ] } diff --git a/data/pcs/harvie.json b/data/pcs/harvie.json index 6c10f79..b1acc14 100644 --- a/data/pcs/harvie.json +++ b/data/pcs/harvie.json @@ -63,5 +63,11 @@ } ], "source": "fiches_personnages/harvie_sheet.json", - "speed_fly_ft": 80 + "speed_fly_ft": 80, + "features": [ + "Friendly Face", + "Fey-touched", + "Animal (race)" + ], + "spells": [] } diff --git a/data/pcs/ierlieth.json b/data/pcs/ierlieth.json index 6ad9cd1..b75de22 100644 --- a/data/pcs/ierlieth.json +++ b/data/pcs/ierlieth.json @@ -62,5 +62,11 @@ "count": 1 } ], - "source": "fiches_personnages/ierlieth_sheet.json" + "source": "fiches_personnages/ierlieth_sheet.json", + "features": [ + "Toughness", + "Power Attack", + "Drake (race)" + ], + "spells": [] } diff --git a/data/pcs/jeanne.json b/data/pcs/jeanne.json index 38e69ea..1b37c4c 100644 --- a/data/pcs/jeanne.json +++ b/data/pcs/jeanne.json @@ -62,5 +62,14 @@ "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": [] } diff --git a/data/pcs/misty.json b/data/pcs/misty.json index c182fc7..d4f9649 100644 --- a/data/pcs/misty.json +++ b/data/pcs/misty.json @@ -117,5 +117,43 @@ "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" + ] } diff --git a/data/pcs/nairda.json b/data/pcs/nairda.json index 3826857..ea2f7f2 100644 --- a/data/pcs/nairda.json +++ b/data/pcs/nairda.json @@ -63,5 +63,48 @@ "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" + ] } diff --git a/data/pcs/oni.json b/data/pcs/oni.json index bf318c4..0ea6a68 100644 --- a/data/pcs/oni.json +++ b/data/pcs/oni.json @@ -83,5 +83,36 @@ } ], "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" + ] } diff --git a/data/pcs/tammara.json b/data/pcs/tammara.json index 4ecf22d..441d9ab 100644 --- a/data/pcs/tammara.json +++ b/data/pcs/tammara.json @@ -119,5 +119,78 @@ "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" + ] } diff --git a/src/pf1e_simulator/cli.py b/src/pf1e_simulator/cli.py index 7195021..cf2dbb5 100644 --- a/src/pf1e_simulator/cli.py +++ b/src/pf1e_simulator/cli.py @@ -16,7 +16,7 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: 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.runner import EncounterSpec, Side, run_encounter @@ -44,7 +44,7 @@ def _build_spec(args: argparse.Namespace) -> EncounterSpec: if not files: msg = f"side {name!r}: at least one monster JSON file is required" 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])) 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: print(f"pf1e-sim: {exc}", file=sys.stderr) return 2 - except (MapValidationError, MonsterLoadError, SheetLoadError, OSError) as exc: + except (MapValidationError, CombatantLoadError, SheetLoadError, OSError) as exc: print(f"pf1e-sim: {exc}", file=sys.stderr) return 2 try: diff --git a/src/pf1e_simulator/loaders/__init__.py b/src/pf1e_simulator/loaders/__init__.py index 467fd0c..ac08485 100644 --- a/src/pf1e_simulator/loaders/__init__.py +++ b/src/pf1e_simulator/loaders/__init__.py @@ -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 ( LoadReport, SheetLoadError, @@ -7,14 +12,17 @@ from pf1e_simulator.loaders.foundry import ( load_sheet, load_sheet_detailed, ) -from pf1e_simulator.loaders.monster import MonsterLoadError, load_monster __all__ = [ + "CombatantLoadError", "LoadReport", "MonsterLoadError", "SheetLoadError", "SkippedEntry", + "load_combatant", "load_monster", "load_sheet", "load_sheet_detailed", ] + +MonsterLoadError = CombatantLoadError diff --git a/src/pf1e_simulator/loaders/combatant.py b/src/pf1e_simulator/loaders/combatant.py new file mode 100644 index 0000000..7148f5e --- /dev/null +++ b/src/pf1e_simulator/loaders/combatant.py @@ -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 diff --git a/src/pf1e_simulator/loaders/monster.py b/src/pf1e_simulator/loaders/monster.py deleted file mode 100644 index d00e510..0000000 --- a/src/pf1e_simulator/loaders/monster.py +++ /dev/null @@ -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 diff --git a/tests/test_monster_loader.py b/tests/test_monster_loader.py index 360f7dd..bac0fa4 100644 --- a/tests/test_monster_loader.py +++ b/tests/test_monster_loader.py @@ -8,7 +8,7 @@ from pathlib import Path import pytest 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" 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: """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.hp_max >= 1 assert combatant.ac.total >= 10 @@ -28,7 +28,7 @@ def test_pc_sheet_loads(stem: str) -> 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 [a.name for a in esha.attacks] == [ "Lance", @@ -38,15 +38,15 @@ def test_pc_sheets_golden_spots() -> None: assert esha.attacks[0].reach_ft == 10 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.speed_fly_ft == 80 - oni = load_monster(PCS_DIR / "oni.json") + oni = load_combatant(PCS_DIR / "oni.json") assert "Pioche" 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 @@ -86,7 +86,7 @@ def _valid_payload() -> dict[str, object]: 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.size == "Small" assert goblin.cr == "1/3" @@ -118,7 +118,7 @@ def test_goblin_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.size == "Medium" assert orc.hp_max == 6 @@ -139,12 +139,12 @@ def test_orc_golden() -> 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"] 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.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, } ] - with pytest.raises(MonsterLoadError): - load_monster(_write(tmp_path, payload)) + with pytest.raises(CombatantLoadError): + load_combatant(_write(tmp_path, payload)) def test_rejects_zero_hp(tmp_path: Path) -> None: payload = _valid_payload() payload["hp_max"] = 0 - with pytest.raises(MonsterLoadError): - load_monster(_write(tmp_path, payload)) + with pytest.raises(CombatantLoadError): + load_combatant(_write(tmp_path, payload)) def test_rejects_unknown_key(tmp_path: Path) -> None: payload = _valid_payload() payload["bogus_field"] = 1 - with pytest.raises(MonsterLoadError): - load_monster(_write(tmp_path, payload)) + with pytest.raises(CombatantLoadError): + load_combatant(_write(tmp_path, payload)) 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"]}], } ] - with pytest.raises(MonsterLoadError): - load_monster(_write(tmp_path, payload)) + with pytest.raises(CombatantLoadError): + load_combatant(_write(tmp_path, payload))