# 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 (+4) "W": { type: ruin, move_cost: null, cover_bonus: 2 } # couvert partiel (+2) ; 4 = standard, 8 = amélioré elevation: | # optionnel : chiffres = hauteur (appliqué : coût de pas + high ground) .................... ..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 CA. Le champ optionnel `cover_bonus` (`2`, `4` ou `8`) encode le niveau de couvert PF1e (partiel +2, standard +4, amélioré +8) ; `cover: true` sans `cover_bonus` vaut couvert standard (+4), idem pour un terrain `blocks_los`. ### 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` ou `kind: touch`) : incrément de portée en pieds. La pénalité de portée vaut −2 par incrément complet au-delà du premier ; une arme `ranged` reste utilisable jusqu'à 10 incréments, une arme `touch` jusqu'à 5 (jet/firearm), au-delà elle n'est pas sélectionnable. `damage_bonus` et `dr` sont optionnels. `spell_resistance` (entier), `resistances` (dict type → montant) et `immunities` (liste de types) 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. - Critique améliorée (« Improved Critical ») : le don double la plage de menace — plage effective = `21 - 2 × (21 - crit_range)` (20 → 19-20, 19 → 17-20, 18 → 15-20). Il n'affecte que le jet de menace, jamais le jet de confirmation. Simplification moteur : le don s'applique à toutes les armes de l'attaquant (pas de sélection par type d'arme comme sur table). - Mourir et stabilisation : une créature à `hp < 0` mais pas morte est mourante — inconsciente (inactive), elle perd 1 pv par tour si elle échoue son jet de Constitution DD 10 à son tour d'initiative : `d20 + mod de CON − |hp|` contre 10 ; un 20 naturel réussit automatiquement. En cas d'échec, elle perd 1 pv de plus ; en cas de réussite, elle est stabilisée (plus de perte de pv, reste inconsciente). Une nouvelle blessure sous le seuil de stabilisation (`hp < stabilized_hp`) fait reprendre l'agonie (nouveau jet) ; un soin ramenant à `hp ≥ 0` réinitialise la stabilisation pour toute chute future. La mort survient au seuil existant `hp < min(-10, -CON)` — divergence RAW documentée : la règle du jeu meurt quand les pv négatifs atteignent le score de CON (le moteur est donc d'un pv plus permissif) ; sémantique inchangée. - 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) sont modélisées via une politique de réaction opt-in (`reaction_policy`) — voir « Actions immédiates » ci-dessous. - Retard (`Action(kind="delay")`, action standard) : le combattant renonce à agir maintenant et agit plus tard dans le **même round** — en fin d'ordre si `target_id` est absent, sinon juste avant le combattant désigné. La nouvelle position est encodée dans la clé d'initiative par un total égal à celui de la cible − 0,5 (ou au minimum des totaux − 0,5 sans cible) et **persiste aux rounds suivants** ; le reste du tour est perdu, un second retard dans le même round est rejeté (`already_delayed`), et le drapeau se réinitialise au début de chaque round. `default_policy` ne choisit pas le retard (disponible via une politique personnalisée). - Préparation (`Action(kind="ready", ready_trigger=...)`, action standard) : le combattant se prépare à agir quand un événement déclencheur survient. Le payload est encodé dans les champs porteurs de l'action, comme l'immédiate : `spell_name` → incantation, `maneuver` → manœuvre (avec `direction` / `trick_condition`), sinon attaque simple sur `target_id` (les capacités de classe en payload ne sont pas modélisées). Quatre déclencheurs, mappés aux événements de réaction existants : `enemy_enters_reach` ← pas de mouvement d'un ennemi (simplification documentée : la portée menacée n'est pas recalculée, tout pas ennemi déclenche), `enemy_casts` ← incantation d'un ennemi, `enemy_attacks` ← attaque résolue d'un ennemi, `enemy_hits_ally` ← dégâts subis par un allié du préparant infligés par un ennemi. Quand l'événement correspond, le moteur exécute le payload **hors-tour, sans coût de swift** (au plus une action préparée par événement, sans cascade), puis l'action préparée est **consommée** (one-shot). Une nouvelle préparation remplace la précédente ; le reste du tour (action de mouvement) reste exécutable. Le snapshot expose l'état via la clé `readied` (`{kind, trigger}` ou `None`). `default_policy` ne se prépare pas (disponible via une politique personnalisée). - Défense totale (`Action(kind="total_defense")`, action standard) : le combattant se couvre entièrement et gagne **+4 d'esquive à la CA** jusqu'au début de son prochain tour (champ runtime `defensive_ac_bonus`, réinitialisé avec les autres drapeaux de tour). Le kind ne requiert pas de cible. Simplification documentée : la progression d'Acrobatie (+6 avec 3 rangs) n'est pas modélisée. `default_policy` ne la choisit pas (disponible via une politique personnalisée). - Combat défensif (`Action(kind="fight_defensively")`, action standard) : le combattant se bat avec prudence — **−4 à ses jets d'attaque et de confirmation de critique**, et **+2 d'esquive à la CA** jusqu'au début de son prochain tour (drapeaux runtime `fight_defensively` + `defensive_ac_bonus`). Simplification documentée : la progression d'Acrobatie (−2 / +3 avec 3 rangs) n'est pas modélisée. `default_policy` ne le choisit pas (disponible via une politique personnalisée). - Se relever (`Action(kind="stand_up")`, action de mouvement) : le combattant à plat ventre (renversé par trip/overrun ou après s'être jeté à terre) retire la condition `prone` des **deux** représentations (`active_conditions` durée, `conditions` sans durée) et redevient valide. Le relèvement **provoque une attaque d'opportunité** de chaque ennemi menaçant (résolue via la mécanique de mouvement `_check_move_aoo`, pendant qu'il est encore à plat ventre, CA −4). Sans condition `prone`, l'action est rejetée (`not_prone`). Le kind ne requiert pas de cible. `default_policy` ne la choisit pas (disponible via une politique personnalisée). - Se jeter à terre (`Action(kind="drop_prone")`, action libre) : le combattant ajoute `prone` à la liste `conditions` **sans durée**, donc la condition persiste jusqu'à `stand_up` (contrairement à trip/overrun qui posent `active_conditions` avec 1 round). Les modificateurs de `prone` (−4 CA, −4 aux attaques à distance) s'appliquent immédiatement. Si la condition est déjà présente, l'action est rejetée (`already_prone`). Le kind ne requiert pas de cible. `default_policy` ne la choisit pas (disponible via une politique personnalisée). - Retour d'exécution des actions : chaque action produit un `ActionResult` explicite (kind, ok, reason) consigné dans le snapshot (`last_turn_results` au tour suivant). Les actions invalides sont rejetées et journalisées au lieu d'être des no-op silencieux : cible absente → `missing_target` ; kinds déclarés mais non implémentés (`swift`, `free`, `full_round` — leur implémentation générique reste à faire) → `unsupported_kind` ; dégradation par les filtres staggered/nauseated (ou swift consommé par une immédiate) → `downgraded:staggered` / `downgraded:nauseated` / `downgraded:immediate_swift` ; retard valide → `ok=True, reason="delayed"`, second retard du même round → `ok=False, reason="already_delayed"` ; préparation valide → `ok=True, reason="ready"` ; relèvement sans `prone` → `ok=False, reason="not_prone"` ; se jeter à terre déjà `prone` → `ok=False, reason="already_prone"` ; attaque avec un `weapon_id` choisi inutilisable → `ok=False, reason="weapon_unavailable"`. - 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. Choix de l'arme : `Action(kind="attack", weapon_id=...)` et `Action(kind="full_attack", weapon_id=...)` frappent avec l'arme désignée (l'arc plutôt que l'épée, l'arme principale plutôt que la secondaire) au lieu de l'arme auto-sélectionnée par `weapon_for` (première arme utilisable capable de toucher la cible à cette distance avec ligne d'effet). L'`id` doit désigner une arme utilisable (non brisée, non volée) pouvant atteindre la cible ; sinon l'action est rejetée (`weapon_unavailable`) et aucun jet n'est effectué. Sans `weapon_id`, la sélection automatique est inchangée. `default_policy` ne choisit pas d'arme (disponible via une politique personnalisée). - Mêlée : allonge effective = max(allonge naturelle du gabarit (Small/Medium 5 ft, Large 10 ft, Huge 15 ft, Gargantuan 20 ft, Colossal 30 ft), `reach_ft` de l'arme). Une arme à allonge (`reach_ft` > 5) menace l'anneau de cases à distance égale à son allonge — les cases à 5 ft sont une « dead zone » où elle ne frappe pas ; une arme sans allonge menace les cases adjacentes. 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. Destination explicite : `Action(kind="move", destination=(r, c))` et `Action(kind="5foot_step", destination=(r, c))` déplacent le combattant vers la case `(r, c)` désignée (monter au couvert, quitter un défilé, se désengager) au lieu de auto-diriger vers `target_id`. Pour `move`, la case doit être praticable, libre et atteignable dans le budget de vitesse (sinon rejet `unreachable_destination`) ; le trajet réutilise la mécanique pas à pas avec provocation d'AoO. Pour `5foot_step`, la case doit être exactement adjacente (sinon `unreachable_destination`) et le pas ne provoque jamais d'AoO. Sans `destination`, le comportement auto-dirigé est inchangé ; avec `target_id` et `destination` absents, rejet `missing_target` (U1). `default_policy` ne choisit pas de destination (disponible via une politique personnalisée). - Tailles et occupation multi-cases : une créature occupe un footprint carré à son ancre (`pos`) selon sa taille — Small/Medium 1×1, Large 2×2, Huge 3×3, Gargantuan 4×4, Colossal 6×6 (taille inconnue → 1×1). Distance, ligne d'effet/couvert, flanquement, zones menacées, AoO, charge, mouvement, manœuvres et invocation sont généralisés aux cellules occupées (distance = minimum entre cellules, LoS/couvert = meilleure paire) ; le comportement 1×1 reste bit-identique. - Élévation : monter d'un niveau de hauteur coûte +1 case de mouvement par niveau (la descente est gratuite) ; une créature située plus haut que sa cible gagne +1 à ses jets d'attaque de mêlée et à la confirmation de critique (règle du high ground). La ligne d'effet reste 2D (pas de LoS 3D). - Actions immédiates hors-tour : politique de réaction optionnelle `reaction_policy(engine, state, event)` (défaut : aucune) ; sur événement (`attack_resolved`, `movement_step`, `spell_cast`, `damage_taken`) une réaction `Action(kind="immediate")` peut être exécutée hors tour — une par événement — et consomme le prochain swift de l'état (immédiate pendant le tour : exécutée, consomme aussi le prochain swift — simplification documentée). Les actions préparées (`ready`) sont traitées sur ces mêmes événements **avant** et indépendamment de la politique de réaction (voir « Préparation » plus haut). - 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 le bonus de couvert du terrain (+2 partiel, +4 standard, +8 amélioré via `cover_bonus`) 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 cases à distance de son allonge effective (les 8 cases adjacentes pour une arme sans allonge ; l'anneau à son allonge pour une arme reach — la dead zone de 5 ft n'est pas menacée). 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) ; le budget restant est exposé dans l'Observation via la clé `aoo_remaining` par combattant ; 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 ; les armes ranged restent utilisables jusqu'à 10 incréments, les armes touch avec incrément (armes de jet, firearms) jusqu'à 5. - Touch AC et armes à feu : les attaques `kind: touch` résolvent leur touche contre la CA de contact de la cible (`ac.touch`) ; si la cible perd son bonus de Dextérité à la CA (flat-footed, `denies_dex_to_ac`), la CA de contact est amputée du modificateur de Dextérité. Les armes marquées `firearm: true` (pistolets) résolvent contre la CA de contact uniquement dans leur premier incrément de portée — au-delà, elles frappent la CA totale. Une attaque touch avec incrément est une attaque à distance : elle provoque des attaques d'opportunité depuis une case menacée, subit −4 si l'attaquant est à plat ventre et bénéficie du couvert mou ; un contact de mêlée (sans incrément, ex. le contact paralysant du liche) n'en fait rien. - 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). - Modificateurs conditionnés : un `StatModifier` avec `condition=` n'est appliqué que si la condition tient au moment de la résolution (`_modifier_condition_holds`) : `target_within_30ft_ranged` (arme à distance à ≤ 30 ft), `axe` (arme dont le nom contient « axe »), et les descripteurs de sauvegarde (`fear`, `cold`, `electricity`, `fey`, `divination`, `poison`, `disease`, `mind_affecting`) — les descripteurs sont portés par l'aura de terreur, les sorts (types de dégâts, conditions de peur, écoles enchantement/divination) et les effets de zone. Toute condition inconnue ou non supportée reste exclue (False). - Incantation : chaque `CombatantState` porte `spell_slots_left` (initialisé depuis le champ `spell_slots` du JSON, ex. `{"1": 4, ...}`) ; `_cast_spell` dépense l'emplacement du niveau concerné (`spend_spell_slot`). Le DD vaut `10 + niveau du sort + mod de la caractéristique d'incantation` (`casting_ability`), la sauvegarde est résolue par `resolve_save`, et la RD ne réduit pas les dégâts des sorts (`test_dr_does_not_reduce_spell_damage`). Incanter dans une case menacée provoque des AoO ; tout dégât subi pendant l'incantation impose un jet de concentration dont le DD dépend de la situation (`_concentration_dc`) : `10 + dégâts + niveau` si blessé, `10 + CMB de l'adversaire + niveau` si agrippé, sinon `15 + 2×niveau` (incantation défensive), jet = d20 + niveau + mod de CON (+4 avec le don Combat Casting). Un sort ciblé avec sauvegarde fait pleins dégâts sur un échec et **aucun dégât sur un succès** (la sauvegarde annule) ; le demi-dégât sur sauvegarde réussie ne s'applique que là où l'effet le déclare (`half_on_save: true` pour les rayons/lignes/cônes, `AreaEffect.save_for_half` pour les explosions). `default_policy` choisit soin / offensif / buff via `_find_heal_spell`, `_find_offensive_spell` et `_find_ally_needing_heal`. - Résistance aux sorts (SR) et résistances/immunités énergétiques : un combattant avec `spell_resistance > 0` oppose sa SR à chaque sort qui le cible — jet `d20 + niveau du jeteur` contre la SR ; si le total est inférieur, le sort est résisté (journalisé, tous les effets annulés pour cette cible). Pour les sorts à zone, la SR est résolue par créature touchée (pas seulement sur la cible primaire). Les dégâts énergétiques (feu, froid, électricité, acide, son…) sont annulés par l'immunité (`immunities`) ou réduits par la résistance du type correspondant (`resistances` : soustraction du maximum parmi les types du dégât, plancher 0) ; les types physiques (contondant/perforant/tranchant) restent sur le chemin de la RD. La résistance énergétique s'applique au dégât final (même étape que la RD), y compris aux dégâts de sorts et aux composantes énergétiques des armes. - Concealment (chance de rater) : les conditions `blurred` (20 %, sort flou) et `displaced` (50 %, concealment total) portent un champ `miss_chance` ; quand la cible en porte une, après la touche et la confirmation de critique, le moteur tire un d100 : si `d100 ≤ miss_chance`, l'attaque rate complètement (aucun dégât, pas de critique, deed non consommé — journalisé « concealment »). Une cible avec concealment ne peut pas être la victime d'une attaque sournoise. - Attaque sournoise : un combattant avec la capacité « Sneak Attack » inflige des d6 de dégâts de précision ((niveau + 1) // 2 dés : 1d6 au niveau 1, 2d6 au niveau 3…) si la cible est flanquée (attaque de mêlée uniquement) ou privée de son bonus de Dextérité à la CA (condition `denies_dex_to_ac` ; mêlée, ou attaque à distance à ≤ 30 ft). Les dés sont jetés avec le RNG du moteur et ajoutés APRÈS le multiplicateur de critique (la précision n'est jamais multipliée) et AVANT la RD et les résistances énergétiques ; jamais contre une cible avec concealment. - Tirer dans une mêlée / Precise Shot : toute attaque à distance (kind `ranged` ou `touch` avec incrément) contre une cible engagée dans une mêlée subit −4 au jet d'attaque ET au jet de confirmation (la cible est « engagée » quand un ennemi actif autre que l'attaquant, doté d'une arme de mêlée, menace ses cases) ; la pénalité tombe à −2 si la cible ou l'adversaire menaçant est à ≥ 10 ft (2 cases) de l'attaquant. Le don « Precise Shot » annule la pénalité. Les attaques de mêlée ne sont pas concernées. - Couvert : les trois niveaux PF1e sont modélisés via `cover_bonus` sur le terrain — partiel +2, standard +4 (ou `cover: true`), amélioré +8 ; le couvert mou des créatures reste +4 (couvert standard). - Invocation : les créatures invoquées agissent (calées sur la case d'initiative de leur conjurateur via `_build_round_order`) puis expirent après leur `summon_duration` (`_expire_summons` décrémente puis retire). - Conditions effectives : 23 conditions prédéfinies (`conditions.py`). Leurs drapeaux comportementaux sont appliqués par le moteur : `cant_act` (étourdi, paralysé, dazed, endormi) annule le tour ; `cant_attack` (nauséeux) n'autorise qu'une unique action simple et bloque toute attaque / manœuvre ; `staggered` restreint à une action simple (pas d'attaque à outrance) ; `cant_move` bloque le déplacement ; `denies_dex_to_ac` (à plat ventre, paralysé…) fait perdre le bonus de Dextérité à la CA (CA flat-footed) ; `miss_chance` (flou 20 %, déplacement 50 %) impose la chance de rater du concealment. À plat ventre : −4 CA et −4 aux attaques à distance. Agrippé : −4 Dextérité, −2 attaque/CA/Réflexes, sans déplacement ni AoO. - Manœuvres de combat : trip, disarm, bull_rush, sunder, grapple, escape, overrun, dirty trick, steal, reposition et drag sont modélisées, chacune comme une tentative unique (1d20 + CMB contre CMD) : trip → `prone` ; disarm → `disarmed` (−4 attaque) ; bull_rush → pousse la cible (direction sélectionnable via `Action.direction`) ; grapple → `grappled`/`pinned` ; escape → **une** sauvegarde (bonus statique de la compétence `escape_artist` ou CMB, + un d20) contre CMD — une seule tentative, sans boucle multi-tour. Un sunder endommage l'**arme**, pas les PV : `str_mod + 2` de dégâts à l'arme, qui se brise à 10 d'usure cumulée (`_WEAPON_DURABILITY`) → `disarmed` ; une arme brisée est exclue de `usable_attacks()` et réparable via `Action(kind="repair_weapon")`. overrun → `prone` 1 round + passage au travers si la case au-delà est libre ; dirty trick → condition choisie par `Action.trick_condition` (défaut `dazzled` 1 round ; `blinded`/`deafened`/`entangled`/`nauseated` possibles) ; steal → l'arme de mêlée de la cible est volée (exclue de `usable_attacks()`, récupérable via `Action(kind="retrieve_weapon")`) ; reposition → déplacement de 5 ft de la cible vers le premier voisin libre (direction sélectionnable via `Action.direction`) ; drag → traction de 5 ft de la cible, l'attaquant occupant sa case d'origine. `default_policy` ne choisit pas ces manœuvres (utilisables via `Action(kind="maneuver")` ou une politique personnalisée). - Combat Reflexes : 1 AoO par combattant par round par défaut ; avec le don Combat Reflexes, `1 + mod de Dextérité` AoO par round (`_aoo_allowance`, plancher 1). - Sortilèges du sorcier (witch hex) : le DD vaut `10 + niveau//2 + mod d'Intelligence` (evil_eye, slumber, cackle) ; `cackle` prolonge d'un round les sortilèges déjà actifs, sur une liste instantanée pour ne pas s'étendre à l'infini. Les sorts à zone (AoE) utilisent le DD d'incantation du jeteur et, sur une sauvegarde réussie, ne font que la moitié des dégâts. - Dons : les 8 PJ portent 55 noms de dons uniques ; le chargeur résout chacun d'eux soit vers un effet réel (Power Attack actif, Weapon Focus ciblé par arme, Toughness lié au niveau, dons passifs via `_stat_modifiers`), soit vers un « no-op » tracé en toute transparence (noms décoratifs / de race) — aucun n'est silencieusement ignoré (`tests/test_feats.py`). - Capacités de classe émises par `default_policy`, chacune conditionnée à une ressource `> 0` dépensée dans la méthode d'exécution : `channel_energy` (oni, énergie positive : si un allié est < 50 % de ses PV → soigne tous les êtres vivants à 30 ft ; sinon, si au moins 2 morts-vivants ennemis sont groupés à 30 ft → les blesse en 2d6 ; le camp de la cible n'est pas pris en compte — seul `creature_type` compte, `selective_channeling` non modélisé), sortilège `evil_eye` (nairda), deed `up_close_and_deadly` (tammara, action bonus +1d6 à ≤ 30 ft — consommé sur la première attaque résolue : si elle est à distance ET à ≤ 30 ft, +1d6 s'ajoute aux dégâts ; sinon le bonus est perdu ; le drapeau est réinitialisé dans tous les cas), `inspire_courage` + `spell_combat` (misty) — couvertes par `tests/test_class_abilities.py`. - Le Liche (`data/monsters/lich.json`) : `creature_type: "undead"` (sensible à l'énergie positive canalisée), aura de terreur (30 ft, Volonté DD 22 ou `shaken` 1 round, au début de son tour) et contact paralysant (au toucher, `paralysé` 1d4+1 rounds), RD 15/magie+contondant ; son JSON porte un dictionnaire `spell_slots` sans clés dupliquées. - Snapshot d'observation (contrat LLM, Phase 2) : avant chaque appel de politique, le moteur construit une `Observation` sérialisable (`to_dict()` → JSON) figée — le snapshot est la SEULE source d'info de la LLM (aucune référence `engine.*` exposée, collections copiées/figées). Une politique LLM se branche via `CombatEngine(..., llm_policy=...)` (`LLMPolicy(observation, state) -> tuple[Action, ...]`). Schéma enrichi (E1-E5) : - Par combattant : id, side, pos, occupied_cells, hp/hp_max, active/dead, dying/stabilized, conditions (noms + rounds_left), resources_left (+ `spell_slot_{level}`), weapon_broken/weapon_stolen, grappled_with, moved_this_turn, power_attack, name, level, size, dr {amount, bypass} ou None (C1), creature_type (C3), bab (E1), initiative_mod (E2) et initiative_total (U2 : total du jet d'initiative courant = d20 + mod, 0 si pas encore de clé — invocation fraîche), casting_ability ou None pour non-jeteur (E3), abilities {str/dex/con/int/wis/cha: score brut} (Medium), skills {nom: {rank, class_skill, armor_penalty}} uniquement rank > 0 (Medium), metamagic_feats (tuple trié), weapon_damage {weapon_id: dégâts courants}, summon_duration (int ou None), immediate_used, deed_up_close_and_deadly (flags actuels), active_buffs {source, rounds_left, mods: tuple de {target, value, bonus_type}} (E5), speed_land_ft, speed_fly_ft, ac {total, touch, flat_footed}, saves {fort, ref, will}, cmb, cmd, attacks (référentiel complet : id, name, kind, attack_bonus, damage, damage_bonus, crit_range, crit_mult, reach_ft, range_increment_ft, count, firearm — y compris les armes broken/stolen), features (noms des capacités), feature_details (C4 : tuple de {name, category, source_detail, effects: tuple de {target, value, bonus_type, condition, weapon_filter}}), spells (résumés enrichis du registre : name, level, school, range_ft, save, dc, summary, half_on_save, save_negates (E4), area {shape, radius_ft} | None (U7a : premier AreaEffect de niveau supérieur du sort, gabarit de zone structuré, None si le sort n'a pas de zone)), spell_resistance (int), resistances {type: montant}, immunities (tuple triée des types immunisés), sneak_dice (dés de précision disponibles : (niveau + 1) // 2 avec « Sneak Attack », sinon 0), engaged_in_melee (bool — une cible engagée dans une mêlée, signal pour le tir dans la mêlée et l'attaque sournoise), dying (bool — `hp < 0` et pas morte : agonisante, inconsciente), stabilized (bool — stabilisée pendant son agonie, ne perd plus de pv tant qu'aucune nouvelle blessure ne la remet en agonie), last_turn_results (tuple de {kind, ok, reason} — résultat d'exécution du dernier tour, pour que la policy apprenne les actions rejetées/dégradées), readied ({kind, trigger} de l'action préparée ou None — préparation en cours, consommée quand le déclencheur la fait tirer), defensive_ac_bonus (int — bonus d'esquive à la CA de la défense totale (+4) ou du combat défensif (+2), réinitialisé au début du tour du combattant), fight_defensively (bool — combat défensif actif : −4 aux jets d'attaque et de confirmation jusqu'au prochain tour), aoo_remaining (int — attaques d'opportunité restantes ce round : `_aoo_allowance` (1 par défaut, 1 + mod de Dex avec Combat Reflexes, plancher 1) moins les AoO déjà consommées, réinitialisé à chaque nouveau round, jamais négatif). - Par paire combattant actif × cible : target_index, distance_ft, weapon_for, range_penalty, can_charge, can_step5_to_attack, has_line_of_effect, flanking, movement_cost_ft, target_threatens_me, enemies_threatening_me, cover_bonus (int — bonus de couvert que la cible gagne contre une attaque de l'acteur, calculé comme dans `resolve_attack` : terrain +2/+4/+8 via la règle des coins, couvert mou +4 des créatures interposées pour les seules attaques à distance ; 0 si `weapon_for` est None), weapon_reason (str | None — None si `weapon_for` a choisi une arme ; sinon la raison normalisée de l'indisponibilité d'attaque, dans le vocabulaire `ACTION_RESULT_REASONS` : `no_weapon` (aucune arme utilisable), `no_line_of_effect` (ligne d'effet bloquée), `out_of_range` (arme existante mais cible hors de portée)), charge_reason (str | None — None si `can_charge` est vrai ; sinon la raison de `ChargeResult` verbatim (`no_line_of_effect` / `no_charge_path`) ou `no_weapon` si aucune arme de mêlée utilisable). - Top-level : round, acting_combatant, combatants, pairs, terrain, elevation, threatened (grille bool des cases menacées par les ennemis), initiative_order (U2 : tuple d'ids dans l'ordre d'initiative du round courant — le premier est le prochain à agir ; les invocations héritent de la case de leur conjurateur), terrain_semantics (un dict par type de terrain : type, move_cost, blocks_los, cover, cover_bonus), condition_definitions (C2 : tuple trié de (nom, {mods, flags, speed_mult, miss_chance}) — définitions complètes des 23 conditions de CONDITIONS_BY_NAME pour que la LLM interprète les drapeaux, les modificateurs et les chances de rater (concealment)). Les helpers publics du moteur (L5) sont désormais embarqués EN DONNÉES dans le snapshot (`threatened`, `terrain_semantics`, `movement_cost_ft`, `target_threatens_me`, `enemies_threatening_me`) — une `LLMPolicy` ne peut pas appeler `engine.*`, elle lit leurs résultats. Mapping des clés `resources_left` (vérifié par grep sur `spend_resource`/`resources_left[` : `channel_energy`, `hexes`, `grit`, `arcane_pool`, `bardic_rounds`, plus `spell_slot_{level}` ajouté par le snapshot). Limites documentées : pas de grille `reachable_ft` budget=speed (délibérément rejetée — `movement_cost_ft` par paire + `terrain_semantics` + `speed_land_ft` suffisent). Le couvert par paire est exposé via la clé `cover_bonus` (calcul identique à `resolve_attack`, couvert mou des créatures inclus). Les API de décision exposées (items L1-L8) s'y retrouvent : `direction`/`trick_condition`/`weapon_id` sur `Action`, `can_charge` → `ChargeResult`, kinds `retrieve_weapon`/`repair_weapon`. Le kind `activate_power_attack` (action libre) active le don Power Attack (`state.power_attack = True`) si le combattant possède le don ; sans le don, c'est un no-op journalisé. Le snapshot expose l'état courant via la clé `power_attack` (bool) par combattant. Non modélisé : la couche `markers` (points nommés de la carte) est validée à la lecture mais non appliquée dans la résolution. ## 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` (avec `firearm` pour les armes touch à feu), `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` (terrain + élévation), 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 par niveau (+2/+4/+8 terrain via `cover_bonus`, +4 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, CA de contact pour les attaques touch et firearms), tailles et allonges (occupation multi-cases, zones menacées en anneau), high ground, actions immédiates opt-in (réactions hors-tour), états de vie, incantation (emplacements, concentration par situation, AoO d'incantation), invocation, manœuvres (avec sélection directionnelle et choix de condition d'état), capacités de classe, transcripts, politique par défaut, snapshot d'observation sérialisable `Observation`/`LLMPolicy` (frontière LLM). Les modificateurs conditionnés (`StatModifier.condition`) sont évalués via `_modifier_condition_holds` avec le contexte d'action (arme, portée, descripteurs de sauvegarde) ; les conditions inconnues restent exclues. - `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 + `miss_chance`) et 23 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 ; les drapeaux comportementaux (`cant_act`, `cant_attack`, `cant_move`, staggered, etc.) sont appliqués par le moteur pendant le tour. - `abilities.py` — dons et capacités : `AbilitySpec` (dataclass avec catégorie feat/class/racial/trait) et fabriques de dons (Weapon Focus ciblé par arme, Toughness lié au niveau, Power Attack actif −X attaque / +2X dégâts, X = BAB//4 + 1, mêlée uniquement, activé comme action libre via le kind `activate_power_attack` qui pose `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 ; le chargeur (`loaders/combatant.py`) résout les noms de dons des fiches vers des `AbilitySpec` réels ou des « no-op » tracés. - `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/`. Des descripteurs de sauvegarde (`spell_save_descriptors` / `save_descriptors_for_effects` : types de dégâts, conditions de peur, écoles enchantement/divination) sont dérivés des effets pour évaluer les modificateurs conditionnés lors d'une sauvegarde. Le moteur intègre les sorts via `Action(kind="cast_spell")` et `_cast_spell` : résolution de sauvegarde, application des dégâts (aucun dégât sur sauvegarde réussie pour un effet ciblé — la sauvegarde annule ; demi-dégâts seulement si l'effet déclare `half_on_save` ; la RD ne s'applique pas aux dégâts de sorts), 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 # 714 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 : largement réalisée. Sorts par emplacements (`spells.py` + base `data/spells/`, 11 sorts), DD et sauvegardes, RD non appliquée aux dégâts de sorts, concentration par situation, invocation, conditions effectives (`conditions.py`), manœuvres de combat, dons (55 noms résolus, `abilities.py` + chargeur) et capacités de classe (channel energy, hex, deeds, inspire courage / spell combat) sont en place et documentés dans « Règles implémentées ». Le socle B5 est en place : hauteurs/élévations (coût de déplacement + high ground), tailles Large+ (occupation multi-cases), allonges > 5 ft (allonge naturelle + `reach_ft`, zones menacées en anneau) et actions immédiates hors-tour (politique de réaction opt-in). Restent hors du socle (voir « Non modélisé ») : les markers. - **Phase 2** — couche tactique LLM : stratégies en langage naturel traduites en politiques, balayage de matrices de positionnement. Le contrat d'observation (snapshot sérialisable `Observation` + wrapper `LLMPolicy`) est en place ; la couche LLM elle-même reste à implémenter. - **Phase 3** — rapporteur LLM local : agrégation des statistiques et rédaction du rapport d'équilibrage en français. (Non implémentée.)