ctan 84bcf004c3 docs(rules): document missile count and per-spell SR limits
Two documented limitations, no mechanical change: (a) Magic Missile fires a single 1d4+1 missile - the SRD scaling to up to five missiles per caster level and multi-target splitting is not modeled; (b) the engine applies spell resistance to every targeted spell, so the few spells whose SRD entry says "SR: no" (e.g. Acid Arrow) are not distinguished - a per-spell SR flag was deliberately not added.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-12 13:08:27 +02:00

pf1e-simulator

Simulateur de combat Monte Carlo déterministe pour Pathfinder 1re édition.

Le moteur joue une rencontre définie (carte + camps) un grand nombre de fois — chaque combat est reproductible grâce à un générateur aléatoire seedé — puis produit un rapport d'équilibrage en français : taux de victoire par camp avec bande de confiance 3σ, nuls, rounds moyens et attrition par combattant.

Phase 0 : le moteur de combat couvre le socle des règles (initiative, attaques, critiques, RD, états de vie, déplacement, mêlée/distance). La couche LLM (tactiques et rédaction du rapport) n'est pas encore implémentée.

Fonctionnalités

  • Carte en grille de cases de 5 ft (1,5 m) au format YAML : couches ASCII terrain, elevation, zones, points nommés markers, déploiement par camp.
  • Comptage des diagonales selon la règle 5-10-5, coûts de déplacement selon le terrain, règle des coins pour les diagonales le long des murs.
  • Monstres définis en JSON (bestiaire) ; combattants déployés dans les zones de la carte ; ids dupliqués automatiquement désambiguïsés (goblin, goblin-2…).
  • Combats déterministes : chaque run utilise un flux SeededRng(seed + i) ; même seed ⇒ mêmes combats.
  • Rapport d'équilibrage en français : taux de victoire + bande 3σ (σ = √(p(1−p)/n)), nuls, rounds moyens, attrition moyenne par combattant (touches, critiques, dégâts infligés/subis).
  • Interface en ligne de commande pf1e-sim.

Prérequis et installation

