# pf1e-simulator Simulateur de combat Monte Carlo déterministe pour Pathfinder 1re édition. Le moteur joue une rencontre définie (carte + camps) un grand nombre de fois — chaque combat est reproductible grâce à un générateur aléatoire seedé — puis produit un rapport d'équilibrage en français : taux de victoire par camp avec bande de confiance 3σ, nuls, rounds moyens et attrition par combattant. Phase 0 : le moteur de combat couvre le socle des règles (initiative, attaques, critiques, RD, états de vie, déplacement, mêlée/distance). La couche LLM (tactiques et rédaction du rapport) n'est pas encore implémentée. ## Fonctionnalités - Carte en grille de cases de 5 ft (1,5 m) au format YAML : couches ASCII `terrain`, `elevation`, `zones`, points nommés `markers`, déploiement par camp. - Comptage des diagonales selon la règle 5-10-5, coûts de déplacement selon le terrain, règle des coins pour les diagonales le long des murs. - Monstres définis en JSON (bestiaire) ; combattants déployés dans les zones de la carte ; ids dupliqués automatiquement désambiguïsés (`goblin`, `goblin-2`…). - Combats déterministes : chaque run utilise un flux `SeededRng(seed + i)` ; même seed ⇒ mêmes combats. - Rapport d'équilibrage en français : taux de victoire + bande 3σ (σ = √(p(1−p)/n)), nuls, rounds moyens, attrition moyenne par combattant (touches, critiques, dégâts infligés/subis). - Interface en ligne de commande `pf1e-sim`. ## Prérequis et installation Prérequis : - Python ≥ 3.12 - [uv](https://docs.astral.sh/uv/) (gestionnaire de paquets et d'environnements) Installation : ```bash git clone pf1e-simulator cd pf1e-simulator uv sync --dev # inclut pytest, ruff et basedpyright ``` `uv sync` seul suffit pour n'utiliser que la CLI (pas d'outillage de dev). Vérification : ```bash uv run pf1e-sim --help ``` ## Démarrage rapide La carte d'exemple `data/maps/sample_arena.yaml` et les monstres `data/monsters/goblin.json` / `data/monsters/orc.json` permettent de lancer une première simulation immédiatement. 3 gobelins (camp `players`) contre 2 orcs (camp `monsters`), 1000 combats, seed 1 : ```bash uv run pf1e-sim \ --map data/maps/sample_arena.yaml \ --side players data/monsters/goblin.json data/monsters/goblin.json data/monsters/goblin.json \ --side monsters data/monsters/orc.json data/monsters/orc.json \ --runs 1000 --seed 1 ``` Sortie (extrait) : ``` === Rapport d'équilibrage === Carte : Arène d'essai players : 3 combatants monsters : 2 combatants 1000 combats simulés (seed 1, plafond 100 rounds) Victoires players : 91.4% (914) — bande 3σ : [88.7%, 94.1%] Victoires monsters : 8.6% (86) — bande 3σ : [5.9%, 11.3%] Nuls : 0.0% (0) Rounds moyens : 6.0 ``` ## Utilisation de la CLI ``` usage: pf1e-sim [-h] --map MAP [--side NAME [FILE ...]] [--runs RUNS] [--seed SEED] [--round-cap ROUND_CAP] ``` | Option | Rôle | Défaut | |---|---|---| | `--map MAP` | Fichier YAML de la carte (obligatoire) | — | | `--side NAME [FILE ...]` | Camp `NAME` avec un ou plusieurs JSON de monstres ; répétable pour chaque camp | — | | `--runs RUNS` | Nombre de combats simulés | `1000` | | `--seed SEED` | Graine du générateur aléatoire | `1` | | `--round-cap ROUND_CAP` | Plafond de rounds par combat (au-delà, combat nul) | `100` | Codes de sortie : - `0` — simulation terminée, rapport imprimé. - `2` — erreur d'utilisation ou de données (message sur stderr) : côté inconnu, côté de déploiement manquant, zone trop petite, fichier invalide… Règles de construction d'une rencontre : - Les noms de camps (`--side NAME`) doivent correspondre exactement à la section `deployment` de la carte, et **tous** les camps du déploiement doivent être fournis. - Le même fichier monstre peut être répété pour créer plusieurs combattants identiques ; les ids sont désambiguïsés globalement dans l'ordre de déploiement (`goblin`, `goblin-2`, `goblin-3`). - Les combattants d'un camp remplissent les cases de leur zone dans l'ordre (ligne, colonne). Une zone trop petite pour le nombre de combattants est une erreur. ## Interpréter le rapport Pour chaque camp, le rapport donne le taux de victoire observé et une bande de confiance à 3σ calculée en forme fermée (modèle binomial) : - σ = √(p(1−p)/n) ; la bande est [p − 3σ, p + 3σ], bornée à [0, 1]. - Un affrontement symétrique (combattants identiques sur des zones en miroir) doit rester proche de 50 % ; un écart hors de la bande 3σ signale un déséquilibre (avantage d'initiative, géométrie, portées…). - Une bande large indique un échantillon trop petit pour conclure — augmentez `--runs`. - L'attrition moyenne par combattant indique qui participe réellement : un combattant à distance qui n'est jamais rejoint affiche des dégâts subis proches de zéro, un combattant qui meurt systématiquement affiche des dégâts subis supérieurs à ses PV maximum (dégâts du coup fatal inclus). - Les nuls proviennent des combats atteignant le plafond de rounds (combattants immobiles, camps inaccessibles…). Exemple vérifié — 1 gobelin (players) contre 2 orcs (monsters), 1000 runs, seed 1 : players 22,0 %, bande 3σ [18,1 % ; 25,9 %], 0 nuls, 5,2 rounds moyens. Sans ligne de visée depuis la zone de départ, l'archer gobelin doit contourner le mur central avant de tirer ; mais avec l'économie d'action activée, les orcs parcourent toute leur vitesse (30 ft) puis frappent dans le même tour — le gobelin solitaire ne peut plus les distancer et tombe vite en mêlée sous les 2d4+4 des falchions. C'est ce déséquilibre de cadence qui penche fortement le face-à-face à 1 contre 2 du côté des monstres. ## Formats de données ### Carte (YAML) Chaque couche est un bloc ASCII de mêmes dimensions ; un caractère = une case de `square_size_ft` pieds. Seule `terrain` est obligatoire. ```yaml name: "Arène d'essai" square_size_ft: 5 terrain: | # couche obligatoire #################### #......######......# #..C...######...C..# #......~..~........# #..TT..~..~...TT...# #......~..~........# #..C............C..# #################### legend: # un caractère = un type de terrain "#": { type: wall, move_cost: null, blocks_los: true } # bloque déplacement + LoS ".": { type: floor, move_cost: 1 } "T": { type: rubble, move_cost: 2 } # terrain difficile "~": { type: water, move_cost: 2 } "C": { type: pillar, move_cost: null, cover: true } # bloque le pas, couvert elevation: | # optionnel : chiffres = hauteur (validé, pas encore appliqué) .................... ..1111.............. ..1111.............. .................... zones: | # optionnel : lettres de zone de déploiement .................... .AAAA..........BBBB. .AAAA..........BBBB. .................... markers: # optionnel : points nommés (validés, pas encore utilisés) autel: [1, 16] deployment: # obligatoire si zones : nom de camp -> lettre de zone players: A monsters: B ``` Valeur du `move_cost` : entier > 0 pour un terrain praticable, `null` pour un terrain infranchissable (mur, pilier). `blocks_los` et `cover` sont appliqués par la résolution des attaques (module `los.py`, règle des coins) : pas de ligne d'effet ⇒ l'attaque est impossible ; couvert ⇒ bonus de +4 CA. ### Monstre (JSON) ```json { "name": "Goblin", "level": 1, "size": "Small", "cr": "1/3", "xp": 135, "source": "Bestiary > Goblin", "abilities": { "str_score": 11, "dex_score": 15, "con_score": 12, "int_score": 10, "wis_score": 9, "cha_score": 6 }, "hp_max": 6, "ac": { "total": 16, "touch": 13, "flat_footed": 14 }, "bab": 1, "initiative_mod": 6, "speed_land_ft": 30, "saves": { "fort": 3, "ref": 2, "will": -1 }, "attacks": [ { "name": "short sword", "kind": "melee", "attack_bonus": 2, "damage": [{ "formula": "1d4", "types": ["slashing"] }], "crit_range": 19, "crit_mult": 2 }, { "name": "short bow", "kind": "ranged", "attack_bonus": 4, "damage": [{ "formula": "1d4", "types": ["piercing"] }], "crit_mult": 3, "range_increment_ft": 60 } ] } ``` Champ `range_increment_ft` (optionnel, uniquement pour `kind: ranged`) : incrément de portée en pieds. La pénalité de portée vaut −2 par incrément complet au-delà du premier ; l'arme reste utilisable jusqu'à 10 incréments, au-delà elle n'est pas sélectionnable. `damage_bonus` et `dr` sont optionnels. Deux monstres d'exemple sont fournis : gobelin et orc. ### Fiches de personnages (PJ) `fiches_personnages/` contient les 8 fiches de PJ au format Foundry VTT (`pf1-sheet/v1`) ; le chargeur `loaders/foundry.py` sait les lire. Elles sont transposées en JSON au schéma `Combatant` dans `data/pcs/` (`esha.json`, `harvie.json`, `ierlieth.json`, `jeanne.json`, `misty.json`, `nairda.json`, `oni.json`, `tammara.json`) et se passent directement à la CLI comme les monstres : ```bash uv run pf1e-sim --map data/maps/sample_arena.yaml \ --side players data/pcs/oni.json \ --side monsters data/pcs/tammara.json --runs 200 ``` Les armes placeholder des fiches sans dégâts encodés sont omises (notées dans `notes` le cas échéant) ; le champ `source` de chaque fichier pointe vers la fiche Foundry d'origine. ## Règles implémentées et limites de la Phase 0 Règles modélisées : - 1 naturel = échec automatique ; 20 naturel = touche + menace de critique. - Critique : (dés + bonus) × multiplicateur ; la RD s'applique après multiplication, les types perforants annulent la RD, les dégâts plancher à 0. - Mort quand `hp < min(-10, -CON)` ; `hp ≤ 0` = inactif. - Initiative : triée sur (total, modificateur, ordre de liste), sans re-jet. - Économie d'action : un tour normal donne une action standard + une action de mouvement (ou une action à round complet), plus swift/free/immediate. La politique renvoie une séquence ordonnée d'actions exécutées dans l'ordre ; `default_policy` renvoie `(full_attack,)` si l'ennemi est en portée, `(charge,)` si une ligne droite existe, `(5foot_step, full_attack)` si un seul pas suffit à entrer en portée, ou `(move, attack)` sinon. Les actions immédiates (hors-tour, consomment le prochain swift) ne sont pas encore modélisées. - Attaques multiples par action : une arme avec `count` > 1 (« 2x Talons ») résout `count` balayages indépendants dans la même action ; les balayages restants sont perdus si la cible tombe (inconsciente ou morte) en cours de rafale. Attaque à outrance (action à round complet) : à BAB +6/+11/+16, des attaques supplémentaires à -5/-10/-15 ; les armes naturelles (`count` > 1) ne gagnent pas d'itératifs. Plusieurs armes naturelles en une seule attaque à outrance ne sont pas modélisées. - Mêlée : allonge d'une case ; mouvement : un déplacement parcourt jusqu'à la vitesse du combattant en cases le long du plus court chemin réel (champ de coût Dijkstra depuis la cible, diagonales 5-10-5), en s'arrêtant adjacent à l'ennemi le plus proche — les combattants contournent les murs au lieu d'osciller contre eux. - Ligne d'effet : une cible entièrement derrière un terrain `blocks_los` ne peut pas être attaquée — la politique se déplace jusqu'à gagner une ligne de visée. - Couvert (règle des coins) : la cible gagne +4 CA sur la touche et la confirmation de critique ; couvert mou : une créature active entre l'attaquant et la cible octroie aussi +4 CA aux attaques à distance (les attaques de mêlée ignorent les créatures). - Flanquement : +2 au jet d'attaque de mêlée si un allié actif (doté d'une arme de mêlée) menace la cible depuis la bordure ou le coin opposé. Les attaques à distance ignorent le flanquement ; un allié désarmé ou à distance ne compte pas pour le flanquement. - Attaques d'opportunité : un combattant actif doté d'une arme de mêlée menace les 8 cases adjacentes. Deux déclencheurs sont modélisés : (1) quitter une case menacée — le mouvement est résolu pas à pas, et chaque ennemi qui menace la case actuelle porte une AoO avant que le mouvant ne la quitte ; (2) tirer avec une arme à distance depuis une case menacée — chaque ennemi menaçant porte une AoO avant chaque tir. Une AoO par combattant par round (piste `_aoo_used`, réinitialisée au début du round) ; les AoO sont toujours portées (PF1e permet de les décliner, le simulateur ne le fait pas). Le pas de placement (5 ft) ne provoque jamais d'AoO (voir ci-dessous) ; la retraite ne protège que la case de départ (voir ci-dessous). - Charge : action à round complet. Le combattant se déplace en ligne droite (Bresenham) jusqu'à 2× sa vitesse vers la case la plus proche d'où il peut frapper la cible en mêlée, puis porte une seule attaque de mêlée à +2. La charge impose −2 CA à l'attaquant jusqu'au début de son prochain tour. La ligne droite doit être dégagée (pas de terrain difficile, d'obstacles ni de créatures) ; distance minimum 2 cases (10 ft). `default_policy` choisit la charge quand l'ennemi est hors de portée mais joignable en ligne droite. - Retraite : action à round complet. Le combattant se déplace jusqu'à 2× sa vitesse en s'éloignant de l'ennemi le plus proche (ascension gloutonne du champ de coût Dijkstra depuis la menace). La case de départ n'est pas considérée comme menacée — aucune AoO en la quittant. Les cases ultérieures provoquent des AoO normalement (résolues pas à pas, comme pour le mouvement). `default_policy` ne choisit pas la retraite (disponible via une politique personnalisée). - Pas de placement (5-foot step) : action libre qui déplace le combattant d'une case vers l'ennemi le plus proche sans provoquer d'AoO. Exclusivité mutuelle avec tout autre mouvement (move, charge, retraite) dans le même tour — tracked via `moved_this_turn`, réinitialisé au début du tour. `default_policy` choisit `(5foot_step, full_attack)` quand un seul pas suffit à entrer en portée. - Distance : pénalité cumulative de −2 par incrément de portée complet au-delà du premier, appliquée au jet d'attaque et à la confirmation de critique ; l'arme ranged reste utilisable jusqu'à 10 incréments. Les armes de jet (5 incréments max) ne sont pas distinguées des armes à projectiles. - Jets de sauvegarde : `resolve_save(state, save_type, dc)` — fort/ref/will. 1 naturel = échec automatique, 20 naturel = succès automatique. Les modificateurs d'effets (`StatModifier` avec `target` correspondant) sont appliqués via `resolve_modifiers` (mêmes règles d'empilement que les attaques). Renvoie un `SaveResult` (success, roll, total, dc). Non modélisé (couches `elevation`/`markers` présentes mais non appliquées dans la résolution) : - Manœuvres de combat. - Effets mécaniques de hauteur/élévation. - Tailles Large+ (2×2), allonge > 5 ft et actions immédiates hors-tour. ## Architecture - `dice.py` — parseur de notation de dés (`2d4+1`) et lancer. - `rng.py` — `SeededRng` (flux reproductible par graine) et `ScriptedRng` (séquence de dés scriptée pour les tests de transcripts). - `models.py` — schémas Pydantic v2 : `Combatant`, `AbilityScores`, `ACProfile`, `AttackSpec`, `DamageComponent`, `Saves`, `DamageReduction`. - `map.py` — `MapSpec`/`TerrainType`, chargement YAML et validation (dimensions, caractères connus, zones, déploiement, markers). - `grid.py` — grille 5-10-5 : `distance`, `step_cost`, règle des coins (`diagonal_allowed`), Dijkstra `reachable` avec ou sans budget. - `los.py` — ligne d'effet et couvert par la règle des coins : gate de ligne d'effet dans `weapon_for`, bonus de couvert +4 CA (terrain et couvert mou des créatures) dans `resolve_attack`. - `combat.py` — `CombatEngine` déterministe : initiative, actions, résolution des attaques (couvert, ligne d'effet, pénalités de portée), états de vie, transcripts, politique par défaut. - `effects.py` — système d'effets : `StatModifier` (modificateur de caractéristique avec type de bonus) et `resolve_modifiers` (application des règles d'empilement PF1e). Types de bonus cumulables (dodge, racial, trait, sans type) s'additionnent ; types non-cumulables (morale, sacré, profane, enhancement…) gardent la valeur la plus élevée. Les pénalités suivent les mêmes règles. Fondation pour sorts, conditions, dons et capacités de classe. - `conditions.py` — conditions PF1e : `Condition` (dataclass avec modificateurs + flags comportementaux) et 18 conditions prédéfinies (shaken, sickened, fatigued, exhausted, entangled, dazzled, blinded, deafened, flat-footed, prone, stunned, paralyzed, nauseated, dazed, staggered, frightened, panicked, cowering). Les modificateurs sont intégrés à `resolve_attack` (attaque, dégâts, CA) et `resolve_save` (jets de sauvegarde) via `_stat_modifiers` qui collecte effets + conditions. - `abilities.py` — dons et capacités : `AbilitySpec` (dataclass avec catégorie feat/class/racial/trait), dons passifs prédéfinis (Weapon Focus, Toughness, Iron Will, Great Fortitude, Lightning Reflexes, Alertness, Point-Blank Shot, Dodge) et don actif Power Attack (−X attaque / +2X dégâts, X = BAB//4 + 1, mêlée uniquement, declared as free action at turn start via `state.power_attack`). Les modificateurs sont collectés par `_stat_modifiers` avec filtrage par arme (`weapon_filter`). Le champ `features` de `Combatant` contient les aptitudes permanentes. - `spells.py` — sorts : `SpellSpec` (dataclass avec niveau, école, portée, sauvegarde, DC de base) et types d'effets (`DamageEffect`, `ConditionEffect`, `HealEffect`, `BuffEffect`). Portées PF1e (personal, touch, close, medium, long) calculées par `spell_range_ft`. Chargeur JSON (`load_spell`, `load_spell_registry`) pour `data/spells/`. Le moteur intègre les sorts via `Action(kind="cast_spell")` et `_cast_spell` : résolution de sauvegarde, application des dégâts (avec RD et demi-dégâts sur sauvegarde réussie), conditions, soins (plafonnés à hp_max) et buffs. Le champ `spells` de `Combatant` contient les noms de sorts connus ; le `spell_registry` est passé au `CombatEngine`. - `metrics.py` — statistiques en forme fermée : `win_rate`, `win_rate_sigma`, `win_rate_band` (bande 3σ bornée à [0, 1]). - `runner.py` — `EncounterSpec`/`Side`, `build_states` (placement en zone + désambiguïsation des ids), `run_encounter` (un `SeededRng` par run) et 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/` — `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 La gate de validation complète (tests + lint + types) : ```bash uv run pytest -q # 377 tests uv run ruff check src tests uv run basedpyright src # mode strict ``` - Ruff est configuré avec `select = ["ALL"]`, longueur de ligne 100. - Basedpyright tourne en `typeCheckingMode = "strict"` sur `src` et `tests`. - Le moteur est testé par des transcripts épinglés (combats 1v1 et 2v2 scriptés) et par des assertions statistiques en forme fermée (matchup symétrique dans la bande 3σ). ## Feuille de route - **Phase 1** — magie et états : sorts modélisés comme effets paramétrés, conditions, manœuvres de combat, dons et capacités de classe. Flanquement, attaques à outrance, attaques d'opportunité, charge, retraite et pas de placement sont déjà modélisés. Le système d'effets (`effects.py`), les 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. 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 rédaction du rapport d'équilibrage en français.