# 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 ; le 20 naturel au jet de confirmation confirme automatiquement la critique (conformance P2 — le jet de confirmation est un jet d'attaque) ; la RD s'applique après multiplication, un type de contournement annule la RD — une RD à plusieurs composants (« DR X/Y and Z », conformance P2) exige que l'attaque porte TOUS les composants (le contournement n'est plus traité comme un OU), 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 − |pv négatifs|` 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 quand les pv négatifs atteignent le score de CON (`hp ≤ -CON`) — conformance P2 : la règle du −10 de D&D 3.5 n'existe pas en PF1e et n'est plus modélisée. - Invalide (conformance P2, CRB « Effects of Death ») : à `hp = 0` exactement, une créature est **invalide** — elle reste active mais **staggered** (une action de mouvement OU une action standard par tour, pas d'action à round complet), se déplace à **moitié vitesse**, et achever une action non-mouvement (standard ou pénible) lui coûte **1 pv** à la fin de cette action (pv → −1, agonie). Simplification documentée : si l'action elle-même soigne la créature au-dessus de 0 pv, ce coût n'est pas appliqué. - `hp < 0` = inactive (mourante ou morte) ; `hp = 0` = active mais invalide. - 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` s'appliquent immédiatement : l'attaquant à plat ventre subit −4 à ses attaques (de mêlée selon le CRB ; les armes à distance sont traitées comme des arbalètes — utilisables à −4, simplification documentée), la cible à plat ventre subit −4 CA contre les attaques de mêlée et gagne +4 CA contre les attaques à distance. 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). - Aider autrui (`Action(kind="aid_another", aid_mode=...)`, action standard, U5e) : l'aidant **doit avoir une arme de mêlée utilisable** ; il fait un jet d'attaque `d20 + bonus d'attaque de sa première arme de mêlée utilisable` contre le CA 10 (20 naturel réussit toujours, 1 naturel échoue toujours — convention des jets d'attaque du moteur). Sur succès, la cible empile **+2** soit à son prochain jet d'attaque (`aid_mode="attack"` ou absent — consommé one-shot par `resolve_attack`, attaque et confirmation de critique), soit à sa CA contre la prochaine attaque la ciblant (`aid_mode="ac"`). Plusieurs aides s'additionnent (le bonus s'ajoute, ne se remplace pas) ; tout bonus inutilisé expire au début du prochain tour du bénéficiaire. Simplifications documentées : pas de vérification d'adjacence/ligne d'effet vers l'allié, toute cible résolue peut être aidée, et la progression de compétence d'aide (+5 avec 5 rangs, +8 avec 10) n'est pas modélisée. Sans cible → `missing_target` ; sans arme de mêlée utilisable → `no_weapon`. `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"` ; incantation avec un `target_pos` hors carte ou sans ligne d'effet → `ok=False, reason="invalid_destination"` (U7b) ; course ou aide sans cible **et** sans destination → `missing_target` ; aide sans arme de mêlée utilisable → `no_weapon` ; course vers une `destination` hors du budget 4× → `unreachable_destination` (U5e). - 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. Vitesse de déplacement (V4) : le budget de tout mouvement (déplacement, charge 2×, retraite 2×, course 4×) est le **meilleur entre la vitesse au sol (`speed_land_ft`) et la vitesse de vol (`speed_fly_ft`)** — une créature dotée d'une vitesse de vol (harvie : vol 80 vs sol 30, ierlieth : vol 40) se déplace donc à sa vitesse de vol. Simplification documentée : voler PAR-DESSUS les obstacles ou le terrain difficile n'est PAS modélisé — la praticabilité et les coûts de terrain de la grille s'appliquent à tout le budget (pas de vol 3D ni de maniabilité) ; le snapshot expose ce budget sous la clé additive `speed_effective_ft`. 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. - Taille et attaques/CA : les valeurs `ac` (total/touch/flat_footed) et `attack_bonus` des JSON de créatures sont des valeurs FINALES, déjà majorées du modificateur de taille PF1e aux attaques/CA (Small +1, Medium 0, Large −1, Tiny +2…) — vérifié sur toutes les fiches (`data/monsters/*.json`, `data/pcs/*.json`, verrouillé par `tests/test_size_is_baked_in.py`). Le moteur n'applique donc JAMAIS de bonus de taille aux jets d'attaque ni à la CA (ce serait un double comptage) ; la taille n'intervient dans la résolution que pour les manœuvres de combat (CMB/CMD, table `_SIZE_MODIFIERS` Small −1 … Colossal +8 — colonne distincte de la table attaques/CA), l'allonge naturelle, le footprint et l'occupation. - É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 (toucher de mêlée inclus, voir « Touch AC ») si un allié actif menace réellement la cible depuis la bordure ou le coin opposé — conformance P2 : l'allié doit **menacer** une case de la cible avec une arme de mêlée utilisable (la « dead zone » de 5 ft d'une arme allongée ne menace pas) et ne pas être incapable d'agir (étourdi/paralysé ne flanque pas). Les attaques à distance ignorent le flanquement ; un allié désarmé ou à distance ne compte pas. - 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). - Course (`Action(kind="run")`, action à round complet, U5e) : le combattant se déplace jusqu'à **4× sa vitesse** vers `target_id` (auto-dirigé, s'arrête adjacent à la cible) ou vers une `destination` explicite (U5c), en réutilisant exactement la mécanique de `move` (descente du champ de coût Dijkstra, pas à pas avec provocation d'AoO — courir ne protège pas des AoO). À l'achèvement, le combattant est `running` jusqu'au début de son prochain tour : il **perd son bonus de Dextérité à la CA** (résolu par le même chemin flat-footed/`denies_dex_to_ac` que les conditions). Une `destination` hors budget 4× (ou invalide) est rejetée (`unreachable_destination`) ; sans cible ni destination, rejet (`missing_target`). `default_policy` ne choisit pas la course (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. Conformance P2 : le contact de mêlée (touch sans incrément) est une **attaque de mêlée** — il bénéficie du flanquement, de la hauteur (+1), du +4 contre une cible inerme et peut porter une attaque sournoise ; Power Attack reste réservé aux armes de mêlée (le contact n'en bénéficie pas). - 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`) — **sans emplacement disponible, l'incantation est refusée** (journalisée `NO SPELL SLOT`, aucun jet consommé) ; les cantrips (niveau 0) s'incantent librement. 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 la caractéristique d'incantation (Int/Wis/Cha — jamais Constitution, CRB) ; le don Combat Casting n'accorde son +4 qu'en incantation défensive ou quand on est agrippé, jamais pour les dégâts subis pendant l'incantation. 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). Ciblage par point (U7b) : `Action(kind="cast_spell", target_pos=(r, c))` centre l'`AreaEffect` de niveau supérieur du sort sur la case `(r, c)` désignée au lieu de la position de la cible créature — poser un Fireball au sol, pas seulement sur une tête. Le point doit être dans la carte et avoir une ligne d'effet depuis le jeteur (sinon rejet `invalid_destination`, aucun sort lancé). Avec `target_pos` et pas de cible résolue, l'incantation se fait sur le point : la sauvegarde primaire est sautée et chaque créature de la zone effectue sa propre sauvegarde (SR incluse). `target_pos` n'a de sens que pour un sort à zone : pour un sort ciblé sans `AreaEffect`, il est ignoré et la cible résolue est utilisée normalement. Sans `target_pos`, le comportement centré sur la créature est inchangé. `default_policy` ne choisit pas de point (disponible via une politique personnalisée). `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), `displaced` (50 %, concealment total) et `invisible` (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. Attaquant aveugle (V5a) : un attaquant portant `blinded` considère tous ses adversaires comme ayant un concealment total — le même jet unique de d100 est tiré avec `miss_chance = max(concealment de la cible, 50)` et journalisé « attacker blinded » ; l'attaque peut donc rater même contre une cible sans concealment. - Attaquant invisible (V5a) : un attaquant portant `invisible` gagne **+2 à ses jets d'attaque et de confirmation de critique** et sa cible **perd son bonus de Dextérité à la CA** (même chemin `denies_dex_to_ac` que flat-footed/course). L'invisibilité est symétrique : subi comme cible, la condition accorde le concealment total de 50 % ci-dessus. - Cible inerme (helpless, V5a + conformance P1) : sont traitées comme **inertes** les conditions `helpless`, `paralyzed`, `asleep` et `unconscious` (CRB : inerme = paralysé, immobilisé, ligoté, endormi, inconscient — `stunned` **ne** rend **pas** inerme). Les attaques de **mêlée** contre une cible inerme gagnent **+4 au jet d'attaque et à la confirmation** (aucun bonus à distance) ; la cible a une **Dextérité effective de 0** : son modificateur de Dextérité est entièrement remplacé par la pénalité Dex 0 (−5), en plus de la perte du bonus de Dextérité (CA flat-footed via `denies_dex_to_ac`). Simplification documentée : l'attaque sournoise automatique du roublard sur cible inerme n'est pas modélisée (la cible, privée de Dextérité, la déclenche déjà via le chemin `denies_dex_to_ac`). - 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 — toucher de mêlée inclus, conformance P2) 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. Conformance P2 : les morts-vivants (`undead`) et les constructeurs (`construct`) sont immunisés aux dégâts de précision — la cible ne peut pas être la victime d'une attaque sournoise ; les autres immunités RAW (vase, plante) ne sont pas modélisées (pas de tels types dans le modèle). - 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 : 28 conditions prédéfinies (`conditions.py`). Leurs drapeaux comportementaux sont appliqués par le moteur : `cant_act` (étourdi, paralysé, dazed, endormi, inerme, fasciné) annule le tour ; `cant_attack` (nauséeux) n'autorise qu'une unique action de mouvement (`move`, `5foot_step`, `stand_up`, `drop_prone`) et bloque toute attaque, manœuvre **et incantation** (CRB : un nauséeux ne peut ni attaquer, ni incanter, ni se concentrer) ; `staggered` restreint à une action simple (pas d'attaque à outrance) ; `cant_move` bloque le déplacement ; `denies_dex_to_ac` (flat-footed, paralysé, aveuglé…) fait perdre le bonus de Dextérité à la CA (CA flat-footed) ; `miss_chance` (flou 20 %, déplacement 50 %, invisible 50 %) impose la chance de rater du concealment. À plat ventre : l'attaquant à plat ventre subit −4 à ses attaques (mêlée selon le CRB ; distance traitée arbalète à −4, simplification documentée) ; la cible à plat ventre subit −4 CA contre la mêlée et gagne +4 CA contre la distance (le CRB inverse du « −4 CA contre tout » précédemment modélisé). Agrippé : −4 Dextérité — le modificateur cible `dex_skills` s'applique désormais réellement aux compétences de Dexterity (Acrobaties, Escalade, Équilibre, Évasion, Vol, Équitation, Manipulation de corde, Discrétion, Natation, Désamorçage — conformance P2) —, −2 attaque/CA/Réflexes, sans déplacement ni AoO. Épinglé : même −4 `dex_skills`. Aveuglé (conformance P2) : −2 CA, perte du bonus de Dextérité à la CA, −4 aux compétences de Dexterity (`dex_skills`) et **aucun malus plat d'attaque** — la pénalité RAW est la dissimulation totale (50 % d'échec, modélisée) ; le −2 d'attaque non-RAW qui s'y superposait a été supprimé. Simplification documentée : la moitié Force du −4 RAW (compétences de Force) n'est pas modélisée (pas de cible `str_skills`). Inerme (`helpless`, `paralyzed`, `asleep`, `unconscious`) : ne peut pas agir, Dextérité effective 0 (−5 CA en plus de la perte du bonus), +4 en mêlée contre lui (voir « Cible inerme ») ; `stunned` ne rend pas inerte. Fasciné (`fascinated`) : ne peut pas agir. Invisible (`invisible`) : concealment total subi +2 attaquant / perte de Dex de la cible (voir « Attaquant invisible »). Saignement (`bleeding`, V5b) : une créature qui saigne subit `bleed_damage` points de dégâts au **début de son tour** (avant politique et flags) ; tout effet restaurant des points de vie (sort de soin, channel energy) stoppe le saignement (`bleed_damage = 0`) et retire la condition. Simplification documentée : le jet de compétence Soin DD 15 n'est pas modélisé (le moteur n'a pas d'action Soin). Confus (`confused`, V5b) : au début de chaque tour, jet d% — 01-25 agit normalement (le tour se poursuit) ; 26-50 bave incohéremment (tour perdu) ; 51-75 se inflige 1d8 + modificateur de Force avec l'objet tenu ; 76-100 attaque d'une seule attaque de mêlée la créature la plus proche (allié OU ennemi, une seule si en allonge ; sinon le tour est perdu, journalisé). Simplification documentée : la clause « si elle est blessée, riposte l'agresseur » est réduite à « attaque la créature la plus proche » ; le coup de grâce n'est pas modélisé. - 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) : le jet de manœuvre est un jet d'attaque — 20 naturel réussit toujours, 1 naturel échoue toujours (conformance P2). Conformance P2 : tenter une manœuvre (sauf `escape`) **provoque une attaque d'opportunité** de la cible, résolue **avant** le jet de CMB ; le don « Improved » correspondant (Improved Trip, …) annule cette provocation ; si l'AoO fait passer l'attaquant sous 0 pv, la manœuvre est perdue. L'AoO de manœuvre consomme le budget d'AoO du round de la cible (Combat Reflexes inclus). 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 chaque créature de la zone effectue la sauvegarde listée par le sort (Volonté / Réflexes / Constitution — le sort primaire réutilise son jet déjà effectué) ; un succès annule ou divise par deux selon ce que l'effet déclare (`save_negates` / `half_on_save` / `AreaEffect.save_for_half`) — les zones « sauvegarde annule » (Sleep, Color Spray, Web) sauvent donc aussi. - 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/contondant **et** magie (conformance P2 : les deux composants sont requis — une arme contondante non magique ne contourne plus la RD ; lacune documentée : le modèle d'arme n'expose pas de propriété « magique », donc aucun équipement ne contourne cette RD) ; 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, speed_effective_ft (V4 : budget de déplacement effectif = max(land, fly), en pieds), 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), aid_attack_bonus (int — bonus d'aide autrui (+2 par aide réussie, cumulatif) ajouté au prochain jet d'attaque du combattant puis consommé, expiré au début de son tour s'il est inutilisé), aid_ac_bonus (int — bonus d'aide autrui (+2 par aide réussie, cumulatif) ajouté à sa CA, expiré au début de son tour), running (bool — course en cours jusqu'au prochain tour du combattant : il perd son bonus de Dextérité à la CA), bleed_damage (int — dégâts de saignement subis au début du tour du combattant, 0 = ne saigne pas ; V5b ; `bleeding`/`confused` sont visibles via `conditions`). - 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 au contact par les ennemis — AoO), ranged_threatened (U6d : grille bool des cases sous tir ennemi à distance — champ de tir des armes à distance utilisables des ennemis actifs (portée maximale + ligne d'effet, bornée à la carte) ; les armes à distance ne menacent pas pour les AoO en PF1e : c'est une carte de champ de tir, pas une menace d'AoO ; la grille `threatened` de mêlée reste inchangée), 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 28 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_effective_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 28 conditions prédéfinies (shaken, sickened, fatigued, exhausted, entangled, dazzled, blinded, deafened, flat-footed, prone, stunned, paralyzed, nauseated, dazed, staggered, frightened, panicked, cowering, helpless, fascinated, invisible, bleeding, confused). 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/`, 107 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. Les capacités désormais débloquées du moteur (ready, delay, défense totale, destination de déplacement, choix d'arme, boucle de feedback) sont démontrées par une politique lisant uniquement l'`Observation` dans `tests/test_llm_engine_capabilities.py` (démo de capacités, pas une implémentation de la couche LLM). - **Phase 3** — rapporteur LLM local : agrégation des statistiques et rédaction du rapport d'équilibrage en français. (Non implémentée.)