Prérequis :

  • Python ≥ 3.12
  • uv (gestionnaire de paquets et d'environnements)

Installation :

git clone <url-du-dépôt> 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 :

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 :

uv run pf1e-sim \
  --map data/maps/sample_arena.yaml \
  --side players data/monsters/goblin.json data/monsters/goblin.json data/monsters/goblin.json \
  --side monsters data/monsters/orc.json data/monsters/orc.json \
  --runs 1000 --seed 1

Sortie (extrait) :

=== Rapport d'équilibrage ===
Carte : Arène d'essai
players : 3 combatants
monsters : 2 combatants
1000 combats simulés (seed 1, plafond 100 rounds)

Victoires players : 91.4% (914) — bande 3σ : [88.7%, 94.1%]
Victoires monsters : 8.6% (86) — bande 3σ : [5.9%, 11.3%]
Nuls : 0.0% (0)
Rounds moyens : 6.0

Utilisation de la CLI

usage: pf1e-sim [-h] --map MAP [--side NAME [FILE ...]] [--runs RUNS]
                [--seed SEED] [--round-cap ROUND_CAP] [--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 (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.

Interpréter le rapport

Pour chaque camp, le rapport donne le taux de victoire observé et une bande de confiance à 3σ calculée en forme fermée (modèle binomial) :

  • σ = √(p(1−p)/n) ; la bande est [p − 3σ, p + 3σ], bornée à [0, 1].
  • Un affrontement symétrique (combattants identiques sur des zones en miroir) doit rester proche de 50 % ; un écart hors de la bande 3σ signale un déséquilibre (avantage d'initiative, géométrie, portées…).
  • Une bande large indique un échantillon trop petit pour conclure — augmentez --runs.
  • L'attrition moyenne par combattant indique qui participe réellement : un combattant à distance qui n'est jamais rejoint affiche des dégâts subis proches de zéro, un combattant qui meurt systématiquement affiche des dégâts subis supérieurs à ses PV maximum (dégâts du coup fatal inclus).
  • Les nuls proviennent des combats atteignant le plafond de rounds (combattants immobiles, camps inaccessibles…).

Exemple vérifié — 1 gobelin (players) contre 2 orcs (monsters), 1000 runs, seed 1 : players 22,0 %, bande 3σ [18,1 % ; 25,9 %], 0 nuls, 5,2 rounds moyens. Sans ligne de visée depuis la zone de départ, l'archer gobelin doit contourner le mur central avant de tirer ; mais avec l'économie d'action activée, les orcs parcourent toute leur vitesse (30 ft) puis frappent dans le même tour — le gobelin solitaire ne peut plus les distancer et tombe vite en mêlée sous les 2d4+4 des falchions. C'est ce déséquilibre de cadence qui penche fortement le face-à-face à 1 contre 2 du côté des monstres.

Formats de données

Carte (YAML)

Chaque couche est un bloc ASCII de mêmes dimensions ; un caractère = une case de square_size_ft pieds. Seule terrain est obligatoire.

name: "Arène d'essai"
square_size_ft: 5

terrain: |            # couche obligatoire
  ####################
  #......######......#
  #..C...######...C..#
  #......~..~........#
  #..TT..~..~...TT...#
  #......~..~........#
  #..C............C..#
  ####################

legend:               # un caractère = un type de terrain
  "#": { type: wall, move_cost: null, blocks_los: true }  # bloque déplacement + LoS
  ".": { type: floor, move_cost: 1 }
  "T": { type: rubble, move_cost: 2 }                     # terrain difficile
  "~": { type: water, move_cost: 2 }
  "C": { type: pillar, move_cost: null, cover: true }     # bloque le pas, couvert (+4)
  "W": { type: ruin, move_cost: null, cover_bonus: 2 }    # couvert partiel (+2) ; 4 = standard, 8 = amélioré

elevation: |          # optionnel : chiffres = hauteur (appliqué : coût de pas + high ground)
  ....................
  ..1111..............
  ..1111..............
  ....................

zones: |              # optionnel : lettres de zone de déploiement
  ....................
  .AAAA..........BBBB.
  .AAAA..........BBBB.
  ....................

markers:              # optionnel : points nommés (validés, pas encore utilisés)
  autel: [1, 16]

deployment:           # obligatoire si zones : nom de camp -> lettre de zone
  players: A
  monsters: B

Valeur du move_cost : entier > 0 pour un terrain praticable, null pour un terrain infranchissable (mur, pilier). blocks_los et cover sont appliqués par la résolution des attaques (module los.py, règle des coins) : pas de ligne d'effet ⇒ l'attaque est impossible ; couvert ⇒ bonus de CA. Le champ optionnel cover_bonus (2, 4 ou 8) encode le niveau de couvert PF1e (partiel +2, standard +4, amélioré +8) ; cover: true sans cover_bonus vaut couvert standard (+4), idem pour un terrain blocks_los.

Monstre (JSON)

{
  "name": "Goblin",
  "level": 1,
  "size": "Small",
  "cr": "1/3",
  "xp": 135,
  "source": "Bestiary > Goblin",
  "abilities": {
    "str_score": 11, "dex_score": 15, "con_score": 12,
    "int_score": 10, "wis_score": 9, "cha_score": 6
  },
  "hp_max": 6,
  "ac": { "total": 16, "touch": 13, "flat_footed": 14 },
  "bab": 1,
  "initiative_mod": 6,
  "speed_land_ft": 30,
  "saves": { "fort": 3, "ref": 2, "will": -1 },
  "attacks": [
    {
      "name": "short sword",
      "kind": "melee",
      "attack_bonus": 2,
      "damage": [{ "formula": "1d4", "types": ["slashing"] }],
      "crit_range": 19,
      "crit_mult": 2
    },
    {
      "name": "short bow",
      "kind": "ranged",
      "attack_bonus": 4,
      "damage": [{ "formula": "1d4", "types": ["piercing"] }],
      "crit_mult": 3,
      "range_increment_ft": 60
    }
  ]
}

Champ range_increment_ft (optionnel, uniquement pour kind: ranged ou kind: touch) : incrément de portée en pieds. La pénalité de portée vaut −2 par incrément complet au-delà du premier ; une arme ranged reste utilisable jusqu'à 10 incréments, une arme touch jusqu'à 5 (jet/firearm), au-delà elle n'est pas sélectionnable. damage_bonus et dr sont optionnels. 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. spell_resistance (entier), resistances (dict type → montant) et immunities (liste de types) sont optionnels. Deux monstres d'exemple sont fournis : gobelin et orc.

Fiches de personnages (PJ)

fiches_personnages/ contient les 8 fiches de PJ au format Foundry VTT (pf1-sheet/v1) ; le chargeur loaders/foundry.py sait les lire. Elles sont transposées en JSON au schéma Combatant dans data/pcs/ (esha.json, harvie.json, ierlieth.json, jeanne.json, misty.json, nairda.json, oni.json, tammara.json) et se passent directement à la CLI comme les monstres :

uv run pf1e-sim --map data/maps/sample_arena.yaml \
  --side players data/pcs/oni.json \
  --side monsters data/pcs/tammara.json --runs 200

Les armes placeholder des fiches sans dégâts encodés sont omises (notées dans notes le cas échéant) ; le champ source de chaque fichier pointe vers la fiche Foundry d'origine.

Règles implémentées et limites de la Phase 0

Règles modélisées :

  • 1 naturel = échec automatique ; 20 naturel = touche + menace de critique.
  • Critique : (dés 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.
  • 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.
  • Initiative : triée sur (total, modificateur, ordre de liste), sans re-jet.
  • Économie d'action : un tour normal donne une action standard + une action de mouvement (ou une action à round complet), plus swift/free/immediate. La politique renvoie une séquence ordonnée d'actions exécutées dans l'ordre ; default_policy renvoie (full_attack,) si l'ennemi est en portée, (charge,) si une ligne droite existe, (5foot_step, full_attack) si un seul pas suffit à entrer en portée, ou (move, attack) sinon. Les actions immédiates (hors-tour : 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 ; 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 par clamp — approximation documentée : le moteur ne tient pas d'incantation en cours entre les tours, le sort d'1 round se résout au bout du tour courant au lieu du suivant ; 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 107 sorts importés de data/spells/ déclarent tous leur cast_time (codes normalisés, backfillés depuis le SRD) : 95 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.
  • 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. 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 ; 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 ; les armes naturelles (count > 1) ne gagnent pas d'itératifs. Plusieurs armes naturelles en une seule attaque à outrance ne sont pas modélisées. Choix de l'arme : Action(kind="attack", weapon_id=...) et Action(kind="full_attack", weapon_id=...) frappent avec l'arme désignée (l'arc plutôt que l'épée, l'arme principale plutôt que la secondaire) au lieu de l'arme auto-sélectionnée par weapon_for (première arme utilisable capable de toucher la cible à cette distance avec ligne d'effet). L'id doit désigner une arme utilisable (non brisée, non volée) pouvant atteindre la cible ; sinon l'action est rejetée (weapon_unavailable) et aucun jet n'est effectué. Sans weapon_id, la sélection automatique est inchangée. default_policy ne choisit pas d'arme (disponible via une politique personnalisée).
  • Mêlée (conformance R1, CRB « Big and Little Creatures in Combat ») : l'allonge naturelle du gabarit (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). 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 (les ennemis bloquent toujours le chemin), 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. 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. Simplification documentée : voler PAR-DESSUS les obstacles ou le terrain difficile n'est PAS modélisé — la praticabilité et les coûts de terrain de la grille s'appliquent à tout le budget (pas de vol 3D ni de maniabilité) ; le snapshot expose ce budget sous la clé additive speed_effective_ft. Destination explicite : Action(kind="move", destination=(r, c)) et Action(kind="5foot_step", destination=(r, c)) déplacent le combattant vers la case (r, c) désignée (monter au couvert, quitter un défilé, se désengager) au lieu de auto-diriger vers target_id. Pour move, la case doit être praticable, libre et atteignable dans le budget de vitesse (sinon rejet unreachable_destination) ; le trajet réutilise la mécanique pas à pas avec provocation d'AoO. Pour 5foot_step, la case doit être exactement adjacente (sinon unreachable_destination) et le pas ne provoque jamais d'AoO. Sans destination, le comportement auto-dirigé est inchangé ; avec target_id et destination absents, rejet missing_target (U1). default_policy ne choisit pas de destination (disponible via une politique personnalisée).
  • Tailles et occupation multi-cases : une créature occupe un footprint carré à son ancre (pos) selon sa taille — Small/Medium 1×1, Large 2×2, Huge 3×3, Gargantuan 4×4, Colossal 6×6 (taille inconnue → 1×1). Distance, ligne d'effet/couvert, flanquement, zones menacées, AoO, charge, mouvement, manœuvres et invocation sont généralisés aux cellules occupées (distance = minimum entre cellules, LoS/couvert = meilleure paire) ; le comportement 1×1 reste bit-identique.
  • Taille et attaques/CA : les valeurs ac (total/touch/flat_footed) et attack_bonus des JSON de créatures sont des valeurs FINALES, déjà majorées du modificateur de taille PF1e aux attaques/CA (Small +1, Medium 0, Large −1, Tiny +2…) — vérifié sur toutes les fiches (data/monsters/*.json, data/pcs/*.json, verrouillé par tests/test_size_is_baked_in.py). Le moteur n'applique donc JAMAIS de bonus de taille aux jets d'attaque ni à la CA (ce serait un double comptage) ; la taille n'intervient dans la résolution que pour les manœuvres de combat (CMB/CMD, table _SIZE_MODIFIERS Small −1 … Colossal +8 — colonne distincte de la table attaques/CA), l'allonge naturelle, le footprint et l'occupation.
  • Élévation : monter d'un niveau de hauteur coûte +1 case de mouvement par niveau (la descente est gratuite) ; une créature située plus haut que sa cible gagne +1 à ses jets d'attaque de mêlée et à la confirmation de critique (règle du high ground). La ligne d'effet reste 2D (pas de LoS 3D).
  • Actions immédiates hors-tour : politique de réaction optionnelle reaction_policy(engine, state, event) (défaut : aucune) ; sur événement (attack_resolved, movement_step, spell_cast, damage_taken) une réaction Action(kind="immediate") peut être exécutée hors tour — une par événement — et consomme le prochain swift de l'état (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. 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 — 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.
  • 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) ; distance minimum 2 cases (10 ft). default_policy choisit la charge quand l'ennemi est hors de portée mais joignable en ligne droite.
  • Retraite : action à round complet. Le combattant se déplace jusqu'à 2× sa vitesse en s'éloignant de l'ennemi le plus proche (ascension gloutonne du champ de coût Dijkstra depuis la menace). La case de départ n'est pas considérée comme menacée — aucune AoO en la quittant. Les cases ultérieures provoquent des AoO normalement (résolues pas à pas, comme pour le mouvement). default_policy ne choisit pas la retraite (disponible via une politique personnalisée).
  • Course (Action(kind="run"), action à round complet, U5e) : le combattant se déplace jusqu'à 4× sa vitesse vers target_id (auto-dirigé, s'arrête adjacent à la cible) ou vers une destination explicite (U5c), en réutilisant exactement la mécanique de move (descente du champ de coût Dijkstra, pas à pas avec provocation d'AoO — courir ne protège pas des AoO), sauf que la course ne traverse pas le terrain difficile (conformance R2 — CRB « Movement, Position and Distance — Difficult Terrain » : « You can't run or charge across difficult terrain ») : la descente refuse tout pas dont le footprint toucherait une case de coût > 1 (mécanisme avoid_difficult de _descend_path, symétrique du refus de la charge dans _charge_path_clear) ; le combattant avance jusqu'à l'obstacle et s'y arrête (comportement déterministe, retenu plutôt que le rejet de l'action) quand la course 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). Une destination hors budget 4× (ou invalide) est rejetée (unreachable_destination) ; une destination joignable uniquement en traversant le terrain difficile est aussi rejetée — le test de portée (_destination_reachable) applique la même contrainte avoid_difficult que la descente, donc le verdict et le chemin parcouru sont cohérents (pas d'arrêt silencieux avant la destination) ; sans cible ni destination, rejet (missing_target). default_policy ne choisit pas la course (disponible via une politique personnalisée).
  • Pas de placement (5-foot step) : action libre qui déplace le combattant d'une case vers l'ennemi le plus proche sans provoquer d'AoO. Exclusivité mutuelle avec tout autre mouvement (move, charge, retraite) dans le même tour — tracked via moved_this_turn, réinitialisé au début du tour. 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. 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).
  • Jets de sauvegarde : resolve_save(state, save_type, dc) — fort/ref/will. 1 naturel = échec automatique, 20 naturel = succès automatique. Les modificateurs d'effets (StatModifier avec target correspondant) sont appliqués via resolve_modifiers (mêmes règles d'empilement que les attaques). Renvoie un SaveResult (success, roll, total, dc).
  • Modificateurs conditionnés : un StatModifier avec condition= n'est appliqué que si la condition tient au moment de la résolution (_modifier_condition_holds) : target_within_30ft_ranged (arme à distance à ≤ 30 ft), axe (arme dont le nom contient « axe »), et les descripteurs de sauvegarde (fear, cold, electricity, fey, divination, poison, disease, mind_affecting) — les descripteurs sont portés par l'aura de terreur, les sorts (types de dégâts, conditions de peur, écoles enchantement/divination) et les effets de zone. Toute condition inconnue ou non supportée reste exclue (False).
  • Incantation : chaque CombatantState porte spell_slots_left (initialisé depuis le champ spell_slots du JSON, ex. {"1": 4, ...}) ; _cast_spell dépense l'emplacement du niveau concerné (spend_spell_slot) — sans emplacement disponible, l'incantation est refusée (journalisée NO SPELL SLOT, feedback ok=False, reason="no_slot", aucun jet consommé) ; les cantrips (niveau 0) s'incantent librement. Le DD vaut 10 + niveau du sort (niveau d'emplacement effectif en cas de métamagie, 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) et son DD utilise ce niveau d'emplacement ; 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'a aucun effet (l'emplacement est brûlé). Simplifications documentées : Weapon Finesse et les autres bonus situationnels de jet de contact ne sont pas modélisés. 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. 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). Ciblage par point (U7b) : Action(kind="cast_spell", target_pos=(r, c)) centre l'AreaEffect de niveau supérieur du sort sur la case (r, c) désignée au lieu de la position de la cible créature — poser un Fireball au sol, pas seulement sur une tête. Le point doit être dans la carte et avoir une ligne d'effet depuis le jeteur (sinon rejet invalid_destination, aucun sort lancé). Avec target_pos et pas de cible résolue, l'incantation se fait sur le point : la sauvegarde primaire est sautée et chaque créature de la zone effectue sa propre sauvegarde (SR incluse). target_pos n'a de sens que pour un sort à zone : pour un sort ciblé sans AreaEffect, il est ignoré et la cible résolue est utilisée normalement. Sans target_pos, le comportement centré sur la créature est inchangé. default_policy ne choisit pas de point (disponible via une politique personnalisée). default_policy choisit soin / offensif / buff via _find_heal_spell, _find_offensive_spell et _find_ally_needing_heal.
  • Résistance aux sorts (SR) et résistances/immunités énergétiques : un combattant avec spell_resistance > 0 oppose sa SR à chaque sort qui le cible — jet d20 + niveau du jeteur contre la SR ; si le total est inférieur, le sort est résisté (journalisé, tous les effets annulés pour cette cible). Pour les sorts à zone, la SR est résolue par créature touchée (pas seulement sur la cible primaire). Les dégâts énergétiques (feu, froid, électricité, acide, son…) sont annulés par l'immunité (immunities) ou réduits par la résistance du type correspondant, 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 »). Une cible avec concealment total (miss_chance ≥ 50 %) ne peut pas être la victime d'une attaque sournoise ; une dissimulation partielle (flou 20 %) ne bloque plus la sournoise (conformance A7 — CRB rogue « Sneak Attack » : « cannot sneak attack while striking a creature with total 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.
  • 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 à dissimulation totale (miss_chance ≥ 50 % ; conformance A7 — une dissimulation partielle comme le flou 20 % n'empêche plus la sournoise). Conformance P2 : les morts-vivants (undead) et les constructeurs (construct) sont immunisés aux dégâts de précision — la cible ne peut pas être la victime d'une attaque sournoise ; les autres immunités RAW (vase, plante) ne sont pas modélisées (pas de tels types dans le modèle).
  • Tirer dans une mêlée / Precise Shot : toute attaque à distance (kind ranged ou touch avec incrément) contre une cible engagée dans une mêlée subit −4 au jet d'attaque ET au jet de confirmation (la cible est « engagée » quand un ennemi actif autre que l'attaquant, doté d'une arme de mêlée, menace ses cases) ; la pénalité tombe à −2 si la cible ou l'adversaire menaçant est à ≥ 10 ft (2 cases) de l'attaquant. Le don « Precise Shot » annule la pénalité. Les attaques de mêlée ne sont pas concernées.
  • Couvert : les trois niveaux PF1e sont modélisés via cover_bonus sur le terrain — partiel +2, standard +4 (ou cover: true), amélioré +8 ; le couvert mou des créatures reste +4 (couvert standard). 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).
  • Conditions effectives : 28 conditions prédéfinies (conditions.py). Leurs drapeaux comportementaux sont appliqués par le moteur : cant_act (étourdi, paralysé, dazed, endormi, inerme, fasciné) annule le tour ; cant_attack (nauséeux) n'autorise qu'une unique action de mouvement — 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. 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é.
  • 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 ; lacune documentée : l'allonge nulle et la co-occupation des créatures Tiny et plus petites ne sont pas modélisées (abstraction à la grille de 5 ft). 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 → disarmed (−4 attaque) ; 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/pinned ; escape → une sauvegarde (bonus statique de la compétence escape_artist ou CMB, + un d20) contre CMD — une seule tentative, sans boucle multi-tour. 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, pas les PV : str_mod + 2 de dégâts à l'arme, qui se brise à 10 d'usure cumulée (_WEAPON_DURABILITY) → disarmed ; une arme brisée est exclue de usable_attacks() et réparable via Action(kind="repair_weapon"). overrun → 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/deafened/entangled possibles, nauseated exclu car hors de la liste RAW) ; steal → le modèle ne porte aucun objet transporté non tenu : en application de l'APG « Steal » (« 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 ne vole rien (journalisé nothing stealable — wielded weapons require disarm) ; 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.
  • Dons : les 8 PJ portent 55 noms de dons uniques ; le chargeur résout chacun d'eux soit vers un effet réel (Power Attack actif, Weapon Focus ciblé par arme, Toughness lié au niveau, dons passifs via _stat_modifiers), soit vers un « no-op » tracé en toute transparence (noms décoratifs / de race) — aucun n'est silencieusement ignoré (tests/test_feats.py).
  • Capacités de classe émises par default_policy, chacune conditionnée à une ressource > 0 dépensée dans la méthode d'exécution : channel_energy (oni, énergie positive : si un allié est < 50 % de ses PV → soigne tous les êtres vivants à 30 ft ; sinon, si au moins 2 morts-vivants ennemis sont groupés à 30 ft → les blesse en 2d6 ; le camp de la cible n'est pas pris en compte — seul creature_type compte, selective_channeling non modélisé ; 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 à distance ET à ≤ 30 ft, +1d6 s'ajoute aux dégâts ; sinon le bonus est perdu ; le drapeau est réinitialisé dans tous les cas), inspire_courage + spell_combat (misty — 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) : creature_type: "undead" (sensible à l'énergie positive canalisée), aura de terreur (30 ft, Volonté DD 22 ou shaken 1 round, au début de son tour) et contact paralysant (au toucher, paralysé 1d4+1 rounds), RD 15/contondant et magie (conformance P2 : les deux composants sont requis — une arme contondante non magique ne contourne plus la RD ; lacune documentée : le modèle d'arme n'expose pas de propriété « magique », donc aucun équipement ne contourne cette RD) ; son JSON porte un dictionnaire spell_slots sans clés dupliquées.
  • Snapshot d'observation (contrat LLM, Phase 2) : avant chaque appel de politique, le moteur construit une Observation sérialisable (to_dict() → JSON) figée — le snapshot est la SEULE source d'info de la LLM (aucune référence engine.* exposée, collections copiées/figées). Une politique LLM se branche via CombatEngine(..., llm_policy=...) (LLMPolicy(observation, state) -> tuple[Action, ...]). Schéma enrichi (E1-E5) :
    • Par combattant : id, side, pos, occupied_cells, hp/hp_max, active/dead, dying/stabilized, conditions (noms + rounds_left), resources_left (+ spell_slot_{level}), weapon_broken/weapon_stolen, grappled_with, moved_this_turn, power_attack, name, level, size, dr {amount, bypass} ou None (C1), creature_type (C3), bab (E1), initiative_mod (E2) et initiative_total (U2 : total du jet d'initiative courant = d20 + mod, 0 si pas encore de clé — invocation fraîche), casting_ability ou None pour non-jeteur (E3), abilities {str/dex/con/int/wis/cha: score brut} (Medium), skills {nom: {rank, class_skill, armor_penalty}} uniquement rank > 0 (Medium), metamagic_feats (tuple trié), weapon_damage {weapon_id: dégâts courants}, summon_duration (int ou None), immediate_used, deed_up_close_and_deadly (flags actuels), active_buffs {source, rounds_left, mods: tuple de {target, value, bonus_type}} (E5), speed_land_ft, speed_fly_ft, speed_effective_ft (V4 : budget de déplacement effectif = max(land, fly), en pieds), ac {total, touch, flat_footed}, saves {fort, ref, will}, cmb, cmd, attacks (référentiel complet : id, name, kind, attack_bonus, damage, damage_bonus, crit_range, crit_mult, reach_ft, range_increment_ft, count, firearm — y compris les armes broken/stolen), features (noms des capacités), feature_details (C4 : tuple de {name, category, source_detail, effects: tuple de {target, value, bonus_type, condition, weapon_filter}}), spells (résumés enrichis du registre : name, level, school, range_ft, save, dc, summary, half_on_save, save_negates (E4), area {shape, radius_ft} | None (U7a : premier AreaEffect de niveau supérieur du sort, gabarit de zone structuré, None si le sort n'a pas de zone), cast_time (code de temps d'incantation, « standard » pour un sort inconnu du registre)), spell_resistance (int), resistances {type: montant}, immunities (tuple triée des types immunisés), sneak_dice (dés de précision disponibles : (niveau + 1) // 2 avec « Sneak Attack », sinon 0), engaged_in_melee (bool — une cible engagée dans une mêlée, signal pour le tir dans la mêlée et l'attaque sournoise), dying (bool — hp < 0 et pas morte : agonisante, inconsciente), stabilized (bool — stabilisée pendant son agonie, ne perd plus de pv tant qu'aucune nouvelle blessure ne la remet en agonie), last_turn_results (tuple de {kind, ok, reason} — résultat d'exécution du dernier tour, pour que la policy apprenne les actions rejetées/dégradées), readied ({kind, trigger} de l'action préparée ou None — préparation en cours, consommée quand le déclencheur la fait tirer), defensive_ac_bonus (int — bonus d'esquive à la CA de la défense totale (+4) ou du combat défensif (+2), réinitialisé au début du tour du combattant), fight_defensively (bool — combat défensif actif : −4 aux jets d'attaque et de confirmation jusqu'au prochain tour), aoo_remaining (int — attaques d'opportunité restantes ce round : _aoo_allowance (1 par défaut, 1 + mod de Dex avec Combat Reflexes, plancher 1) moins les AoO déjà consommées, réinitialisé à chaque nouveau round, jamais négatif), aid_attack_bonus (int — bonus d'aide autrui (+2 par aide réussie, cumulatif) ajouté au prochain jet d'attaque du combattant puis consommé, expiré au début de son tour s'il est inutilisé), aid_ac_bonus (int — bonus d'aide autrui (+2 par aide réussie, cumulatif) ajouté à sa CA, expiré au début de son tour), running (bool — course en cours jusqu'au prochain tour du combattant : il perd son bonus de Dextérité à la CA), bleed_damage (int — dégâts de saignement subis au début du tour du combattant, 0 = ne saigne pas ; V5b ; bleeding/confused sont visibles via conditions).
    • Par paire combattant actif × cible : target_index, distance_ft, weapon_for, range_penalty, can_charge, can_step5_to_attack, has_line_of_effect, flanking, movement_cost_ft, target_threatens_me, enemies_threatening_me, cover_bonus (int — bonus de couvert que la cible gagne contre une attaque de l'acteur, calculé comme dans resolve_attack : terrain +2/+4/+8 via la règle des coins, couvert mou +4 des créatures interposées pour les seules attaques à distance ; 0 si weapon_for est None), weapon_reason (str | None — None si weapon_for a choisi une arme ; sinon la raison normalisée de l'indisponibilité d'attaque, dans le vocabulaire ACTION_RESULT_REASONS : no_weapon (aucune arme utilisable), no_line_of_effect (ligne d'effet bloquée), out_of_range (arme existante mais cible hors de portée)), charge_reason (str | None — None si can_charge est vrai ; sinon la raison de ChargeResult verbatim (no_line_of_effect / no_charge_path) ou no_weapon si aucune arme de mêlée utilisable).
    • Top-level : round, acting_combatant, combatants, pairs, terrain, elevation, threatened (grille bool des cases menacées au contact par les ennemis — AoO), ranged_threatened (U6d : grille bool des cases sous tir ennemi à distance — champ de tir des armes à distance utilisables des ennemis actifs (portée maximale + ligne d'effet, bornée à la carte) ; les armes à distance ne menacent pas pour les AoO en PF1e : c'est une carte de champ de tir, pas une menace d'AoO ; la grille threatened de mêlée reste inchangée), initiative_order (U2 : tuple d'ids dans l'ordre d'initiative du round courant — le premier est le prochain à agir ; les invocations héritent de la case de leur conjurateur), terrain_semantics (un dict par type de terrain : type, move_cost, blocks_los, cover, cover_bonus), condition_definitions (C2 : tuple trié de (nom, {mods, flags, speed_mult, miss_chance}) — définitions complètes des 28 conditions de CONDITIONS_BY_NAME pour que la LLM interprète les drapeaux, les modificateurs et les chances de rater (concealment)). Les helpers publics du moteur (L5) sont désormais embarqués EN DONNÉES dans le snapshot (threatened, terrain_semantics, movement_cost_ft, target_threatens_me, enemies_threatening_me) — une LLMPolicy ne peut pas appeler engine.*, elle lit leurs résultats. Mapping des clés resources_left (vérifié par grep sur spend_resource/resources_left[ : channel_energy, hexes, grit, arcane_pool, bardic_rounds, plus spell_slot_{level} ajouté par le snapshot). Limites documentées : pas de grille reachable_ft budget=speed (délibérément rejetée — movement_cost_ft par paire + terrain_semantics + speed_effective_ft suffisent). Le couvert par paire est exposé via la clé cover_bonus (calcul identique à resolve_attack, couvert mou des créatures inclus). Les API de décision exposées (items L1-L8) s'y retrouvent : direction/trick_condition/weapon_id sur Action, ainsi que metamagic (dons de métamagie choisis pour cette incantation, B7) et cast_defensively (incantation défensive, B24) sur l'action cast_spell ; 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) :

  • Pas de round de surprise : le modèle de combattant n'a pas de champ de perception/attention (conscience du combat), donc le SRD « Surprise » ne peut pas être résolu — le combat démarre toujours au round 1 complet.
  • Incantation one_round bornée à un round complet : le moteur ne tient pas d'incantation en cours entre les tours ; un sort d'1 round se résout à la fin du tour courant au lieu du suivant (approximation documentée dans « Temps d'incantation »). L'incantation multi-round vraie (> 1 round) est jetée du budget (long).
  • Gabarits Tiny/Diminutive/Fine abstraits à la grille de 5 ft : leur allonge nulle et la co-occupation de leurs cases (plusieurs créatures minuscules sur une même case) ne sont pas modélisées ; seule la substitution Dextérité/Force du CMB (conformance L1) tient compte de leur taille.

Limites de sorts (conformance D6-D9, documentées plutôt que bricolées — le moteur n'a ni gabarit de cône, ni scalage par niveau d'incanteur, ni projectiles multiples, ni sauvegarde partielle) :

  • Burning Hands — ni cône ni dégâts par niveau (SRD spells/burning-hands.md : « 1d4 points of fire damage per caster level (maximum 5d4) », cône de 15 ft) : le moteur n'a pas de scalage par niveau d'incanteur ni de zone en cône ; le sort est modélisé en dégâts fixes 1d4 feu, sauvegarde de Réflexes pour moitié, sur la cible résolue (la formule 1d4+1 qui surestimait le dégât de base a été corrigée — conformance D6).
  • Acid Arrow — sans dégâts continus (SRD spells/acid-arrow.md : portée longue — corrigée, conformance D7 — et « For every three caster levels you possess, the acid, unless neutralized, lasts for another round [...], dealing another 2d4 points of damage in each round ») : le moteur n'a pas de subsystem de dégâts continus par sort (ni neutralisation d'acide) ; seuls les 2d4 de dégâts initiaux sont modélisés, les rounds de dégâts persistants sont hors périmètre.
  • Fear — sauvegarde totale et pas de cône (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) : la condition appliquée est bien panicked (conformance D8), mais le moteur n'a pas de sauvegarde partielle pour les conditions — un succès annule totalement l'effet au lieu de poser shaken 1 round — et le gabarit en cône n'existe pas (sort résolu sur la cible) ; la durée « 1 round/niveau » est fixée à 4 rounds.
  • Magic Missile — un seul projectile (SRD spells/magic-missile.md : « You strike [...] with one missile, plus one additional missile per two caster levels beyond 1st [...] maximum of five missiles ») : le moteur n'a pas de scalage multi-projectiles ni de répartition sur plusieurs cibles ; le sort tire une unique missile (1d4+1 force, touche automatiquement) sur la cible résolue (conformance D9 — documenté, la fiche dorée est inchangée).
  • Résistance aux sorts appliquée à tout sort ciblé : le moteur oppose la SR du combattant à chaque sort qui le cible, sans drapeau par sort ; les rares sorts dont la fiche SRD déclare « Spell Resistance: no » (ex. Acid Arrow) ne sont donc pas distingués et se heurtent quand même à la SR (conformance D9 — documenté plutôt qu'un drapeau spell_resistance par sort).

Architecture

  • dice.py — parseur de notation de dés (2d4+1) et lancer.
  • rng.py — SeededRng (flux reproductible par graine) et ScriptedRng (séquence de dés scriptée pour les tests de transcripts).
  • models.py — schémas Pydantic v2 : Combatant, AbilityScores, ACProfile, AttackSpec (avec firearm pour les armes touch à feu), DamageComponent, Saves, DamageReduction.
  • map.py — MapSpec/TerrainType, chargement YAML et validation (dimensions, caractères connus, zones, déploiement, markers).
  • grid.py — grille 5-10-5 : distance, step_cost (terrain + élévation), règle des coins (diagonal_allowed), Dijkstra reachable avec ou sans budget.
  • los.py — ligne d'effet et couvert par la règle des coins : gate de ligne d'effet dans weapon_for, bonus de couvert par niveau (+2/+4/+8 terrain via cover_bonus, +4 couvert mou des créatures) dans resolve_attack.
  • combat.py — CombatEngine déterministe : initiative, actions, résolution des attaques (couvert, ligne d'effet, pénalités de portée, CA de contact pour les attaques touch et firearms), tailles et allonges (occupation multi-cases, zones menacées en anneau), high ground, actions immédiates opt-in (réactions hors-tour), états de vie, incantation (emplacements, concentration par situation, AoO d'incantation), invocation, manœuvres (avec sélection directionnelle et choix de condition d'état), capacités de classe, transcripts, politique par défaut, snapshot d'observation sérialisable Observation/LLMPolicy (frontière LLM). Les modificateurs conditionnés (StatModifier.condition) sont évalués via _modifier_condition_holds avec le contexte d'action (arme, portée, descripteurs de sauvegarde) ; les conditions inconnues restent exclues.
  • effects.py — système d'effets : StatModifier (modificateur de caractéristique avec type de bonus) et resolve_modifiers (application des règles d'empilement PF1e). Types de bonus cumulables (dodge, racial, trait, sans type) s'additionnent ; types non-cumulables (morale, sacré, profane, enhancement…) gardent la valeur la plus élevée. Les pénalités suivent les mêmes règles. Fondation pour sorts, conditions, dons et capacités de classe.
  • conditions.py — conditions PF1e : Condition (dataclass avec modificateurs + flags comportementaux + miss_chance) et 28 conditions prédéfinies (shaken, sickened, fatigued, exhausted, entangled, dazzled, blinded, deafened, flat-footed, prone, stunned, paralyzed, nauseated, dazed, staggered, frightened, panicked, cowering, helpless, fascinated, invisible, bleeding, confused). Les modificateurs sont intégrés à resolve_attack (attaque, dégâts, CA) et resolve_save (jets de sauvegarde) via _stat_modifiers qui collecte effets + conditions ; les drapeaux comportementaux (cant_act, cant_attack, cant_move, staggered, etc.) sont appliqués par le moteur pendant le tour.
  • abilities.py — dons et capacités : AbilitySpec (dataclass avec catégorie feat/class/racial/trait) et fabriques de dons (Weapon Focus ciblé par arme, Toughness lié au niveau, Power Attack actif −X attaque / +2X dégâts, X = BAB//4 + 1 — 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), temps d'incantation cast_time) et types d'effets (DamageEffect, ConditionEffect, HealEffect, BuffEffect). Portées PF1e (personal, touch, close, medium, long) calculées par spell_range_ft. Chargeur JSON (load_spell, load_spell_registry) pour data/spells/, 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.
  • cli.py — front-end argparse pf1e-sim, rapport français, code de sortie 2 en cas d'erreur.
  • loaders/ — combatant.py (chargeur unifié PJ + monstres avec résolution des dons → AbilitySpec et des sorts → Combatant.spells) et foundry.py (outil de migration one-shot depuis les fiches Foundry).

Développement

La gate de validation complète (tests + lint + types) :

uv run pytest -q          # 714 tests
uv run ruff check src tests
uv run basedpyright src   # mode strict
  • Ruff est configuré avec select = ["ALL"], longueur de ligne 100.
  • Basedpyright tourne en typeCheckingMode = "strict" sur src et tests.
  • Le moteur est testé par des transcripts épinglés (combats 1v1 et 2v2 scriptés) et par des assertions statistiques en forme fermée (matchup symétrique dans la bande 3σ).

Feuille de route

  • Phase 1 — magie et états : largement réalisée. Sorts par emplacements (spells.py + base data/spells/, 107 sorts), DD et sauvegardes, RD non appliquée aux dégâts de sorts, concentration par situation, invocation, conditions effectives (conditions.py), manœuvres de combat, dons (55 noms résolus, abilities.py + chargeur) et capacités de classe (channel energy, hex, deeds, inspire courage / spell combat) sont en place et documentés dans « Règles implémentées ». Le socle B5 est en place : hauteurs/élévations (coût de déplacement + high ground), tailles Large+ (occupation multi-cases), allonges > 5 ft (allonge naturelle + reach_ft, zones menacées en anneau) et actions immédiates hors-tour (politique de réaction opt-in). Restent hors du socle (voir « Non modélisé ») : les markers.
  • Phase 2 — couche tactique LLM : stratégies en langage naturel traduites en politiques, balayage de matrices de positionnement. Le contrat d'observation (snapshot sérialisable Observation + wrapper LLMPolicy) est en place ; la couche LLM elle-même reste à implémenter. Les capacités désormais débloquées du moteur (ready, delay, défense totale, destination de déplacement, choix d'arme, boucle de feedback) sont démontrées par une politique lisant uniquement l'Observation dans tests/test_llm_engine_capabilities.py (démo de capacités, pas une implémentation de la couche LLM).
  • Phase 3 — rapporteur LLM local : agrégation des statistiques et rédaction du rapport d'équilibrage en français. (Non implémentée.)
S
Description
Scripts de simulation de combats PF 1e
Readme 5.8 MiB
Languages
Python 100%