# 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 `(move, attack)` quand l'ennemi le plus proche est hors de portée (déplacement à pleine vitesse le long du chemin Dijkstra, puis attaque si une arme est utilisable) et `(attack,)` sinon. Les actions immédiates (hors-tour, consomment le prochain swift) et les actions spéciales (AoO, flanquement, attaque à outrance, charge, retraite) 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. - 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. Non modélisé en Phase 0 (couches `elevation`/`markers` présentes mais non appliquées dans la résolution) : - Sorts, jets de sauvegarde, conditions et états. - Attaques d'opportunité, manœuvres de combat. - Effets mécaniques de hauteur/élévation. - Tailles Large+ (2×2), allonge > 5 ft, actions spéciales (charge, pas de placement, retraite) 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. - `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/` — `foundry.py` (fiches Foundry `pf1-sheet/v1`) et `monster.py` (JSON de monstre). ## Développement La gate de validation complète (tests + lint + types) : ```bash uv run pytest -q # 206 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 : jets de sauvegarde, sorts modélisés comme effets paramétrés, conditions, flanquement, attaques d'opportunité. - **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.