# 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, soins prodigués). - 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 : 96.2% (962) — bande 3σ : [94.4%, 98.0%] Victoires monsters : 3.6% (36) — bande 3σ : [1.8%, 5.4%] Nuls : 0.2% (2) Rounds moyens : 6.6 ``` ## Utilisation de la CLI ``` usage: pf1e-sim [-h] --map MAP [--side NAME [FILE ...]] [--runs RUNS] [--seed SEED] [--round-cap ROUND_CAP] [--transcript] [--simple-monster-death] ``` | 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, entier ≥ 1 (au-delà, combat nul) | `100` | | `--transcript` | Imprimer le déroulé du premier combat **en tableau** avant le rapport (voir ci-dessous) | désactivé | | `--simple-monster-death` | Mort simplifiée des monstres : `hp ≤ 0` au lieu de `hp ≤ -CON` (les PJ ne sont jamais affectés) | désactivé | Affichage du transcript (`--transcript`) : les lignes du moteur sont présentées dans un tableau à trois colonnes alignées — `Round`, `Combattant`, `Message` — précédé d'un en-tête et d'une ligne de séparation. Les largeurs de colonnes sont calculées sur le contenu affiché et l'alignement tient compte de la largeur d'affichage réelle des caractères (accents, CJK). Les lignes du transcript qui ne suivent pas le format `round N id: message` (jets d'initiative, fin de combat) restent affichées telles quelles, en ligne brute intercalée à leur position d'origine — aucune entrée n'est jamais perdue ni tronquée. Exemple : ``` === Déroulé du premier combat === Round Combattant Message ----- ---------- --------------------------------------------------------- initiative: orc d20=19+0=19 1 orc move (1,15)->(2,14)->(3,13)->(3,12)->(3,11)->(3,10) 1 goblin short bow vs orc d20=9+4=13 AC 17 -> MISS battle over: players win in 4 rounds ``` Les données du transcript (contenu et ordre des lignes du moteur) ne sont pas modifiées par cette présentation. 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. - Chaque camp du déploiement doit être lié à une lettre de zone **distincte** : deux camps partageant la même lettre se déploieraient l'un sur l'autre, ce qui est une erreur de carte (`share zone letters`). ## 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). La colonne « soins prodigués » compte les PV réellement rendus par le combattant (sorts de soin et channel energy en mode soin), plafonnés aux PV maximum des cibles — les soins excédentaires (overheal) ne sont pas comptés ; les dégâts infligés et les subis restent strictement réservés aux dégâts (le soin n'a jamais été compté dans les dégâts infligés). - 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 33,0 %, bande 3σ [28,5 % ; 37,5 %], 6 nuls (0,6 %), 7,1 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 durablement et finit par tomber en mêlée sous les 2d4+4 des falchions. Il parvient parfois à en coucher un avant : ces duels inégaux prolongés, et les rares combats de kiting menés jusqu'au plafond de rounds (les 6 nuls), expliquent que sa part dise atteigne tout de même un tiers des combats. C'est ce déséquilibre de cadence qui penche nettement le face-à-face à 1 contre 2 du côté des monstres (deux tiers des combats). ## 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). **Omettre `move_cost` équivaut à `null` : le terrain est infranchissable** — un terrain praticable doit donc toujours déclarer explicitement son coût (une entrée de légende sans coût de déplacement n'est jamais un couloir invisible). `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`. Une créature **aérienne** (vol ≥ sol, voir « Vitesse de déplacement ») ignore les coûts de `move_cost` > 1 et **enjambe** les cases `move_cost: null` qui ne portent **pas** `blocks_los` (fosses, gravats, piliers non bloquants) ; une case `blocks_los` (barrière solide, hauteur supposée pleine) l'arrête aussi. Sémantique de `blocks_los` — **ligne d'effet, non ligne de vision** (SRD « Aiming a Spell — Line of Effect ») : « A line of effect is a straight, unblocked path that indicates what a spell can affect. A line of effect is canceled by a solid barrier. It's like line of sight for ranged weapons, except that it's not blocked by fog, darkness, and other factors that limit normal sight. » Le drapeau encode donc une **barrière solide** ; un terrain qui ne limite que la vision normale (brouillard, obscurité, dissimulation) n'est pas modélisé — le moteur n'a qu'un seul drapeau, et la dissimulation passe par les conditions (`miss_chance`), pas par la carte. ### 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, entier **strictement positif** (zéro ou négatif = erreur de données, rejetée au chargement). 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. Le booléen `two_handed` (optionnel, défaut `false`) marque une arme tenue à deux mains : Power Attack donne alors +3X (et non +2X) aux dégâts (conformance B16). Le booléen `reach_weapon` (optionnel, défaut `false`) marque une arme dotée de la qualité « reach » du CRB (lance, glaive, longspear, etc.) : elle double l'allonge naturelle du porteur mais ne frappe pas à cette allonge naturelle ou moins — anneau avec dead zone (conformance R1, voir « Mêlée »). Le `reach_ft` d'une attaque naturelle (morsure, sabots) n'est **pas** une arme à allonge : il encode l'allonge naturelle de la créature, qui reste un disque. L'objet `splash` (optionnel, défaut `null`) marque une arme de jet à éclaboussures (SRD « Throw Splash Weapon ») : `{"damage": N, "type": "fire"}` — dégâts d'éclaboussure plats (type énergétique, comme dans `damage`) infligés sans jet ni sauvegarde à toute créature à 5 pieds du point d'impact (touche directe) ou d'atterrissage (dispersion au raté, voir « Armes de jet à éclaboussures »). Une arme `splash` doit être lançable (`kind: ranged`, ou `kind: touch` avec `range_increment_ft`) — le schéma rejette `splash` sur une attaque de mêlée ou sans incrément. `spell_resistance` (entier), `resistances` (dict type → montant) et `immunities` (liste de types) sont optionnels. Le tableau `carried_items` (optionnel, défaut vide) énumère les **objets transportés non maniés et non portés serré** — potions, scrolls, outils, bourses, clés, vivres… — seuls objets volables par la manœuvre « Steal » de l'APG (voir « Manœuvres de combat »). Les armes maniées vivent dans `attacks` (elles se prennent par disarm, pas par steal) ; l'équipement porté serré (armure, sac à dos, vêtements, anneaux) ne peut pas être volé et n'a pas à être encodé — la CA des JSON reste une valeur finale. 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. Les `carried_items` (objets volables par « Steal ») sont transcrits des inventaires `inventory.items` des fiches Foundry (esha 13, jeanne 3, misty 9, nairda 7, oni 6, tammara 20), filtrés conformément à l'APG : armes maniées (déjà dans `attacks`), équipement porté serré (armures, anneaux, amulettes, capes, ceintures, wayfinders, vêtements, sacs à dos) et contenants portés exclus. Trois fiches n'ont **aucun** objet volable : `harvie.json` (fiche sans inventaire), `ierlieth.json` (seul un « Backpack of Naidra » — sac à dos = porté serré, non volable) et `esha_mount.json` (monture sans inventaire propre) — listes laissées vides. Les monstres (`data/monsters/*.json`, issu du bestiaire, sans fiche Foundry ni inventaire encodé) portent `carried_items` vide. > **`data/pcs/*.json` est la référence canonique des PJ.** Le chargeur > `loaders/foundry.py` est un outil de migration **one-shot et incomplet** : > il ne reproduit pas les corrections manuelles/SRD appliquées aux JSON > (drapeaux `natural`/`secondary`/`firearm`, allonge des sabots de Jeanne > (10 ft), vol d'Ierlieth (40 ft), morsure de Misty (1d6), dons au-delà de sa > table de 8 entrées, `spell_slots`/`resources`/`casting_ability`). Le > relancer et régénérer les JSON de PJ ferait régresser ces corrections : ne > pas régénérer depuis `foundry.py` sans réappliquer au préalable les > corrections de conformance consignées dans > [`docs/conformance-srd.md`](docs/conformance-srd.md). ## Règles implémentées et limites de la Phase 0 > Le suivi détaillé des écarts avec le SRD — corrigés, hors périmètre et > résiduels assumés — est consigné dans > [`docs/conformance-srd.md`](docs/conformance-srd.md). Règles modélisées : - 1 naturel = échec automatique ; 20 naturel = touche + menace de critique. - Critique : (dés physiques + bonus plat) × multiplicateur ; les dés énergétiques des spécificités d'arme (flaming, etc.) et les dés de précision ne sont PAS multipliés (exception CRB « Critical Hits ») ; 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. Une créature mourante qui subit des dégâts continus (saignement) perd ses dégâts de saignement au début de son tour, puis **échoue automatiquement** son jet de stabilisation (aucun d20 jeté) et perd donc 1 pv de plus par tour en plus des dégâts continus (CRB « Stable Characters and Recovery ») — elle meurt ainsi plus vite qu'une créature mourante non saignante. Les morts-vivants et les constructeurs n'ont pas de score de Constitution et sont **détruits dès `hp ≤ 0`** — ni bande d'invalidité à 0 pv, ni bande d'agonie (SRD traits « Undead » / « Construct » : « undead do not risk death from massive damage, but are immediately destroyed when reduced to 0 hit points or fewer ») ; le seuil `hp ≤ -CON` ne s'applique qu'aux créatures vivantes. - Mort simplifiée des monstres (option `--simple-monster-death`, défaut désactivé) : quand l'option est présente dans la commande, un **monstre** meurt dès `hp ≤ 0` — ni bande d'invalidité à 0 pv, ni bande d'agonie entre 0 et `-CON`. Les **personnages joueurs ne sont jamais affectés**, quel que soit le réglage : ils gardent la règle PF1e complète (`hp ≤ -CON` pour mourir, invalidité à 0 pv, agonie et stabilisation en dessous). La distinction monstre/PJ repose sur une seule marque fiable issue du chargeur : le champ `source` des JSON de PJ pointe vers la fiche Foundry d'origine sous `fiches_personnages/` (convention documentée plus bas), alors que les JSON de monstres portent des sources « Bestiary », « summon monster » ou « bestiary » ; le nom de camp (`--side`) ne peut pas servir de critère puisqu'il est choisi par l'utilisateur et que la carte d'exemple oppose des monstres déployés côté `players`. Sans l'option, le comportement du moteur est bit-identique à celui d'aujourd'hui (transcripts dorés intacts). Équivalent Python : `run_encounter(..., monsters_die_at_zero=True)` et `CombatEngine(..., monsters_die_at_zero=True)`. - 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, mais les actions swift, libre et immédiate restent permises — CRB « Staggered »), 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). Les actions de mouvement — dont `retrieve_weapon` (sortir/récupérer une arme) et `repair_weapon` (raccommoder une arme brisée), classées mouvement — ne risquent pas de blessure supplémentaire et coûtent 0 pv (CRB « Disabled »). 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. - Dégâts non-létaux (SRD « Nonlethal Damage ») : piste séparée `nonlethal` du runtime (`CombatantState.nonlethal`), **jamais** déduite des pv (« Do not deduct the nonlethal damage number from your current hit points. It is not "real" damage »). Les seuils sont **dérivés** de (nonlethal, pv courants), sans objet de condition : `nonlethal == hp` (et > 0) = **staggered** (une action de mouvement OU standard, via le filtre `_is_staggered_limited` déjà en place) ; `nonlethal > hp` = **inconsciente et inerme** (inactif via `CombatantState.active` — ne peut rien faire, ne menace aucune case, compte comme tombé pour la victoire de camp — et `helpless` via `_is_helpless` : Dex effective 0, +4 contre elle en mêlée). Un soin qui remonte les pv au-dessus du total non-létal (ou qui réduit ce total) restaure la conscience automatiquement, la règle étant une fonction pure des deux nombres. **Règle du plafond de pv maximaux** (SRD) : « If a creature's nonlethal damage is equal to his total maximum hit points (not his current hit points), all further nonlethal damage is treated as lethal damage » — au-delà de `hp_max` accumulé, les dégâts non-létaux suivants deviennent réels et peuvent tuer (l'exception des créatures à régénération n'est pas modélisée : aucune créature du moteur n'a la régénération). **Immunité** : morts-vivants et constructeurs ne subissent **pas** de dégâts non-létaux (traits SRD « Undead » / « Construct », KB `rules/advanced-race-guide/ undead-16-rp.md` et `construct-20-rp.md` : « not subject to ... nonlethal damage ») — les dégâts sont annulés. **Soins** : « When a spell or ability cures hit point damage, it also removes an equal amount of nonlethal damage » — un sort de soin ou `channel energy` qui rend N pv retire aussi N points de non-létal. **Sources de dégâts non-létaux** : (1) l'attaque sans arme — les coups sans arme infligent des dégâts non-létaux par défaut (SRD « Unarmed Attacks » : « An unarmed strike deals nonlethal damage ») ; le moteur reconnaît une attaque dont le nom d'arme est exactement « Unarmed Strike » (insensible à la casse, comme transcrit dans `data/pcs/*.json`) et route la totalité de son dégât (après RD et résistances) vers la piste non-létale ; heuristique documentée : le modèle de données n'a pas de champ « mode de dégâts », le nom est le marqueur. (2) La règle du **dégât minimal** (CRB « Damage — Minimum Damage ») : « If penalties reduce the damage result to less than 1, a hit still deals 1 point of nonlethal damage » — une touche réduite sous 1 par la RD ou une résistance énergétique inflige désormais 1 point **non-létal** (approximation « 1 point normal » de ae037c9 corrigée). Hors périmètre (documenté) : la conversion déclarée avant le jet — arme létale frappant en non-létal à −4, attaque sans arme frappant en léthal à −4 (choix du moine sans pénalité non modélisé faute de marqueur de classe) ; la récupération hors combat (1 pv de non-létal par heure et par niveau) ; les dégâts non-létaux ne sont **jamais** comptés dans l'attrition (`damage_dealt`/`damage_taken` restent strictement létaux) ; le snapshot d'observation expose la clé additive `nonlethal` (int) par combattant — la politique dérive staggered/inconscient en la comparant à `hp`. - Initiative : triée sur (total, modificateur, ordre de liste), sans re-jet. - Round de surprise (SRD « Surprise ») : le champ booléen `aware` du JSON de combattant (défaut `true`) encode la conscience des adversaires au début du combat. Si certains combattants actifs sont `aware` et d'autres non, un round de surprise (round 0) précède le round 1 : « In initiative order (highest to lowest), combatants who started the battle aware of their opponents each take a standard or move action during the surprise round. You can also take free actions during the surprise round. » Le filtre `_surprise_filter` applique cette allowance : une seule action standard **ou** de mouvement (la première de la séquence gagne), les actions libres conservées, les actions swift et à round complet retirées (`downgraded:surprise`) ; un `full_attack` se dégrade en attaque standard unique (même convention que le filtre accablé) et `withdraw` survit comme retraite restreinte standard (SRD « Full-Round Actions »). « Combatants who are unaware at the start of battle don't get to act in the surprise round » — les non-`aware` sautent leur tour (journalisé). « If no one or everyone is surprised, no surprise round occurs » — avec le défaut `aware: true`, le comportement est bit-identique à l'absence de la règle. Simplification documentée : les combattants non avertis ne sont pas marqués `flat_footed` pendant le round de surprise (le moteur n'injecte jamais cette condition lui-même — voir « Actions immédiates »). - É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 : prochain swift ; pendant son tour : swift du tour courant — conformance L10) sont modélisées via une politique de réaction opt-in (`reaction_policy`) — voir « Actions immédiates » ci-dessous. **Application du budget (conformance X4)** : le moteur applique désormais le budget d'action PF1e (CRB « Actions in Combat ») à TOUTE séquence renvoyée par une politique, y compris une future politique LLM, et non plus seulement aux cas immédiat/accablé/nausée. Chaque kind d'action est classé dans un seul compartiment du budget — standard, mouvement, swift, round complet, ou libre/conditionnel (hors budget) — et la séquence est vérifiée contre le budget (≤ 1 standard **ou** 1 round complet, ≤ 1 mouvement, ≤ 1 swift ; un round complet consacre à la fois le standard ET le mouvement). **Conversion standard → mouvement** (CRB : une action de mouvement peut se prendre « à la place » de l'action standard) : quand le compartiment mouvement est déjà occupé et que le standard est encore libre, un second mouvement consomme le standard — `(move, stand_up)` et `(move, move)` sont donc légaux. La dégradation est **déterministe et stable en ordre** : parcours glouton dans l'ordre, chaque action est gardée tant que son compartiment est libre ; le résultat est donc le plus préfixe-sous-séquence légal, et toute action excédentaire est retirée et rapportée comme `ActionResult(kind=…, ok=False, reason="downgraded:action_economy")` (visible dans `last_action_results` et le snapshot `last_turn_results`). Exemples : `(attack, attack)` → `(attack,)` ; `(charge, attack)` → `(charge,)` ; `(attack, full_attack, move)` → `(attack, move)` ; `(move, attack, move)` → `(move, attack)` ; `(full_attack, deed, inspire_courage)` → `(full_attack, deed)`. Classification (exhaustive sur le `Literal` des kinds, verrouillée par `tests/test_conformance_action_economy.py`) : **standard** = `attack`, `maneuver`, `channel_energy`, `hex`, `delay`, `ready`, `total_defense`, `aid_another` ; `cast_spell` occupe le compartiment que lui donne son **temps d'incantation** (champ `cast_time` de `SpellSpec`, voir « Temps d'incantation » plus bas ; standard par défaut) ; exception par action (conformance D5 — APG « Cackle ») : `hex` avec `hex_name="cackle"` occupe le compartiment **libre** (résolu par `_effective_slot`, le kind `hex` restant standard pour evil_eye/slumber) ; **mouvement** = `move`, `stand_up`, `retrieve_weapon` (sortir une arme est une action de mouvement), `repair_weapon` ; exception conformance L7 (SRD « Move Actions ») : « If you have a base attack bonus of +1 or higher, you may draw a weapon as a free action combined with a regular move » — quand la séquence du tour contient une action `move` ET que le combattant a un BAB ≥ +1, `retrieve_weapon` occupe le compartiment **libre** (drapeau `free_draw` résolu par `_effective_slot`) et n'entame pas le budget mouvement ; `repair_weapon` reste mouvement (la règle RAW ne couvre que le dégainement) ; exception S5a (Ultimate Combat, deed « Quick Clear ») : `deed` avec `deed_name="quick_clear"` occupe le compartiment **standard** (résolu par `_effective_slot`, le kind `deed` restant swift pour up_close_and_deadly) ; **swift** = `swift`, `deed`, `inspire_courage` ; **round complet** = `full_attack`, `charge`, `withdraw`, `full_round`, `spell_combat` (sort + attaque à outrance dans le même round), `run` (round complet en RAW — il consacre le standard ET le mouvement, tout en posant `moved_this_turn` et `running`) ; **libre/conditionnel (hors budget)** = `wait`, `free`, `immediate` (hors-tour ; il consomme le PROCHAIN swift via `immediate_used` ; pendant son propre tour, il consomme le swift du tour courant via `swift_used_this_turn` — conformance L10), `5foot_step` (pas libre, exclusif avec tout autre mouvement via `moved_this_turn`), `drop_prone`, `activate_power_attack` et `fight_defensively` (des déclarations de mode appliquées à l'attaque qui les suit, comme le basculement Power Attack). Ordre de composition : le pré-filtre des temps d'incantation « longs » est appliqué EN PREMIER (voir « Temps d'incantation » ci-dessous), puis le budget général, puis les filtres spécialisés (immédiat/accablé/nausée) s'exercent sur le reste déjà légal (leur sortie est une action unique, donc jamais re-filtrée par le budget). L'ordre de dégradation est celui de la politique : la première action légale d'un compartiment gagne, les suivantes sont excédentaires. - Temps d'incantation (`SpellSpec.cast_time`, conformance économie d'action) : chaque sort porte un temps d'incantation normalisé en codes de budget — `standard`, `swift`, `immediate`, `free`, `full_round`, `one_round`, `long` (défaut `standard`). Le chargeur JSON accepte aussi les formulations RAW : `"1 round"`/`"one round"` → `one_round`, `"1 full-round action"`/ `"full-round action"`/`"full-round"` → `full_round`, `"1 minute"`/ `"10 minutes"`/`"1 hour"`/`"long"` → `long`, `"standard action"`, `"swift action"`, `"immediate action"`, `"free action"` ; une valeur inconnue est une erreur de données (`ValueError`). Mapping vers le budget : `standard` → standard ; `swift` → swift (il concurrence donc deed/inspire courage et est aussi retiré quand le swift est consommé par une immédiate, via `immediate_used`) ; `immediate` et `free` → libre (l'immédiate reste hors-tour et consomme le prochain swift) ; `full_round` → round complet ; `one_round` → **round complet** — SRD « Casting Time » : « A spell that takes 1 round to cast is a full-round action » ; l'emplacement est dépensé et les gardes de début d'incantation (AoO, concentration) résolus sur le tour courant, mais **l'effet est différé au début du tour suivant** du lanceur (voir « Incantation d'1 round » ci-dessous) ; `long` (> 1 round : 3 rounds, 10 minutes, 1 heure — cast multi-round non planifiable dans un tour) → **jeté avant le filtre de budget**, rapporté comme `ActionResult(kind="cast_spell", ok=False, reason="downgraded:cast_time")` (seule `default_policy` s'en préserve : `_choose_spell_action` ne sélectionne jamais un sort à temps long). Le moteur résout le code via sa registry (`_cast_time_of`, repli `standard` pour un nom inconnu) et le passe aux filtres purs sous forme de résolveur `CastTimeResolver`. Les 108 sorts importés de `data/spells/` déclarent tous leur `cast_time` (codes normalisés, backfillés depuis le SRD) : 96 `standard`, 6 `one_round` (Enhance Water, Silence, Sleep, Summon Instrument, Summon Monster I/II), 2 `immediate` (Grasp, Saving Finale) et 4 `long` (Make Whole, Masterwork Transformation, Mending, Restoration Lesser). Hors du périmètre (hook futur, commenté dans `_cast_time_of`) : la métamagie d'incanteur spontané qui allonge le temps d'incantation à un round complet sans don Quickened — la signature `Callable[[Action], CastTime]` du résolveur l'accommodera sans changement. - Incantation d'1 round (`cast_time: one_round`) : SRD « Casting Time » — « It comes into effect just before the beginning of your turn in the round after you began casting the spell. You then act normally after the spell is completed. » Sur le tour d'incantation, l'emplacement est dépensé, les gardes de début d'incantation jouent (AoO d'incantation + jet de concentration sur dégât, incantation défensive, agrippé), puis le sort est consigné comme incantation en cours (`PendingCast` sur l'état du lanceur : nom du sort, cible, point de centre, métamagie, niveau d'emplacement, pv au moment de l'incantation) et **rien n'est appliqué** — les décisions d'incantation (attaque de contact, RM, sauvegardes, effets) se prennent à la résolution (« You make all pertinent decisions about a spell [...] when the spell comes into effect »). Au début du tour suivant du lanceur, avant le portail de début de tour et avant l'appel de politique, l'incantation en cours se résout (journalisé `finishes casting X`), puis le lanceur agit normalement. Interruption : toute perte nette de pv entre l'incantation et la résolution impose le jet de concentration « blessé pendant l'incantation » (DD `10 + dégâts + niveau du sort`, **sans** le bonus Combat Casting — même helper `_check_concentration` que l'AoO d'incantation) ; un échec perd le sort (`spell fizzles`, emplacement brûlé). Un lanceur mis hors de combat avant sa résolution perd le sort (journalisé `lost (cannot act)`). Simplifications documentées : l'interruption est mesurée en **perte nette de pv à la résolution** et non par un crochet à chaque site de dégâts — un soin intermédiaire peut masquer des dégâts subis ; l'incantation en cours est exposée dans l'observation via la clé `pending_cast` (`{spell_name, target_id, slot_level}` ou `None`). L'incantation `long` (> 1 round) reste hors périmètre (jetée du budget, voir « Temps d'incantation »). - 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. Le tour réinséré plus tard dans le même round **ne rejoue pas le début de tour** : la décrément des durées (buffs/conditions), les dégâts continus (saignement), l'effet de terreur et les réinitialisations de drapeaux par tour ne s'appliquent qu'une fois par round (le tour retardé garde néanmoins son appel de politique, son budget et son exécution normaux). `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. **S4b — l'action préparée change l'initiative** (SRD « Ready — Initiative Consequences of Readying ») : « Your initiative result becomes the count on which you took the readied action, and you act immediately ahead of the character whose action triggered your readied action » — quand la préparation se déclenche, `_check_readied` appelle `_ready_shift_initiative` qui réécrit la clé d'initiative du préparant dans `_initiative_keys` (même forme de tuple que `_execute_delay` : total du déclencheur ± le pas de 0,5, en conservant le modificateur d'initiative et l'index de liste du préparant), si bien que `_build_round_order` conserve la nouvelle position pour **tous les rounds suivants**. Le pas est **ajouté** (le préparant agit juste AVANT le déclencheur), à l'inverse du retard qui le **retranche** (agir après) — les deux intentions sont opposées. Déterministe : aucun dé consommé, l'ordre du round courant (déjà construit) n'est pas modifié, seuls les rounds suivants se réordonnent. Conformance L4 (SRD « Ready — Initiative Consequences of Readying ») : « If you come to your next action and have not yet performed your readied action, you don't get to take the readied action (though you can ready the same action again) » — la préparation non déclenchée **expire au début du tour suivant** du préparant (bloc de début de tour de `_take_turn`, journalisé `ready expired (trigger never occurred)`), avant l'appel de politique ; elle survit donc à la fin du round courant et peut se déclencher au round suivant tant que le tour du préparant n'est pas arrivé. 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 le budget général d'action (X4 — action excédentaire retirée de la séquence de la politique), par le pré-filtre des temps d'incantation longs (sort `long` jamais planifiable dans un tour) ou par les filtres staggered/nauseated (ou swift consommé par une immédiate) → `downgraded:action_economy` / `downgraded:cast_time` / `downgraded:staggered` / `downgraded:nauseated` / `downgraded:immediate_swift` / `downgraded:surprise` ; 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) ; incantation sans emplacement disponible au niveau requis — niveau de base ou majoré par la métamagie (B7) → `ok=False, reason="no_slot"` ; 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 pour les armes manufacturées. Les armes naturelles (`natural: true` dans le JSON — morsures, griffes, sabots, talons…) ne gagnent **jamais** d'itératifs : une attaque à outrance avec une arme naturelle résout **un seul** balayage quel que soit le BAB (CRB « Natural Attacks » : « You do not receive additional natural attacks for a high base attack bonus »). Une attaque naturelle marquée `secondary: true` (les attaques secondaires du bestiaire) frappe à **BAB −5** ; le don Multiattack réduit cette pénalité à **−2** (CRB « Natural Attacks » / « Multiattack »). 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 (conformance R1, CRB « Big and Little Creatures in Combat ») : l'**allonge naturelle** du gabarit (Tiny/Diminutive/Fine 0 ft, Small/Medium 5 ft, Large 10 ft, Huge 15 ft, Gargantuan 20 ft, Colossal 30 ft) est un **disque** : la créature frappe et menace toutes les cases jusqu'à son allonge, l'adjacence comprise — une créature Large dont l'attaque naturelle a `reach_ft` 10 menace donc aussi les cases adjacentes (le `reach_ft` d'une attaque naturelle EST son allonge naturelle). Une **arme à allonge** (`reach_weapon: true` dans le JSON) est un **anneau** : elle double l'allonge naturelle du porteur mais ne frappe pas à l'allonge naturelle ou moins — la zone utilisable est `allonge naturelle < distance ≤ 2 × allonge naturelle`, la dead zone vaut donc 5 ft pour un porteur Small/Medium et 10 ft pour un porteur Large. Une arme ordinaire sans allonge menace les cases adjacentes (disque de 5 ft). **S4c — allonge nulle des créatures Tiny et plus petites** (CRB « Big and Little Creatures in Combat » : « Tiny, Diminutive, and Fine Creatures [...] They must enter an opponent's square to attack in melee ») : l'allonge naturelle de Tiny/Diminutive/Fine vaut **0 ft** — une telle créature ne **menace aucune case** (aucune AoO) et ne peut pas frapper une case adjacente ; son attaque de mêlée n'est utilisable qu'à **distance 0**, la case qu'elle partage avec sa cible (l'abstraction « entrer dans la case de l'adversaire »). L'allonge effective reste `max(allonge naturelle, reach_ft de l'arme)` : une attaque naturelle dont le JSON déclare explicitement `reach_ft` (ierlieth : morsure `reach_ft: 5`) conserve cette allonge — le `reach_ft` d'une attaque naturelle EST son allonge naturelle, une créature Tiny peut donc être encodée avec une allonge non nulle si sa fiche l'exige. 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. Conformance L5 (SRD « Movement, Position and Distance ») : « You can move through a square occupied by a friendly character, unless you are charging » — `move` et `run` **traversent** les cases des alliés, mais ne peuvent jamais **finir** sur une case occupée : entrer sur une case alliée exige un prolongement libre (règle du « pass-through »), et la queue du chemin est tronquée si le budget s'épuise sur une case alliée. SRD « Moving Through a Square » : « Any creature can move through a square occupied by a creature three size categories larger than itself [...] Creatures moving through squares occupied by other creatures provoke attacks of opportunity from those creatures » — un ennemi séparé du déplaceur par **trois catégories de taille ou plus** est traversé de la même façon (jamais fini dessus : « You can't end your movement in the same square as another creature unless it is helpless »), et l'entrée dans sa case **provoque une AoO de l'occupant** (une seule par entrée fraîche, plafonnée par l'allowance du round ; résolu par `_check_enter_aoo` dans la marche pas à pas). Un ennemi à 0–2 catégories reste un blocage total (« You can't move through a square occupied by an opponent unless the opponent is helpless »). La charge reste bloquée par les créatures (ligne droite dégagée), comme la retraite (limite documentée : le passage à travers les alliés n'est pas modélisé pour `withdraw` et le pas de placement auto-dirigé). 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. **Vol au-dessus du terrain (3D)** : une créature dont le budget vient de sa vitesse de vol (`speed_fly_ft >= `speed_land_ft`, `_is_airborne`) est **aérienne** et se déplace sur une « vue aérienne » de la grille (`Grid.airborne_view`) : le terrain difficile au sol (coût > 1) ne la ralentit pas (coût 1) et elle **enjambe** les cases infranchissables au sol (fosses, eau, gravats, piliers — `move_cost: null` sans `blocks_los`), conformément au SRD « Movement, Position, and Distance » (Terrain and Obstacles) : « Flying and incorporeal creatures are not hampered by difficult terrain » et « Flying and incorporeal creatures are able to avoid most obstacles ». **Exception** : une case qui **bloque la ligne d'effet** (`blocks_los` — mur/pilier, barrière solide supposée de hauteur pleine) **arrête aussi le vol** (elle reste infranchissable dans la vue aérienne). La règle des coins (diagonale) suit la même vue : un aérien peut passer en diagonale au ras d'une fosse (SRD « Measuring Distance » : « You can also move diagonally past other impassable obstacles, such as pits ») mais pas au ras d'un coin de mur. Les portiers de course/charge/pas de placement (« You can't run or charge across difficult terrain », « movement isn't hampered by difficult terrain ») ne bloquent pas un aérien, puisque son déplacement n'est pas gêné par le terrain. Le coût de **montée** (élévation) s'applique aussi au vol : la couche `elevation` encode des **marches** (hauteurs de sol), non des hauteurs de mur ou de falaise, et n'est donc pas survolable (simplification documentée). La **ligne d'effet**, le **couvert** et l'**avantage de hauteur** restent calculés sur la grille réelle (inchangée par le vol) : la géométrie de combat n'est pas affectée par le statut aérien. Les créatures non aériennes (`speed_fly_ft` absent, ou vol < sol) sont bit-par identiques (elles paient le terrain et ne traversent pas les obstacles) ; les transcripts dorés (arène ouverte, sol uniforme) sont inchangés. Le snapshot expose le budget sous la clé additive `speed_effective_ft`. **Hors périmètre (vol 3D incomplet)** : la **ligne d'effet 3D** (un vol à l'altitude X passe au-dessus d'un mur de hauteur Y, franchit le rebord d'une falaise) n'est **pas** modélisable — la carte ne porte aucune hauteur de mur ni de falaise (seule la hauteur de sol `elevation` existe, et `blocks_los` est un booléen « barrière solide », pas une hauteur) ; la **maniabilité de vol** (turn/pitch, chute en fin de tour, `hover`) et les autres vitesses de déplacement (natation, escalade, creusement) ne sont pas modélisées. 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. **S4c — co-occupation des créatures Tiny et plus petites** (CRB « Big and Little Creatures in Combat » : « Very small creatures take up less than 1 square of space. This means that more than one such creature can fit into a single square ») : une créature Tiny/Diminutive/Fine peut **traverser et finir son déplacement** dans une case occupée par une créature plus grande, et une créature plus grande peut traverser et finir dans une case occupée par une créature Tiny ou plus petite — la case n'est pas « bloquée » pour ce couple de tailles (`_can_cooccupy` : tailles différentes ET la plus petite est Tiny ou moins). Deux créatures de même taille (ou Small et plus grande) ne partagent jamais une case : le blocage reste total pour elles. La co-occupation s'applique au mouvement (`_blocked`/`_blocked_hostile` sont conscients du déplaceur), à la destination explicite (`_cells_free`) et à la charge ; deux créatures partageant une case sont à **distance 0** l'une de l'autre (ligne d'effet directe, attaque de mêlée possible — c'est la voie d'attaque des créatures à allonge nulle, voir « Mêlée »). - 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). Conformance vérifiée (SRD Table 8-5 « Attack Roll Modifiers ») : « On higher ground — Melee +1, Ranged +0 » — le bonus est à juste titre réservé aux attaques de mêlée, les attaques à distance ne le reçoivent pas. La ligne d'effet reste 2D (pas de LoS 3D) : la couche `elevation` encode des hauteurs de **sol** (marches), non de murs ou falaises — survol aérien du terrain au sol modélisé (voir « Vitesse de déplacement »), mais la ligne d'effet 3D selon l'altitude reste hors périmètre faute de données de hauteur de mur. - 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 (`immediate_used`). Conformance L10 (SRD « Immediate Actions ») : « Using an immediate action on your turn is the same as using a swift action and counts as your swift action for that turn » — une immédiate déclenchée **pendant le tour** du combattant consomme le swift du **tour courant** (`swift_used_this_turn`, posé par le moteur sur l'acteur du tour) : tout swift encore présent dans la séquence est retiré (`downgraded:immediate_swift`) et aucune seconde immédiate ne part ce round ; le swift du tour **suivant** reste disponible (contrairement à l'immédiate hors-tour). Simplification documentée : l'interdiction RAW d'une seconde immédiate s'étend jusqu'au début du tour suivant, le moteur la borne au round en cours. Conformance L5 (SRD « Immediate Actions ») : « You also cannot use an immediate action if you are flat-footed » — un combattant portant la condition `flat_footed` est **sauté** par la porte de réaction (`_check_reactions`) : aucune immédiate ne part tant qu'il est à plat (la condition est injectable via `active_conditions` ; le moteur ne la pose jamais lui-même — voie d'entrée future). 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). Conformance B20 (CRB « Cover », règle des coins) : c'est **l'attaquant qui choisit son meilleur coin** — conformance L9 (`los.py`, `cover_bonus_cells`) : à distance, le score de chaque case attaquante est le **minimum sur ses quatre coins** de (maximum sur les lignes vers les coins de la cible) — « choose a corner of your square » — puis le minimum sur les cases de l'attaquant ; un créneau n'est donc jamais compté contre l'attaquant si l'un de ses coins l'évite (le bonus reste cohérent avec la porte `has_cover_cells`, qui teste déjà « tout coin de l'attaquant »). Une créature multi-cases (Large +) ne subit que le couvert de SA meilleure case occupée (minimum sur ses cases), pas le pire. Côté cible : à distance, toute ligne vers tout coin des cases de la cible compte (maximum sur les coins de la cible) ; **en mêlée**, l'attaquant peut choisir n'importe quelle case occupée par une créature multi-cases pour déterminer le couvert (conformance A5 — CRB « Big Creatures and Cover » : « when making a melee attack against such a creature, you can pick any of the squares it occupies to determine if it has cover against you ») — le score est donc le minimum sur les cases de la cible en mêlée, le maximum à distance. **Mêlée adjacente** (conformance — SRD « Cover » : « When making a melee attack against an adjacent target, your target has cover if any line from any corner of your square to the target's square goes through a wall ») : la règle est celle de **toute ligne** — une seule ligne coin-à-coin bloquée suffit à donner le couvert, l'attaquant ne choisit plus son meilleur coin (score : maximum sur les seize lignes de la paire de cases, minimum sur les paires de cases adjacentes). **Mêlée non adjacente** (arme à allonge, distance > 1) : le SRD renvoie à la règle à distance (« use the rules for determining cover from ranged attacks ») — l'attaquant garde son **meilleur coin** (minimum sur ses coins du maximum des lignes), avec le choix de la case de cible multi-cases. - 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 de sa zone d'allonge (voir « Mêlée » : le disque de l'allonge naturelle — les 8 cases adjacentes pour un Small/Medium sans allonge — ou l'anneau d'une arme à allonge, dont la dead zone 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 ; sortir de plusieurs cases menacées par le **même** adversaire dans le même round ne compte qu'**une seule opportunity** pour lui (piste `_move_aoo_provoked`, réinitialisée au début du round — conformance A1, CRB « Attacks of Opportunity ») ; le relèvement (stand_up) est un déclencheur distinct et provoque toujours ; (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) — **sauf** si la cible a un couvert ou une dissimulation totale par rapport à l'attaquant : l'AoO est alors supprimée et non consommée (conformance A4, CRB « Cover » : « You can't execute an attack of opportunity against an opponent with cover relative to you » ; CRB « Concealment » : dissimulation totale). 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) et ne peut pas couper un coin de mur (règle des coins, SRD « Measuring Distance » : « You can't move diagonally past a corner ») ; 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). Conformance L6 (SRD « Full-Round Actions — Restricted Withdraw ») : « If you are limited to taking only a standard action each round you can withdraw as a standard action. In this case, you may move up to your speed » — un combattant `staggered`/invalidé qui reçoit `withdraw` le conserve comme **action standard** (le filtre staggered ne le jette plus) avec un budget de **1× vitesse** au lieu de 2× (`_flee_path` via `_is_staggered_limited`). `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). **S4a — la course est une ligne droite** (SRD « Full-Round Actions — Run » : « When you run, you can move up to four times your speed **in a straight line** ») : contrairement à `move` (descente Dijkstra qui contourne les obstacles), la course suit la **ligne de Bresenham** entre la position courante et la cible/destination, et **s'arrête au premier obstacle** — terrain infranchissable, terrain difficile, ou créature bloquante — en miroir de la logique de ligne dégagée de la charge (`_run_line_path`, `_charge_path_clear`). Le déplacement reste pas à pas avec **provocation d'AoO** (courir ne protège pas des AoO), et conserve le budget 4×, le drapeau `moved_this_turn` et l'exclusivité avec les autres mouvements. Comme pour `move`, la course **traverse les cases des alliés** sans jamais y **finir** (SRD « Movement », conformance L5 — la queue du chemin est tronquée) ; une créature hostile (la cible incluse) arrête la course une case avant, donc la course auto-dirigée **s'arrête adjacent à la cible**. La course **ne traverse pas le terrain difficile** (conformance R2 — CRB « Difficult Terrain » : « You can't run or charge across difficult terrain ») : elle avance jusqu'à l'obstacle et s'y arrête **quand elle est auto-dirigée**. À 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), **sauf s'il a le don Run** (SRD « Run » : « You lose any Dexterity bonus to AC unless you have the Run feat » — `_running_denies_dex`). **Endurance (SRD « Full-Round Actions — Run »)** : « You can run for a number of rounds equal to your Constitution score, but after that you must make a DC 10 Constitution check to continue running. You must check again each round in which you continue to run, and the DC of this check increases by 1 for each check you have made. When you fail this check, you must stop running. A character who has run to his limit must rest for 1 minute (10 rounds) before running again. » Le moteur compte les rounds de course **consécutifs** par combattant (`run_rounds`, la série se brise quand un tour passe sans courir) ; au-delà du score de Constitution, chaque nouvelle round exige un jet de Constitution DD `10 + jets déjà faits` (`run_checks`), **sans** réussite/échec automatique sur 20/1 naturel (un test de caractéristique n'est pas un jet d'attaque) ; le don **Run** donne **+4** à ce jet. Un échec stoppe la course (tour perdu, aucun mouvement, journalisé `run stopped`) et pose `run_rest_rounds = 10` : pendant le repos, toute action `run` est refusée (journalisé `wait (cannot run, resting …)`) — le round de l'échec compte comme le premier des dix. Les morts-vivants et les constructeurs n'ont pas de score de Constitution et sont exemptés. Simplification assumée : la règle du repos borne la course consécutive par un compteur de tours au sens du moteur (le « repos » n'exige pas l'inactivité totale, seule la course est interdite). **Hors périmètre (documenté, non approximé)** : la clause d'armure lourde « or three times your speed if you're in heavy armor » — le modèle n'a aucun champ armure (la CA des JSON est une valeur finale). Une `destination` que la ligne droite ne peut pas honorer dans le budget 4× (hors portée, bloquée par un obstacle/une créature, ou joignable uniquement en tournant un coin ou en traversant le terrain difficile) est **rejetée** (`unreachable_destination`) — le verdict et le chemin parcouru sont cohérents (pas de contournement silencieux) ; 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. Conformance L3 (SRD « Measuring Distance ») : « You can't move diagonally past a corner (even by taking a 5-foot step) » — un pas diagonal dont les deux cases adjacentes (flancs) ne sont pas toutes deux praticables est rejeté (`unreachable_destination`), comme pour tout autre déplacement. Simplification documentée (plus strict que le RAW) : le texte « You can also move diagonally past other impassable obstacles, such as pits » autorise à passer en diagonale au ras d'un obstacle isolé (un seul flanc bloqué) — la règle du « coin » RAW ne bloque que les deux flancs à la fois ; le moteur ne distingue pas les obstacles en coin (murs) des obstacles isolés (fosses) et exige les deux flancs praticables, approximation conservatrice assumée (relâcher la règle modifierait les chemins Dijkstra, donc les positions et transcripts dorés). `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 (conformance A6 — CRB : « a –1 penalty on all melee attack rolls… The bonus damage does not apply to touch attacks ») s'applique à **la touche** du contact de mêlée (pénalité −X au jet d'attaque et de confirmation) mais son bonus de dégâts reste réservé aux armes de mêlée (le contact ne gagne pas le +2X/+3X). - Armes de jet à éclaboussures (SRD « Throw Splash Weapon », KB `rules/prpg-core-rulebook/throw-splash-weapon.md` ; SRD « Alchemist's fire », « Acid », « Holy water ») : une attaque dont l'`AttackSpec` porte un marqueur `splash` (`{"damage": 1, "type": "fire"}` dans le JSON — Alchemist's Fire sur `oni.json` et `tammara.json` : 1 point d'éclaboussure de feu) se lance en attaque de contact à distance (incréments de portée ; la maîtrise est automatique en RAW — le moteur ne modélise aucune pénalité de maîtrise). **Touche directe** : la cible subit les dégâts indiqués (1d6 feu) et **toute créature à moins de 5 pieds de la case d'impact** (case adjacente ou occupée — créatures mourantes/inconscientes incluses, cadavres exclus) subit l'éclaboussure plate, sans jet d'attaque ni sauvegarde ; la cible elle-même ne subit **pas** d'éclaboussure supplémentaire (SRD : « A hit deals direct hit damage to the target and splash damage to all creatures within 5 feet of the target »). **Échec** (jet raté ou dissimulation) : la fiole **disperse** — d8 de direction (1 = trop court, en ligne vers le lanceur ; 2 à 8 en rotation horaire autour de la cible), puis un nombre de cases égal à l'**incrément de portée du jet** (Alchemist's Fire : 10 pieds = 2 cases) ; la case d'atterrissage **et ses cases adjacentes** subissent **seulement** l'éclaboussure — la cible d'origine comprise si elle est dans la zone. L'éclaboussure est un dégât **énergétique** : résistance/immunité énergétiques s'appliquent, la **RD non** ; le montant plat n'est **jamais** multiplié au critique, et une arme à éclaboussures ne peut pas porter de dégâts de précision — SRD : « Splash weapons cannot deal precision-based damage (such as sneak attack) ». Le d8 de dispersion n'est jeté **que** pour une arme à éclaboussures : le flux RNG de toute autre attaque est bit-identique. `AttackResult.splash` (additif) rapporte le total d'éclaboussure infligé, distinct de `damage` (cible uniquement) ; le snapshot d'observation expose `splash` (`{damage, type}` ou `None`) par arme. Simplifications et hors périmètre documentés : le ciblage d'une intersection de grille (CA 5) n'est pas modélisé ; l'éclaboussure asperge les cases adjacentes sans vérification de ligne d'effet ; le point d'impact d'une cible multi-cases est sa case d'ancrage (le choix RAW d'une case n'est pas offert) ; les dégâts persistants d'Alchemist's Fire (1d6 feu au round suivant, extinction par Réflexes DD 15) ne sont **pas** modélisés. - 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). - Compétences : `skill_check(state, skill, *, extra_mods=())` — jet de compétence générique `d20 + rangs + mod de caractéristique + 3 si compétence de classe avec rangs investis + pénalité d'armure` (SRD « Skill Checks », Table 4-2 « Trained Class Skill » : le bonus de classe +3 n'est accordé que si la compétence est de classe ET a au moins 1 rang). **Aucune réussite ou échec automatique sur 1/20 naturel** (SRD « Skill Checks » : « Unlike attack rolls and saving throws, a natural roll of 20 on the d20 is not an automatic success, and a natural roll of 1 is not an automatic failure »). Renvoie un `SkillCheckResult` (roll, total). `resolve_skill` (bonus statique de compétence, utilisé par l'escape par compétence) délègue désormais au même calcul. Compétences de combat modélisées : **Acrobatics — Tumbling** et **Stealth** (voir ci-dessous) ; les autres compétences (Climb, Swim, Diplomacy, Disable Device…) restent hors périmètre (voir « Limites hors périmètre »). - Acrobatics — Tumbling (SRD « Acrobatics », KB `rules/pathfinder-unchained/acrobatics-dex-armor-check-penalty.md` ; « Moving Through a Square », KB `rules/prpg-core-rulebook/moving-through-a-square.md`) : `Action(kind="move", tumble=True)` (opt-in, comme `weapon_id`/ `destination` — `default_policy` ne tumbling jamais) fait tenter au déplaceur un jet d'Acrobatics **par adversaire menaçant** avant de quitter une case menacée et avant d'entrer dans la case d'un ennemi. DD = **CMD de l'adversaire** (SRD : « The DC of this check is your opponent's CMD ») ; entrer dans la case d'un ennemi : DD = **CMD + 5** (SRD « Move Through an Enemy's Space » : « A trained character can attempt to use Acrobatics to move through a square occupied by an opponent. The DC is 5 + the opponent's CMD » — la clause « +5 si l'opposant est plus grand que vous » du cahier des charges est couverte par cette entrée de case) ; **+2 par adversaire supplémentaire déjà testé** dans le même déplacement (SRD : « If you attempt to move through an enemy's space and fail, you lose the move action and provoke an attack of opportunity from that enemy » ; « +2 to the DC for each additional opponent you attempt to tumble past in the same round »). Le tumbling est **réservé aux entraînés** (SRD : « A character can attempt to use Acrobatics to move through a square occupied by an opponent only if she is trained in the skill » — sans rang, l'action est rejetée `untrained_tumble`). Le déplacement se fait à **moitié vitesse** (SRD : « You can move through a threatened square at half speed »). **Échec** : le mouvement s'arrête immédiatement et l'adversaire porte une AoO (SRD : « If you fail this check, you provoke an attack of opportunity from that enemy »). Le jet d'Acrobatics est un jet de compétence : pas de réussite/ échec automatique sur 20/1 naturel. - Stealth (SRD « Stealth », KB `rules/ultimate-intrigue/perception-and-stealth.md`, `rules/pathfinder-unchained/perception-wis.md`) : `Action(kind="hide")` (action de mouvement, opt-in) fait tenter au combattant un jet de Stealth **opposé à la Perception de chaque ennemi** (SRD : « Your Stealth check is opposed by the Perception check of anyone who might notice you »). Le jet de Stealth vaut `d20 + rangs + mod de Dextérité + 3 de classe + pénalité d'armure + modificateur de taille` (SRD Table 4-2 : Small +4, Medium 0, Large −4, Tiny +8, Diminutive +12, Fine +16) ; la Perception de l'observateur subit **−1 par 10 ft de distance** (SRD « Perception » : « +1 to the DC for every 10 feet of distance between you and the target » — le moteur applique le modificateur au jet de Perception de l'observateur). Un observateur qui **voit directement** le combattant (ligne d'effet, sans couvert ni dissimulation, non aveuglé) ne peut pas être battu : la dissimulation contre lui est impossible (SRD « Being Observed » : « If people are observing you, you can't use Stealth »). Un observateur qui **échoue à battre** le total de Stealth (opposition résolue par la règle générale des jets opposés — CRB « Skill Checks » : « the attempt is successful if your check result exceeds the result of the target ») ne perçoit plus le combattant : il est ajouté à `hidden_from` et le traite comme **invisible** (SRD : « Creatures that fail to beat your Stealth check are not aware of you and treat you as if you had total concealment ») — dissimulation totale 50 % contre ses attaques, **+2 aux attaques** du combattant caché contre lui et **perte de son bonus de Dextérité à la CA** (même chemin `denies_dex_to_ac` que l'invisible), et **aucune AoO** contre un déplacement caché (SRD « Concealment » : « You can't execute an attack of opportunity against an opponent with total concealment relative to you »). **Briser la dissimulation** (SRD « Breaking Stealth ») : attaquer (ou incanter un sort de contact) révèle le combattant à tous ses observateurs (`hidden_from` vidé) ; **courir ou charger** brise aussi la dissimulation (SRD : « If you run or charge, you lose Stealth ») ; **se déplacer** (move, retraite) ou **faire un pas de placement** tout en restant caché re-joue l'opposition avec **−5** (SRD : « You can move up to half your normal speed and use Stealth at no penalty. When moving at a speed greater than half but not more than your normal speed, you take a −5 penalty » — le moteur applique le −5 à tout déplacement, simplification documentée : la vitesse exacte du déplacement n'est pas distinguée) ; un observateur qui voit directement la nouvelle position retire le combattant de sa liste (journalisé `no longer hidden from X (observed)`). Le snapshot d'observation expose l'état via la clé `hidden_from` (tuple trié des ids d'observateurs qui ne perçoivent pas le combattant). Hors périmètre (documenté) : le **sniping** (−20 au jet de Stealth pour attaquer puis rester caché), la **création de diversion** (Bluff), le modificateur « distrait » (+5 au DD de Perception — le moteur n'a pas d'état « distrait » et aucune règle SRD ne rend les observateurs en combat automatiquement distraits), et la distinction vitesse moitié/vitesse normale du −5 (voir ci-dessus). - 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 »), 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 — et les conditions d'alignement de la source (`attacker_is_evil`, `attacker_is_lawful`, `attacker_is_good`, `attacker_is_chaotic`) évaluées contre l'attaquant / la créature qui crée l'effet, passé en contexte `attacker` par la résolution d'attaque (CA), les sauvegardes de sorts (chemin ciblé et zone) et l'aura de terreur ; sans source en contexte, la condition est Fausse. C'est ce qui porte les protections alignement-dépendantes du SRD (protection from evil/law : « +2 deflection bonus to AC and a +2 resistance bonus on saves against attacks or effects created by evil creatures » — jamais contre une source qui n'est pas du côté aligné en face). 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, ...}`) — convention de données : `spell_slots` est une valeur **finale** par rencontre et le moteur ne calcule **jamais** les sorts bonus de la caractéristique d'incantation élevée (SRD « Abilities and Spellcasters » + Table 1-3 « Ability Modifiers and Bonus Spells » : sagesse 18 (+4) → +1 sort par niveau 1-4) — les éventuels sorts bonus doivent être **encodés dans le JSON par l'auteur de la fiche**. Preuve du choix (constat 2026-09) : les fiches PJ actuelles encodent la table de classe de BASE sans bonus (oni, clerc 4, SAG 18 → `{1:4, 2:3}` = table de base 4/3, alors que le RAW avec bonus serait 5/4 ; nairda, sorcière 4, INT 21 → `{1:4, 2:3}` au lieu de 6/4), tandis que le liche (INT 20, niveau d'incanteur 11) porte `{1:4, 2:4, 3:4, 4:4, 5:4, 6:3}`, qui ne correspond à AUCUNE table quotidienne RAW (sorcier 11 : 7/7/7/6/6/4 + bonus) — les slots sont donc un budget abstrait d'encounter authored, pas une progression RAW ; un calcul moteur de bonus s'appliquerait sur des données non-RAW et gonflerait arbitrairement le budget. Conséquence assumée : les PJ incanteurs tiennent moins de sorts par rencontre que le RAW ne le permettrait. `_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`, feedback `ok=False, reason="no_slot"`, aucun jet consommé) ; les cantrips (niveau 0) s'incantent librement. Le DD vaut `10 + niveau du sort (niveau de base du sort — la métamagie ne le majore PAS, sauf Heighten Spell, voir ci-dessous) + 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`). Métamagie (conformance B7, CRB « Metamagic Feats ») : la métamagie se choisit **à chaque incantation** via `Action(kind="cast_spell", metamagic=("Empower Spell", ...))` — seuls les dons demandés ET connus du lanceur (`metamagic_feats`) s'appliquent (un don inconnu est ignoré, un don connu non demandé n'est pas appliqué) ; le sort occupe alors un emplacement de niveau de base + ajustement (Empower +2, Maximize +3, Extend +1) mais **son DD reste calculé au niveau de base du sort** (SRD `feats/empower-spell.md` : « Saving throws and opposed rolls are not affected ») — seule l'exception SRD Heighten Spell majore le DD au niveau effectif majoré (SRD `feats/heighten-spell.md` : « All effects dependent on spell level (such as saving throw DCs [...]) are calculated according to the heightened level »), modélisée à son ajustement minimal +1 (la signature d'action ne porte pas le niveau choisi — simplification documentée) ; sans emplacement au niveau requis, l'incantation est refusée (`no_slot`). Avec `metamagic` vide (défaut), le comportement est inchangé. Incanter dans une case menacée provoque des AoO. Incantation défensive (conformance B24, CRB « Concentration ») : `Action(kind="cast_spell", cast_defensively=True)` évite la provocation d'AoO mais impose un jet de concentration DD `15 + 2×niveau` (+4 avec Combat Casting) ; un échec perd le sort (emplacement brûlé, journalisé `casting defensively: concentration failed`). Agrippé (`grappled_with`) : un jet de concentration DD `10 + CMB de l'adversaire + niveau` (+4 Combat Casting) est requis pour incanter, défensivement ou non — ce jet remplace la provocation (un agrippé ne peut pas porter d'AoO). Tout dégât subi pendant l'incantation (AoO) impose un jet DD `10 + dégâts + niveau` **sans** le bonus Combat Casting (conformance P1). Jet de concentration = d20 + niveau d'incanteur + mod de la caractéristique d'incantation (Int/Wis/Cha — jamais Constitution, CRB). Sorts de contact (conformance B14, CRB « Magic — Range: Touch ») : délivrer un sort de portée `touch` à une créature **hostile** (camp différent) exige une attaque de contact de mêlée — d20 + BAB + mod de Force du lanceur contre la CA de contact de la cible (amputée du modificateur de Dextérité si la cible perd son bonus de Dextérité, même chemin que `resolve_attack`) ; en cas d'échec le sort **n'est pas dépensé** : le lanceur **garde la charge** (SRD « Holding the Charge » : « If the attack misses, you are still holding the charge [...] You can continue to make touch attacks round after round ») — l'état `held_charge` du lanceur (sort, cible, métamagie, niveau d'emplacement) est rejoué automatiquement au **début de chacun de ses tours suivants** tant que la cible vit et reste à portée de contact ; une nouvelle incantation **dissipe** la charge (« If you cast another spell, the touch spell dissipates ») et une cible morte la dissipe aussi (journalisé `dissipates`). L'emplacement reste brûlé (dépensé à l'incantation). Simplifications documentées : Weapon Finesse et les autres bonus situationnels de jet de contact ne sont pas modélisés ; la re-tentative est pilotée par le moteur au début du tour (coût RAW : action standard) ; la décharge sur tout contact (même involontaire) et les attaques à l'arme tenue pendant la charge ne sont pas modélisés ; la charge n'est pas exposée dans l'Observation. Une cible volontaire (soi ou un allié du même camp) ne demande aucun jet. Sorts à contact à distance (CRB « Magic — Ranged Touch Attacks ») : un sort dont le JSON déclare `requires_ranged_touch: true` (les rayons du SRD — Ray of Frost, Ray of Enfeeblement, Scorching Ray, Acid Arrow, Acid Splash, Disrupt Undead, Shard of Chaos, Spear of Purity) impose une attaque de contact à distance contre une cible **hostile** (hors effet de zone) — d20 + BAB + mod de **Dextérité** du lanceur contre la CA de contact de la cible (amputée du modificateur de Dextérité si la cible perd son bonus de Dextérité, même chemin que `resolve_attack`) ; la portée du sort est une valeur absolue, il n'y a donc **pas** de pénalité par incrément de portée ; en cas d'échec le sort n'a aucun effet (l'emplacement est brûlé). Simplifications documentées (miroir du contact de mêlée) : le modificateur de taille et Weapon Finesse ne sont pas modélisés. Conformance L3 (CRB « Cast a Spell — Touch Attacks », « Cover », « Concealment », « Range — Touch ») : le jet de contact (mêlée ou distance) est une **attaque** — la CA de contact inclut le **couvert** de la cible (terrain +2/+4/+8 et couvert mou +4 à distance, même chemin `_attack_cover_bonus` que `resolve_attack` ; mêlée = ligne terrain seule), la **dissimulation** peut faire rater l'attaque après la touche (jet de d100, convention `max(concealment de la cible, 50)` si l'attaquant est `blinded`, journalisé « spell lost » / « spell delivered »), et un sort de contact qui **inflige des dégâts** menace le critique sur un 20 naturel et **double ses dégâts** sur confirmation réussie (le jet de confirmation est un jet d'attaque contre la même CA ; un sort de contact sans dégâts — Grace, Ray of Enfeeblement — ne menace pas, aucun dé consommé). Magic Missile (touche automatiquement), les sorts de zone et les sorts à sauvegarde simple ne sont pas concernés. 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). **Sorts « harmless » et cible consentante** (SRD « Spell Descriptions — Saving Throw » : « (harmless) : The spell is usually beneficial, not harmful, but a targeted creature can attempt a saving throw if it desires » ; « Voluntarily Giving up a Saving Throw : A creature can voluntarily forego a saving throw and willingly accept a spell's result ») : `SpellSpec` porte un booléen `harmless` (JSON `"harmless"`, défaut `false`) posés sur les 29 sorts dont la fiche SRD marque la sauvegarde « (harmless) » (vérifiés un à un contre le KB — les sorts SRD « save: none » comme Aid et Bless n'ont pas la marque et n'en ont pas besoin). Jeté sur une cible **consentante** (soi-même ou un même camp), le jet de sauvegarde primaire est **totalement sauté** — aucun dé consommé, journalisé `NO SAVE` (`_save_forgone`). Deux exceptions fidèles conservent le jet : une cible **hostile** n'est pas consentante, et un sort de soin sur un **mort-vivant** lui est **nuisible** (SRD cure-light-wounds.md : il « deals damage to them instead » avec « Will half » pour demi-dégâts). Simplification documentée : la « volonté » est approximée par le camp (le moteur n'a pas de concept d'intention de cible) ; un sort harmless jeté sur un ennemi garde donc son jet. 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`. - Formes de zone (S1a) et scalage par niveau d'incanteur (S1b) : `AreaEffect.shape` vaut `burst` (défaut), `cone` ou `line` (CRB « Magic — Aiming a Spell »). Le **burst** reste un cercle centré sur la case résolue (cible ou `target_pos`, U7b) — **sauf** si l'`AreaEffect` déclare `"centered_on_caster": true` : le point d'origine est alors **la case du jeteur**, quel que soit la cible résolue ou le `target_pos` (SRD spells/bless.md : « The caster and all allies within a 50-ft. burst, centered on the caster » ; spells/bane.md : « 50-ft.-radius burst, centered on you ») ; faute de cible primaire désignée, la sauvegarde primaire est sautée et **chaque** créature de la zone jette la sienne (comme au point-cast U7b). `bless.json` et `bane.json` portent le drapeau.. Le **cône** (90° — « a quarter-circle », la largeur valant la longueur à chaque distance) et la **ligne** (5 ft de large, longueur = `radius_ft`) partent de la **case du jeteur** et sont pointés vers la cible résolue (`target_id`) ou le point désigné (`target_pos`) — le cône exclut la case d'origine, la ligne part d'un **coin** de la case du jeteur et n'affecte **pas** sa propre case (CRB « Aiming a Spell » : « It starts from any corner of your square » — SRD Lightning Bolt : « The bolt begins at your fingertips », le trait quitte la case du jeteur au lieu de la remplir ; un viseur dégénéré origine==cible n'affecte personne). **Ligne d'effet des zones** (SRD « Aiming a Spell — Line of Effect ») : la **ligne** s'arrête à la première case qui bloque la ligne d'effet (« extends to the limit of its range or until it strikes a barrier that blocks line of effect ») — la case-barrière est la dernière du gabarit, rien au-delà ; le **cône** exclut toute case sans ligne d'effet depuis sa case d'origine (« a cone... affects only an area, creature, or object to which it has line of effect from its origin ») ; le **burst** n'affecte aucune créature en couvert total depuis son centre (« It can't affect creatures with total cover from its point of origin (in other words, its effects don't extend around corners) »). Toute créature dont une case occupée intersecte le gabarit subit l'effet, avec sauvegarde et RS par créature comme pour un burst (application partagée, géométrie dans `areas.py`). **Couvert et sauvegardes de Réflexes** (SRD « Cover and Reflex Saves ») : une créature dans la zone qui a du couvert **terrain** entre elle et le point d'origine de l'effet (centre du burst, case du jeteur pour cône/ligne) ajoute à son jet de Réflexes la moitié de son bonus de couvert CA (+1 partiel / +2 standard / +4 amélioré) ; le couvert mou des créatures n'accorde **aucun** bonus de sauvegarde (SRD « Soft Cover »). Simplification documentée : la sauvegarde primaire de la cible résolue, jetée sur le chemin générique des sorts avant la résolution de la zone, ne rejoue pas ce bonus (nul pour un burst centré sur la cible, l'origine étant sa propre case). Le snapshot d'observation expose `{shape, radius_ft, centered_on_caster}` via la clé `area`. Un `DamageEffect` peut déclarer `"scaling": {"dice": "1d6", "per_levels": 1, "max_dice": 10}` (dés par niveau — Fireball/Lightning Bolt « 1d6 per caster level (maximum 10d6) », Burning Hands « 1d4 per level (maximum 5d4) », Shocking Grasp « 1d6 per level (maximum 5d6) ») et/ou `"scaling_flat": {"amount": 1, "per_levels": 1, "max": 5}` (bonus plat par niveau — Inflict Light Wounds « 1d8 + 1 per caster level (maximum +5) ») ; à l'incantation, `effective_formula` résout dés additionnels = `min(max_dice, niveau // per_levels)` et bonus plat = `min(max, (niveau // per_levels) × amount)` avec la formule de base — le total scalé suit ensuite le critique, la résistance énergétique et la métamagie comme n'importe quelle formule (le chargeur refuse un dé de scalage de type différent du dé de base). Absence de scalage = comportement inchangé. Le scalage s'applique aussi aux soins (S5b) : un `HealEffect` porte le même `scaling_flat` (bonus plat par niveau) résolu par `effective_formula(niveau)` à l'incantation — Cure Light Wounds « 1d8 + 1 per caster level (maximum +5) » et Cure Moderate Wounds « 2d8 + 1 per caster level (maximum +10) » (SRD spells/cure-light-wounds.md, spells/cure-moderate-wounds.md) sont désormais scalés au lieu d'épinger une formule figée. - Sorts multi-projectiles (S2b) : un `DamageEffect` peut déclarer `"projectiles": {"base": 1, "per_levels": 2, "at_level": 1, "max": 5}` — le nombre de projectiles tirés vaut `base + (niveau - at_level) // per_levels` (delta de niveau planché à 0), plafonné à `max` (SRD Magic Missile : « one missile, plus one additional missile per two caster levels beyond 1st [...] maximum of five » → 1 au niveau 1, 2 au niveau 4, 5 au niveau 9 ; Scorching Ray : `per_levels` 4, `at_level` 3, `max` 3 → 1/2/3 rayons aux niveaux 1/7/11). À l'incantation, chaque projectile **jetle ses propres dégâts** et, si le sort exige une attaque de contact à distance (`requires_ranged_touch`, Scorching Ray), **jetle sa propre attaque de contact à distance** (couvert, dissimulation et menace de critique incluss — un rayon qui rate ne inflige rien, les autres partent quand même) ; les dégâts de sort ne sont pas réduits par la RD, et la résistance énergétique s'applique à chaque projectile. La RS est opposée une fois par cible (le moteur ne répartit pas les projectiles sur plusieurs cibles — toutes touchent la cible résolue). Si la cible tombe en cours de séquence (pv < 0 ou morte), les projectiles restants sont **perdus** (même convention que les attaques multiples d'une arme `count` > 1). Magic Missile (auto-touché, pas d'attaque) et Scorching Ray sont ainsi SRD-conformes sur le nombre de projectiles ; la fiche dorée du liche (niveau d'incanteur 11) tire désormais ses cinq missiles. - Dégâts récurrents de sort (S2c) : un `DamageEffect` peut déclarer `"recurring": {"formula": "2d4", "types": ["acid"], "base_rounds": 1, "per_levels": 3, "max": 7}` — le nombre de rounds de récurrence vaut `base_rounds + niveau // per_levels`, plafonné à `max` **en total** (SRD Acid Arrow : « For every three caster levels you possess, the acid [...] lasts for another round [...] dealing another 2d4 points of damage in each round » → 1 round au niveau 1, 2 au niveau 3, 7 au niveau 18+ : 1 + 6 rounds additionnels). À l'attaque initiale réussie, une source de dégâts récurrents (`RecurringDamage` sur le `CombatantState`, liste `recurring_damage`) est posée sur la cible vivante ; au **début du tour** de la cible (dans `_turn_gate`, après le tick de saignement), chaque source jetle ses dés, est **réduite par la résistance/immunité énergétique** (acide) mais **jamais par la RD** (c'est un dégât de sort), inflige ce dégât, puis perd un round ; arrivée à 0 round elle est retirée. L'observation expose chaque source sous `recurring_damage` (`{formula, types, rounds_left}`). Simplification documentée : la neutralisation d'acide (immersion/eau) n'est pas modélisée — le dégât court jusqu'à l'expiration de sa durée. - 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). **Drapeau `spell_resistance` par sort (S2e)** : `SpellSpec` porte un booléen `spell_resistance` (JSON `"spell_resistance"`, défaut `true`). Quand un sort le déclare `false` (les sorts dont la fiche SRD indique « Spell Resistance: no » — Acid Arrow, Flurry of Snowballs, Web, Darkness, Sanctuary, les `Protection from …`, etc. ; 28 sorts importés vérifiés contre le KB), la vérification de SR est **totalement sautée**, tanto sur la cible directe que par créature dans une zone. Les 28 sorts SRD « RM : non » portent désormais `"spell_resistance": false` ; les 79 autres gardent le défaut `true`. 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, **composante par composante** (conformance B21, CRB « Energy Resistance ») : chaque composante de dégâts (`DamageComponent`) est réduite uniquement par la résistance correspondant à SES types (maximum en cas de types multiples — simplification documentée : une même formule de dés n'est pas répartie entre les types listés), plancher 0 par composante ; une arme 1d6 tranchant + 1d6 feu contre résistance feu 5 ne perd que ses dés de feu, les dés tranchants, les bonus plats et les dés de précision (attaque sournoise, non typés) restent intacts ; l'immunité à un type annule uniquement les composantes de ce type. Les types physiques (contondant/perforant/tranchant) restent sur le chemin de la RD, appliquée **uniquement à la part physique** du dégât (dés physiques + bonus plats + précision) — conformance A3 (CRB « Damage Reduction » : « Damage reduction does not negate … energy damage dealt along with an attack ») : la RD ne mange plus la composante énergétique d'une attaque mixée (ex. arme à feu infligeant des dégâts de feu contre RD 15) ; les composantes énergétiques ne sont réduites que par résistance/immunité énergétique. La résistance énergétique s'applique à l'étape du dégât final (même étape que la RD), y compris aux dégâts de sorts (un `DamageEffect` est une composante unique — comportement inchangé). - 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 »). Le champ est porté par l'**instance** de condition, pas seulement par la définition : Mirror Image (SRD spells/mirror-image.md) jette ses images à l'incantation (1d4 + 1 par 3 niveaux, max 8) et impose une instance `blurred` dont le `miss_chance` vaut `images/(images+1)` en pourcentage. Une cible avec **n'importe quelle dissimulation** (`miss_chance > 0` — flou 20 % comme dissimulation totale 50 %) ne peut pas être la victime d'une attaque sournoise (conformance A7 — SRD rogue « Sneak Attack » : « A rogue cannot sneak attack while striking a creature with concealment »). 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. Conformance L2 (CRB « Conditions — Blinded » : « All opponents are considered to have total concealment (50% miss chance) against the blinded character ») : la même convention `max(cible, 50)` s'applique aux deux autres portes de concealment de l'attaquant — l'attaque sournoise est **impossible** pour un aveugle (dissimulation totale) et ses attaques d'opportunité sont **supprimées** contre toute cible (`suppressed (total concealment)`, budget d'AoO non consommé). - 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 qui a une dissimulation, même partielle (`miss_chance > 0` ; conformance A7 — SRD rogue : « A rogue cannot sneak attack while striking a creature with 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é est **totalement évitée (0)** si la cible ou l'adversaire menaçant est à ≥ 10 ft (2 cases) de l'attaquant (SRD « Attack — Shooting or Throwing into a Melee » : « you can avoid the −4 penalty »). Simplification documentée : la réduction RAW à −2 quand la cible est de deux catégories de taille au-dessus des personnages amis (et 0 à trois ou plus) n'est pas modélisée. 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). B20 (conformance) : pour un attaquant multi-cases, le bonus effectif est le **meilleur** (plus faible) couvert parmi ses cases occupées — l'attaquant choisit son meilleur coin/case selon le CRB ; les créatures 1×1 sont inchangées. - 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). La durée des sorts `summon monster` est `1 round/level` (SRD spells/summon-monster-1.md : « Duration 1 round/level (D) ») : un `SummonEffect` déclare `duration_rounds_per_level` (défaut 0 = durée fixe, comportement pré-conformance) et la durée est résolue au moment de l'incantation comme `max(1, niveau du lanceur × duration_rounds_per_level)`, selon la même convention de scalage par niveau que `ConditionEffect` (voir « Formes de zone et scalage »). Les deux sorts importés (`summon_monster_i/ii.json`) portent `duration_rounds_per_level: 1`. - Conditions effectives : 29 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, épinglé) n'autorise qu'une unique action de mouvement — conformance L9 (SRD « Conditions — Nauseated ») : « The only action such a character can take is a single move actions per turn » — seuls les vraies actions de mouvement (`move`, `stand_up`, `retrieve_weapon`, `repair_weapon`) survivent au filtre ; le pas de placement (`5foot_step`) et se jeter à terre (`drop_prone`) sont des actions **libres**, non de mouvement, et sont donc retirés ; le nauséeux 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 (une action de mouvement OU standard, pas d'attaque à outrance ni d'action à round complet, mais swift/libre/immédiate conservées) ; `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`, et `cant_attack` — un épinglé ne peut pas attaquer (SRD « Conditions — Pinned » : il « peut seulement tenter de se libérer ») ; lacune documentée : le moteur ne pose **jamais** `pinned` (pas de boucle de maintien du grapple), la condition n'est atteignable que par injection directe. 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 ; l'expiration de la dernière condition `bleeding` inflige son **dernier tick** puis remet `bleed_damage = 0` (conformance D2 — SRD « Bleed » : le cantrip, sauvegarde de **Volonté** annule, pose la condition `bleeding` avec `bleed_damage = 1`, donc 1 point de dégâts par round). 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é. Une créature confuse ne fait **aucune** attaque d'opportunité (`cant_aoo`) — simplification de la clause SRD « not already devoted to attacking », le moteur n'ayant pas ce concept. - 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 ; les dégâts infligés par cette AoO sont appliqués comme **pénalité au jet de manœuvre** (conformance A2 — CRB « Combat Maneuvers » : « apply that amount as a penalty to the attack roll to perform the maneuver », total planché à 0, 20 naturel réussit / 1 naturel échoue toujours) ; 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). Conformance B16 (CRB « Power Attack ») : la pénalité −X de Power Attack actif s'applique **au jet de CMB** de la manœuvre (X = BAB//4 + 1) — la pénalité couvre « les jets d'attaque de mêlée et les jets de manœuvres de combat » ; l'escape par compétence n'est pas pénalisé (un test de compétence n'est pas un jet de manœuvre). Conformance L1 (SRD « Combat Maneuvers ») : « Creatures that are size Tiny or smaller use their Dexterity modifier in place of their Strength modifier to determine their CMB » — Tiny/Diminutive/Fine calculent donc leur CMB avec le modificateur de Dextérité à la place de celui de Force. Leur allonge naturelle nulle (aucune case menacée, attaque dans leur propre case) et la co-occupation Tiny-or-smaller avec une créature strictement plus grande sont modélisées (conformance S4c) ; une attaque naturelle dotée d'un `reach_ft` explicite (ex. morsure d'ierlieth) conserve cette allonge. Conformance L2 (SRD « Combat Maneuver Defense ») : le CMD calculé ajoute les bonus de CA des huit types autorisés (« A creature can also add any circumstance, deflection, dodge, insight, luck, morale, profane, and sacred bonuses to AC to its CMD » — bonus issus des buffs/conditions/ dons, défense totale et combat défensif inclus), **toutes les pénalités de CA (« Any penalties to a creature's AC also apply to its CMD » — pénalités sans type comme grapplé −2 ou à plat ventre −4 contre la mêlée, la manœuvre étant un jet d'attaque de mêlée)** et une créature au contact pris de dos ne ajoute pas son bonus de Dextérité (« A flat-footed creature does not add its Dexterity bonus to its CMD » — la pénalité de Dextérité, qui n'est pas un bonus, reste appliquée). (SRD « Trip » / « Bull Rush » / « Overrun » ; APG « Reposition » / « Drag ») : « You can only [affect/reposition/drag] an opponent [who is] no more than one size category larger than you » — conformance L8 : trip, bull_rush, overrun, reposition et drag contre une cible de **deux catégories de taille ou plus au-dessus** de l'attaquant sont illégaux (tentative refusée, journalisée `rejected (target too large)`, aucun jet, pas d'AoO provoquée) ; les autres manœuvres (disarm, sunder, grapple…) n'ont pas de limite de taille. trip → `prone` ; disarm → l'arme tenue est **au sol** (SRD « Disarm » : « your target drops one item it is carrying » — `weapon_dropped`, exclue de `usable_attacks()` jusqu'à un `retrieve_weapon` ; un échec de 10 ou plus fait tomber **l'arme de l'attaquant**) ; conformance (SRD « Disarm ») : « If you successfully disarm your opponent without using a weapon, you may automatically pick up the item dropped » — un désarmement **réussi** par un attaquant **sans aucune arme de mêlée utilisable** (tentative à mains nues) saisit aussitôt l'arme tombée : elle quitte le sol et entre dans les **objets transportés** du désarmeur (`carried_items`, journalisé `unarmed disarm pickup`) — simplification documentée : l'arme saisie n'étant pas une `AttackSpec` du désarmeur, elle est **tenue, pas maniée** (pas utilisable pour attaquer) ; l'arme reste exclue des attaques de la victime jusqu'à son propre `retrieve_weapon` ; bull_rush → pousse la cible (direction sélectionnable via `Action.direction`) ; conformance L6 (SRD « Bull Rush ») : la poussée vaut 5 ft plus « For every 5 by which your attack exceeds your opponent's CMD you can push the target back an additional 5 feet » — `1 + marge//5` cases, stoppées au premier obstacle (un 20 naturel sous le CMD ne donne que la poussée de base) ; grapple → `grappled` uniquement (la boucle de maintien du grapple et l'application de `pinned` sont hors périmètre du moteur — la définition de la condition `pinned` est conforme au SRD, mais elle n'est jamais posée par le moteur lui-même) ; conformance L8 (SRD « Grapple ») : « If you successfully grapple a creature that is not adjacent to you, move that creature to an adjacent open space (if no space is available, your grapple fails) » — une réussite à distance **tire la cible** sur la case libre adjacente la plus proche (distance 5-10-5, départage (ligne, colonne) ; journalisé `grapple pulls`), et **échoue** sans poser la condition si aucune case adjacente libre n'existe (`FAILED (no adjacent space)`) ; escape → **une** sauvegarde (bonus statique de la compétence `escape_artist` ou CMB, + un d20) contre CMD — une seule tentative, sans boucle multi-tour. Conformance L4 (SRD « Combat Maneuvers ») : le 20 naturel réussit **toujours** à l'escape d'un grapple (« Rolling a natural 20 while attempting a combat maneuver is always a success (except when attempting to escape from bonds) » — un grapple n'est pas un lien), le 1 naturel échoue toujours. Un sunder endommage l'**arme** comme un OBJET, pas les PV (SRD « Sunder » / CRB « Smashing an Object ») : les dégâts réels de l'arme de l'attaquant (composantes physiques + bonus plat ; une arme brisée frappe à −2), moins la **dureté** de l'arme cible, sont retranchés de ses **points de vie d'objet**. Chaque arme porte donc dureté/pv d'objet (`AttackSpec.hardness`/`hit_points`, défauts de la Table 7-12 par classe d'arme : lame en acier 10/5 une main, 10/10 deux mains ; arme emmanchée bois 5/5 et 5/10 ; arme de jet 5/5 ; `enhancement` ajoute +2 de dureté et +10 pv par +1). À **pv ≤ moitié**, l'arme gagne la condition **brisée** : elle reste **utilisable** à **−2 aux jets d'attaque et de dégâts**, ne menace de critique que sur le **20 naturel** et ne fait que **×2** (CRB « Broken »). À **pv ≤ 0**, l'arme est **détruite** (`weapon_destroyed`) : excluante de `usable_attacks()`. Simplification documentée : l'option SRD « réduire à 1 pv au lieu de détruire » n'est pas offerte (le moteur détruit toujours). Une arme brisée (non détruite) est réparable via `Action(kind="repair_weapon")` ; une arme détruite ne l'est pas. overrun → passage au travers si la case au-delà est libre, et `prone` 1 round **seulement si le jet dépasse le CMD de 5 ou plus** (conformance R3 — CRB « Overrun » : « If your attack exceeds your opponent's CMD by 5 or more, you move through the target's space and the target is knocked prone ») ; un succès par 0–4 points ne fait que traverser, sans renverser ; conformance L7 (SRD « Overrun ») : une tentative **échouée** arrête l'attaquant « in the space directly in front of the opponent, or the nearest open space in front of the creature » — l'attaquant non adjacent est déplacé sur la case libre la plus proche de la cible sur la ligne d'approche (adjacent : immobile) ; conformance L7 (SRD « Trip ») : un trip qui « fails by 10 or more » renverse l'**attaquant** (`prone` 1 round, `total <= CMD - 10`) ; dirty trick → condition choisie par `Action.trick_condition` (défaut `dazzled` ; durée = 1 round + 1 round par 5 points dont l'attaque dépasse le CMD — APG « Dirty Trick » ; `blinded`/`dazzled`/`deafened`/`entangled`/`shaken`/`sickened` possibles (liste exacte de l'APG), `nauseated` exclu car hors de la liste RAW, et rejeté à l'exécution avec journalisation) ; steal → le champ `carried_items` du JSON de combattant porte les **objets transportés non maniés et non portés serré** (potions, scrolls, outils, bourses, vivres…) — seuls objets volables selon l'APG « Steal » (« any item that is neither held nor hidden in a bag or pack [...] Items that are closely worn (such as armor, backpacks, boots, clothing, or rings) cannot be taken [...] Items held in the hands (such as wielded weapons or wands) also cannot be taken with the steal maneuver—you must use the disarm combat maneuver instead ») : une réussite **transfère un objet** de la liste de la cible vers celle du voleur (journalisé `took `) ; simplifications documentées : le moteur prend le **premier** objet de la liste (le choix RAW de l'objet n'a pas de slot d'action), la main libre obligatoire et le +5 CMD des objets attachés (capes, armes fourrées, bourses) ne sont pas modélisés ; si la cible n'a **aucun objet transporté**, une réussite ne vole rien (journalisé `nothing stealable — wielded weapons require disarm`) ; les armes maniées (`attacks`) restent insaisissables par steal — il faut désarmer ; reposition → déplacement de la cible de 5 ft plus « For every 5 by which your attack exceeds your opponent's CMD, you can move the target an additional 5 feet » (APG « Reposition ») — `1 + marge//5` cases le long de la direction choisie (premier voisin libre par défaut, direction sélectionnable via `Action.direction`), stoppées au premier obstacle ; drag → traction de la cible de 5 ft plus 5 ft supplémentaires par 5 points d'excès sur le CMD (APG « Drag »), l'attaquant occupant sa case d'origine, stoppée au premier obstacle. `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) ; `evil_eye` (APG, conformance D3) inflige −2 à **une seule option** — l'option choisie est modélisée par les jets d'attaque — pendant **3 rounds** ; une sauvegarde de Volonté réussie ne l'annule pas mais **réduit la durée à 1 round** (« A Will save reduces this to just 1 round ») ; `slumber` (APG, conformance D4) endort la cible pour un **nombre de rounds égal au niveau de la sorcière** (Volonté annule ; simplification documentée : la fin RAW du sortilège quand la cible subit des dégâts n'est pas modélisée, le moteur n'a pas de crochet de réveil) ; `cackle` (APG, conformance D5) est une **action libre sans coût de ressource** qui prolonge d'un round tous les sortilèges evil-eye (et fortune/misfortune — non implémentés, seule extension modélisée : les buffs evil_eye) portés par **toutes les créatures à 30 ft** de la sorcière, sur une liste instantanée pour ne pas s'étendre à l'infini. Simplification documentée : le slot « hex » reste standard pour evil_eye/slumber ; les autres hexes à prolonger ne sont pas implémentés. 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. Une composante de dégâts peut déclarer `save_negates: false` (conformance D1 — SRD « Sound Burst » : « Fortitude partial » n'annule que l'étourdissement, jamais les dés) : la sauvegarde réussie ne réduit alors pas ces dégâts. **Sauvegardes partielles (S2d)** : un `ConditionEffect` peut déclarer `"on_success": {"condition_name": "shaken", "duration_rounds": 1}` (champ `save_partial`) — sur une sauvegarde **réussie**, au lieu d'annuler totalement l'effet, la cible subit la condition affaiblie indiquée (SRD Fear : paniqué à l'échec, `shaken` 1 round au succès). Sans `on_success`, le comportement reste « sauvegarde annule ». L'Extend Spell double aussi la durée de la condition partielle. - 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** modélisé (SRD `feats/selective-channeling.md` : « you can choose a number of targets in the area up to your Charisma modifier. These targets are not affected by your channeled energy ») via `Action(kind="channel_energy", channel_exclude=(ids…))` — honoré uniquement avec le don, plafonné au modificateur de Charisme (troncature déterministe de la liste excédentaire), sinon ignoré et journalisé (`selective channeling ignored (no feat)` — SRD Normal : « All targets in a 30-foot burst are affected ») ; `default_policy` n'exclut jamais (opt-in via politique personnalisée, comme `weapon_id`/`destination`/métamagie) ; effet observable dans les données actuelles : le mode soin soigne aussi les **ennemis vivants** dans le burst (positive energy heals every living creature), le don permet de leur refuser le soin, et le mode blessure peut épargner un allié mort-vivant ; les créatures blessées obtiennent un jet de Volonté DD `10 + demi-niveau du canaliseur + modificateur de Charisme` pour demi-dégâts — CRB Channel Energy ; le mode soin n'accorde pas de sauvegarde), 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 une attaque **d'arme à feu** à distance ET à ≤ 30 ft (Ultimate Combat « Deeds — Up Close and Deadly » : « a successful attack with a firearm against a target within 30 feet »), +1d6 s'ajoute aux dégâts ; sinon le bonus est perdu ; le drapeau est réinitialisé dans tous les cas), deed `quick_clear` (S5a — conformance implémentée, Ultimate Combat « Deeds — Quick Clear » : « As a standard action, you can spend 1 point from your grit pool to remove the broken condition from a single firearm you are holding, as long as that condition was gained by a firearm misfire ») — le deed dépense 1 grit et répare la **première** arme à feu brisée par un raté (ordre des attaques, déterministe) ; sans grit, l'action est un no-op journalisé ; sans arme à feu brisée, aucun grit n'est dépensé. Le deed occupe le compartiment **standard** du budget (exception par action résolue par `_effective_slot`, comme cackle pour les hex ; le kind `deed` reste swift pour up_close_and_deadly, et le filtre swift-consommé-par-immédiate ne jette plus le quick_clear). Simplifications documentées : la variante RAW « as part of a move action to clear all firearms » n'est pas modélisée (un seul firearm par deed), et Quick Clear ne répare que le broken issu d'un misfire — le broken d'un sunder se répare via `repair_weapon`. Le misfire lui-même (Ultimate Combat « Firearm Rules — Misfires » : « When a firearm misfires, it gains the broken condition ») : une attaque d'arme à feu (`firearm: true`) sur un **1 naturel** casse l'arme (`weapon_broken`, journalisé « MISFIRE (broken) ») — valeur de misfire minimale 1 ; la progression de la valeur de misfire et l'explosion des early firearms re-cassées ne sont pas modélisées. Interaction avec la règle « Broken » : une arme de mêlée brisée reste utilisable à −2, mais une **arme à feu brisée reste exclue** de `usable_attacks()` (le SRD la laisserait tirer à −2 avec une valeur de misfire majorée de 4 — cette progression n'étant pas modélisée, le moteur interdit prudemment de tirer avec un pistolet enrayé jusqu'à Quick Clear ou `repair_weapon`), `inspire_courage` + `spell_combat` (misty — Spell Combat : action de round complet, incante un sort standard et porte toutes ses attaques de mêlée à **−2** (CRB Magus, conformance — la pénalité s'applique à chaque jet d'attaque et de confirmation ; simplification : le coût de pool arcanique n'est pas modélisé) ; Inspire Courage (CRB Bardic Performance) : **+1 compétence** (competence) aux jets d'attaque et de dégâts — type qui se cumule donc avec les sources de moral (Heroism) — et **+1 moral** aux jets de sauvegarde contre les effets de charme et de peur, portée par deux modificateurs conditionnés (`fear` / `mind_affecting`, dédupliqués par la règle d'empilement du moral) ; simplifications documentées : la progression (+1 par quatre niveaux) n'est pas modélisée (plat +1) et le moteur n'ayant pas de descripteur « charme », `mind_affecting` en tient lieu (surcouverture : les effets de compulsion comptent aussi)) — couvertes par `tests/test_class_abilities.py`. - Le Liche (`data/monsters/lich.json`) : bloc de stats aligné sur la fiche KB `bestiary/lich.md` (PV 111, For 10, Int 22, Init +2, DD et bonus déjà conformes), `creature_type: "undead"` (sensible à l'énergie positive canalisée), aura de terreur (60 ft, Volonté DD 18 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. Choix assumé : la liste de sorts reste la liste d'encounter du simulateur (la liste préparée de la fiche KB contient des sorts absents du registre `data/spells/`) — seul le bloc de stats a été aligné ; la liste de sorts du registre est donc une approximation documentée, pas une transcription SRD. - Snapshot d'observation (contrat LLM, Phase 2) : avant chaque appel de politique, le moteur construit une `Observation` sérialisable (`to_dict()` → JSON) isolée du combat — le snapshot est la SEULE source d'info de la LLM (aucune référence `engine.*` exposée, collections copiées, jamais relues en retour : la politique peut les muter sans affecter le combat ; l'objet est un dataclass figé au premier niveau, les dictionnaires internes sont des copies mutables, non deep-freezés). Une politique LLM se branche via `CombatEngine(..., llm_policy=...)` (`LLMPolicy(observation) -> tuple[Action, ...]` : la politique ne reçoit QUE le snapshot, jamais l'état vivant ; un callable hérité à deux arguments `(observation, state)` reste accepté via l'adaptateur de compatibilité D1, signature inspectée une fois à la construction). 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_destroyed/weapon_stolen/weapon_dropped, carried_items (tuple dans l'ordre de la liste — les objets transportés volables, APG « Steal »), grappled_with, moved_this_turn, power_attack, name, level, size, natural_reach_ft (S4c : allonge naturelle du gabarit en pieds — 0 pour Tiny/Diminutive/Fine, 5 pour Small/Medium, 10 pour Large… ; l'allonge effective d'une attaque reste `max(allonge naturelle, reach_ft de l'arme)`), aware (SRD « Surprise » : agit au round de surprise), alignment (SRD « Alignment » : code à deux axes parmi LG/NG/CG/LN/N/CN/LE/NE/CE, défaut "N"), armor_spell_failure (SRD « Arcane Spells and Armor » : pourcentage 0-100 d'échec d'incantation arcanique dû à l'armure, authored sur le JSON — le modèle n'a pas d'équipement) et arcane_caster (bool : vrai = incanteur arcanse exposé à l'ASF, faux = incanteur divin jamais exposé), 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 cumulés au-delà de la dureté}, 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, splash ({damage, type} ou None — arme de jet à éclaboussures), hardness, hit_points (stats d'objet effectives, Table 7-12) — y compris les armes broken/destroyed/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), harmless (sort « (harmless) » du SRD — sauvegarde sautée sur cible consentante), area {shape, radius_ft, centered_on_caster} | None (U7a : premier AreaEffect de niveau supérieur du sort, gabarit de zone structuré, None si le sort n'a pas de zone), cast_time (code de temps d'incantation, « standard » pour un sort inconnu du registre), components (tuple trié des tokens de composantes V/S/M/F/DF, SRD « Cast a Spell — Spell Components » ; vide = composantes non encodées — aucun échec de composante possible pour ce sort)), 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), pending_cast ({spell_name, target_id, slot_level} de l'incantation d'1 round en cours ou None — résolue au début du prochain tour du lanceur, voir « Incantation d'1 round »), 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, sauf don Run), run_rounds (int — rounds de course consécutifs déjà courus, série brisée quand un tour passe sans courir) et run_rest_rounds (int — rounds de repos restants après un échec du jet d'endurance, course interdite tant que > 0) — voir « Course », 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`), recurring_damage (S2c — liste des sources de dégâts récurrents de sort actives, chacune `{formula, types, rounds_left}` ; tick au début du tour, réduit par résistance énergétique, jamais par la RD), hidden_from (tuple trié des ids d'observateurs ennemis qui ne perçoivent pas le combattant — état de dissimulation par observateur, voir « Stealth » ; vide = tout le monde le voit). - 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 : schema_version (P0.1 : version épinglée du schéma d'observation, constante `OBSERVATION_SCHEMA_VERSION` = 1 ; incrémentée seulement si une clé est retirée ou change de sens), 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 29 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`, ainsi que `metamagic` (dons de métamagie choisis pour cette incantation, B7) et `cast_defensively` (incantation défensive, B24) sur l'action `cast_spell`, et `channel_exclude` (ids exclus par Selective Channeling, plafonné au mod de Charisme, don requis) sur l'action `channel_energy` ; `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. Limites hors périmètre (arrêtées lors de la conformance L1-L10, documentées plutôt que bricolées) : - **Incantation multi-round vraie (`long`) hors périmètre** : le round de surprise et l'incantation d'1 round sont modélisés (voir « Round de surprise » et « Incantation d'1 round ») ; restent hors périmètre les temps d'incantation > 1 round (3 rounds, 10 minutes, 1 heure — SRD « Casting Time » : « These actions must be consecutive and uninterrupted, or the spell automatically fails ») : non planifiables dans un tour, ils sont jetés du budget (`downgraded:cast_time`) plutôt qu'approximés, faute de données d'objectif pour ces sorts rituels. - **Co-occupation au-delà de Tiny vs plus grand** : la règle Tiny-or-smaller est modélisée (S4c) — allonge naturelle 0, aucune case menacée, attaque possible dans sa propre case, et partage de case avec une créature strictement plus grande (déplacement à travers et fin de mouvement dessus) ; en revanche deux créatures de même taille (Small et plus) ne partagent jamais une case, et la co-occupation de plusieurs Tiny sur une même case n'est pas modélisée. - **Squeeze (se serrer dans un passage étroit) hors périmètre** (SRD « Moving Around or Through an Open Square » / « Squeezing Through » : une créature Large+ peut « squeeze » à travers un espace assez large pour une créature d'une catégorie de taille en dessous, à coût de mouvement accru et pénalités) : la largeur de corridor n'est pas modélisée par rapport à la taille — une créature Huge (3×3) est simplement bloquée par tout endroit où son footprint ne rentre pas ; ni le squeeze payant, ni la réduction de footprint dans les couloirs ne sont modélisés. - **Vol 3D — ligne d'effet par l'altitude hors périmètre** : le survol du terrain au sol est modélisé (voir « Vitesse de déplacement » : terrain difficile à coût 1, enjambement des obstacles non bloquants, barrières `blocks_los` infranchissables, SRD « Movement, Position, and Distance ») ; restent hors périmètre la **ligne d'effet 3D** (un vol à une altitude donnée passe AU-DESSUS d'un mur de hauteur finie ou franchit le rebord d'une falaise) et la **maniabilité de vol** (tourner/monter/plonger, chute en fin de mouvement, don Hover) : la carte ne porte **aucune hauteur de mur ni de falaise** — seule la hauteur de **sol** (`elevation`, marches) existe, et `blocks_los` est un booléen « barrière solide » (SRD « Aiming a Spell — Line of Effect »), pas une hauteur — donc « altitude du vol contre hauteur de l'obstacle » n'est pas exprimable dans les données ; la ligne d'effet reste strictement 2D et une barrière `blocks_los` est supposée de hauteur pleine (elle arrête aussi le vol). - **Passage à travers une créature (3 catégories d'écart) — approximations** : la règle est modélisée (traversée + AoO d'entrée, voir « Mouvement ») et le test d'Acrobatics « Tumbling » est désormais modélisé (voir « Acrobatics — Tumbling » : opt-in `tumble=True`, DD = CMD, +5 pour entrer dans la case d'un ennemi, +2 par adversaire supplémentaire, moitié vitesse, échec = AoO + mouvement arrêté) ; reste hors périmètre la clause des très grosses créatures inertes (« Some creatures, particularly very large ones, may present an obstacle even when helpless. In such cases, each square you move through counts as 2 squares » — les cases d'une créature inactive ne bloquent pas et ne coûtent pas double). - **Composantes des sorts et échecs d'incantation associés — moteur et données en place** (SRD « Cast a Spell — Spell Components » : « A spellcaster who has been deafened has a 20% chance to spoil any spell he tries to cast if that spell has a verbal component » ; SRD « Arcane Spells and Armor » : « If you cast an arcane spell with a somatic component while wearing armor, you incur a chance of arcane spell failure ») : `SpellSpec` porte un champ `components` (liste de tokens parmi V/S/M/F/DF, notation RAW acceptée — `"V, S, M/DF"` où la barre oblique sépare les alternatives) et `Combatant` porte `arcane_caster` (vrai pour les incanteurs arcanses — le liche ; faux pour les incanteurs divins — Oni) et `armor_spell_failure` (pourcentage 0-100 authored sur le JSON, le modèle n'ayant pas d'équipement — la CA des JSON est une valeur finale). `_cast_spell` joue les deux jets après les gardes de début d'incantation (emplacement déjà dépensé — un échec brûle l'emplacement, comme le fumble de concentration) : d100 ≤ 20 si le lanceur est `deafened` et le sort a la composante V (journalisé `casting failed (deafened)`) ; d100 ≤ l'ASF si le lanceur est arcanse, porte la condition `S` et a un ASF > 0 (journalisé `arcane spell failure`) ; convention `<=` miroir du jet de dissimulation. Chaque jet n'est joué que si sa condition tient : un lanceur non-sourd et dépourvu d'ASF garde un flux RNG bit-identique. Données encodées : les 108 sorts du registre `data/spells/` portent leur ligne `components` issue du KB (107 encodés ; Create Alcool, cantrip maison sans fiche KB, reste sans composante déclarée — pas d'échec possible) ; les 15 combattants de `data/pcs/` et `data/monsters/` portent `arcane_caster` (vrai : misty magus/bard, nairda sorcière, liche, agent de Thrune) et un `armor_spell_failure` **effectif** — tous à 0 dans les données actuelles : les incanteurs arcanes encodés sont soit sans armure (nairda, liche), soit en armure légère dont l'ASF est annulée par leur classe pour leurs sorts de liste (misty : chemise de mithral, sorts de barde/magus — classes/bard.md, classes/magus.md ; agent de Thrune : cuir clouté +1, sorts de magus — classes/magus.md). Aucun combat doré ne joue donc de jet de composante. Reste hors périmètre : le jet de Perception DD 20 basé sur le son du sourd et son échec automatique associé ne sont pas modélisés. Le soin des morts-vivants par les sorts de soin (`cure`) est en revanche modélisé : SRD « Cure Light Wounds » — « this spell deals damage to them instead of curing their wounds [...] can attempt a Will save to take half damage ». - **Congé des sorts à durée « (D) » hors périmètre** (SRD « Cast a Spell — Dismiss a Spell » : « Dismissing an active spell is a standard action that doesn't provoke attacks of opportunity » ; marque « (D) » de la durée, ex. SRD spells/summon-monster-1.md « Duration 1 round/level (D) ») : le moteur n'a ni action `dismiss` ni registre des sorts actifs par source — les effets à durée (`active_buffs`, `active_conditions`, invocations via `summon_duration`) expirent uniquement par leur décompte de rounds en fin de tour ; le congé volontaire (rappeler une invocation, couper un sort soutenu) n'est pas modélisable sans un modèle de suivi des sorts actifs, hors périmètre de la Phase 1. Limites de sorts (conformance D6-D9, documentées plutôt que bricolées) : - **Fear — durée figée** (SRD spells/fear.md : « each living creature in the area [...] becomes panicked unless it succeeds on a Will save [...]. If the Will save succeeds, the creature is shaken for 1 round » ; cône de 30 ft — modélisé, S1a/S1c) : la sauvegarde partielle est désormais modélisée (panic à l'échec, `shaken` 1 round au succès — voir « Sauvegardes partielles » plus bas) ; seule la durée « 1 round/niveau » du `panicked` reste fixée à 4 rounds. - **Bladed Dash — approximation** (SRD spells/bladed-dash.md : « When you cast this spell, you immediately move up to 30 feet in a straight line any direction [...] This movement does not provoke attacks of opportunity. You may make a single melee attack at your highest base attack bonus against any creature you are adjacent to at any point along this 30 feet ») : le sort est modélisé comme un buff de vitesse +30 ft pour 1 round ; l'attaque de mêlée gratuite, le déplacement sans AoO, la contrainte de ligne droite et le bonus de circonstance à l'attaque (mod d'Int ou de Cha, le plus élevé) ne sont pas modélisés. ## 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 ; `airborne_view` = vue de la grille pour les créatures aériennes (terrain difficile/coûteux → coût 1, cases infranchissables non bloquantes → traversables, `blocks_los` reste bloquant). - `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`. - `areas.py` — géométrie des gabarits de zone cône et ligne (CRB « Magic — Aiming a Spell ») : `cone_cells` (quart de cercle de 90°, test d'angle ±45° en arithmétique entière exacte, case d'origine exclue, cases sans ligne d'effet depuis l'origine exclues via `los.has_line_of_effect`) et `line_cells` (raie de Bresenham de 5 ft de large, longue de `radius_ft`, partant de la case du jeteur, tronquée à la première case `blocks_los`). Les bursts restent gérés par `_area_creatures` (filtrés par ligne d'effet depuis le centre). - `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 29 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, bane). 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 — la pénalité −X vise toutes les attaques de mêlée (contact de mêlée inclus, conformance A6), le bonus de dégâts uniquement les armes de mêlée —, activé comme action libre via le kind `activate_power_attack` qui pose `state.power_attack`). Conformance B16 (CRB « Power Attack ») : le bonus de dégâts passe à **+3X (×1,5)** pour une arme marquée `two_handed: true` dans le JSON de l'attaque (arme à deux mains, arme à une main tenue à deux mains, ou attaque naturelle primaire à ×1,5 de Force) ; la pénalité −X s'applique aussi **aux jets de manœuvre de combat** (CMB, hors escape par compétence) ; la division par deux (×0,5, +1X) pour une arme tenue à la main secondaire ou une attaque naturelle secondaire n'est **pas modélisée** (le moteur ne gère pas le combat à deux armes). 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, exigence de contact à distance `requires_ranged_touch` (rayons), marque `harmless` (SRD « (harmless) » — sauvegarde sautée sur cible consentante), temps d'incantation `cast_time`, composantes `components` (frozenset de tokens V/S/M/F/DF, SRD « Cast a Spell — Spell Components »)) et types d'effets (`DamageEffect`, `ConditionEffect`, `HealEffect`, `BuffEffect`, `AreaEffect` — burst/cône/ligne, S1a, `OneShotEffect` — bonus compétence à usage unique sur un jet d'attaque, de sauvegarde ou de compétence, SRD guidance). Un `DamageEffect` (et un `HealEffect`, S5b) porte des specs de scalage optionnels (`scaling` en dés, `scaling_flat` en bonus plat) résolues par `effective_formula(niveau)` au moment de l'incantation (S1b). Portées PF1e (personal, touch, close, medium, long) calculées par `spell_range_ft`. Chargeur JSON (`load_spell`, `load_spell_registry`) pour `data/spells/`, acceptant les codes de budget `cast_time` et leurs alias RAW (« 1 round », « 1 minute »…). 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. Option `collect_runs=True` (Phase 2) : renvoie en plus un `RunOutcome` par combat (vainqueur, rounds, pv/état de mort par combattant) — comportement par défaut inchangé. - `objectives.py` — primitives d'objectif Phase 2 (pur, sans moteur) : P(survie totale), P(TPK), P(≥1 mort), pv perdus attendus, rounds attendus et métrique de queue (dcile pire des pv restants) sur les `RunOutcome`. - `phase2_spike.py` — spike de cartographie du danger : politique monstre « focus fire » (concentration sur la cible la plus faible en CA, départage aux pv) contre laquelle la carte de danger mesure le groupe ; la face PJ reste sur `default_policy`. - `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 — incomplet, voir l'encadré « Fiches de personnages (PJ) » : ne pas régénérer `data/pcs/*.json`, qui est canonique, sans réappliquer les corrections SRD). ## Développement La gate de validation complète (tests + lint + types) : ```bash uv run pytest -q # 2000 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/`, 108 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.)