From f61fc5c61fd9fed1728c90c40d3e26c7591ec374 Mon Sep 17 00:00:00 2001 From: ctan Date: Sat, 26 Sep 2026 20:12:05 +0200 Subject: [PATCH] docs: reorganize documentation structure - Clean README.md with installation, usage, CLI, examples - Move data formats to docs/data-formats/ (map.md, monster.md, pc-sheets.md, index.md) - Move architecture to docs/architecture/ (index.md, technical-architecture.md, phase-2-llm-architecture.md) - Add contributing guidelines in docs/contributing.md - Clean README.md: install, usage, CLI, data-formats/rules/architecture links, contributing link --- README.md | 2341 +---------------- docs/architecture/index.md | 67 + docs/architecture/phase-2-llm-architecture.md | 183 ++ docs/architecture/technical-architecture.md | 64 + docs/contributing.md | 149 ++ docs/data-formats/index.md | 7 + docs/data-formats/map.md | 87 + docs/data-formats/monster.md | 117 + docs/data-formats/pc-sheets.md | 53 + 9 files changed, 764 insertions(+), 2304 deletions(-) create mode 100644 docs/architecture/index.md create mode 100644 docs/architecture/phase-2-llm-architecture.md create mode 100644 docs/architecture/technical-architecture.md create mode 100644 docs/contributing.md create mode 100644 docs/data-formats/index.md create mode 100644 docs/data-formats/map.md create mode 100644 docs/data-formats/monster.md create mode 100644 docs/data-formats/pc-sheets.md diff --git a/README.md b/README.md index d578281..9b36129 100644 --- a/README.md +++ b/README.md @@ -7,34 +7,13 @@ chaque combat est reproductible grâce à un générateur aléatoire seedé — 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 : - +**Prérequis :** - Python ≥ 3.12 - [uv](https://docs.astral.sh/uv/) (gestionnaire de paquets et d'environnements) -Installation : - +**Installation :** ```bash git clone pf1e-simulator cd pf1e-simulator @@ -42,8 +21,8 @@ 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 : +**Vérification :** ```bash uv run pf1e-sim --help ``` @@ -63,8 +42,7 @@ uv run pf1e-sim \ --runs 1000 --seed 1 ``` -Sortie (extrait) : - +**Sortie (extrait) :** ``` === Rapport d'équilibrage === Carte : Arène d'essai @@ -83,7 +61,8 @@ Rounds moyens : 6.6 ``` usage: pf1e-sim [-h] --map MAP [--side NAME [FILE ...]] [--runs RUNS] [--seed SEED] [--round-cap ROUND_CAP] [--transcript] - [--simple-monster-death] + [--simple-monster-death] [--llm-report] [--output OUTPUT] + [--llm-model MODEL] [--llm-base-url URL] [--llm-api-key KEY] ``` | Option | Rôle | Défaut | @@ -92,2291 +71,45 @@ usage: pf1e-sim [-h] --map MAP [--side NAME [FILE ...]] [--runs RUNS] | `--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 : +| `--round-cap ROUND_CAP` | Plafond de rounds par combat, entier ≥ 1 | `100` | +| `--transcript` | Imprimer le déroulé du premier combat en tableau | désactivé | +| `--simple-monster-death` | Mort simplifiée des monstres : `hp ≤ 0` | désactivé | +| `--llm-report` | Générer un rapport narratif FR via LLM | désactivé | +| `--output OUTPUT` | Écrire le rapport complet (factuel + LLM) dans ce fichier | — | +| `--llm-model MODEL` | Modèle LLM (défaut: `PF1E_LLM_MODEL` ou `gpt-4o-mini`) | — | +| `--llm-base-url URL` | URL de base LLM (défaut: `PF1E_LLM_BASE_URL`) | — | +| `--llm-api-key KEY` | Clé API LLM (défaut: `PF1E_LLM_API_KEY`) | — | +**Exemple avec rapport LLM :** +```bash +export PF1E_LLM_API_KEY=sk-... +export PF1E_PROMPT_PACK_DIR=/chemin/vers/prompt-pack +uv run pf1e-sim \ + --map data/maps/sample_arena.yaml \ + --side players data/monsters/goblin.json \ + --side monsters data/monsters/orc.json \ + --runs 100 --seed 1 --llm-report --output rapport.md ``` -=== 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) +Voir la documentation complète dans [`docs/data-formats/`](docs/data-formats/) : +- [Carte (YAML)](docs/data-formats/map.md) +- [Monstre (JSON)](docs/data-formats/monster.md) +- [Fiches de personnages PJ](docs/data-formats/pc-sheets.md) -Chaque couche est un bloc ASCII de mêmes dimensions ; un caractère = une case -de `square_size_ft` pieds. Seule `terrain` est obligatoire. +## Règles implémentées -```yaml -name: "Arène d'essai" -square_size_ft: 5 +Voir la documentation complète dans [`docs/rules/`](docs/rules/) : +- [Règles Phase 0 implémentées](docs/rules/phase-0-implemented.md) +- [Conformance SRD](docs/rules/conformance-srd.md) -terrain: | # couche obligatoire - #################### - #......######......# - #..C...######...C..# - #......~..~........# - #..TT..~..~...TT...# - #......~..~........# - #..C............C..# - #################### +## Architecture & développement -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é +Voir [`docs/architecture/`](docs/architecture/) pour l'architecture technique, +le plan d'implémentation Phase 2 (LLM), et les décisions de design. -elevation: | # optionnel : chiffres = hauteur (appliqué : coût de pas + high ground) - .................... - ..1111.............. - ..1111.............. - .................... +## Contribuer -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.) +Consultez [`docs/contributing.md`](docs/contributing.md) pour les guidelines de +contribution : style de code, tests, gate de validation, processus de PR. \ No newline at end of file diff --git a/docs/architecture/index.md b/docs/architecture/index.md new file mode 100644 index 0000000..643acab --- /dev/null +++ b/docs/architecture/index.md @@ -0,0 +1,67 @@ +# Architecture & Plan d'implémentation + +## Vue d'ensemble + +- [Architecture technique](technical-architecture.md) — modules, flux de données, interfaces +- [Plan Phase 2 (LLM)](phase-2-llm-architecture.md) — étapes P5.1–P5.3, décisions, risques +- [Conformité SRD](conformance-srd.md) — écarts documentés, corrigés, hors périmètre +- [Feuille de route](roadmap.md) — Phases 0, 1, 2, 3 et au-delà + +## Structure du code + +``` +src/pf1e_simulator/ +├── combat.py # CombatEngine, résolution attaques, actions, states +├── dice.py # parseur notation dés (2d4+1) +├── rng.py # SeededRng, ScriptedRng +├── models.py # schémas Pydantic v2 (Combatant, AttackSpec, etc.) +├── map.py # MapSpec, chargement YAML, validation +├── grid.py # grille 5-10-5, Dijkstra, airborne_view +├── los.py # ligne d'effet, couvert (règle des coins) +├── areas.py # cône, ligne, burst (géométrie zones) +├── effects.py # StatModifier, resolve_modifiers (empilement bonus) +├── conditions.py # 29 conditions PF1e + flags comportementaux +├── abilities.py # dons (Weapon Focus, Power Attack, etc.) +├── spells.py # SpellSpec, effets, scalage, métamagie +├── metrics.py # win_rate, win_rate_sigma, win_rate_band +├── runner.py # EncounterSpec, run_encounter, EncounterReport +├── objectives.py # primitives Phase 2 (P(survie), P(TPK), etc.) +├── phase2_spike.py # politique "focus fire", danger cartography +├── cli.py # argparse, rapport FR, flags LLM +├── loaders/ +│ ├── combatant.py # chargeur PJ + monstres + dons/sorts +│ └── foundry.py # migration Foundry (one-shot, incomplet) +└── report.py # LLM report writer (Phase 3) +``` + +## Décisions clés (D-table) + +| ID | Domaine | Décision | +|----|---------|----------| +| D1 | Économie d'action | Résolue par deux étages (budget général + filtres spécialisés) | +| D2 | Cartographie danger | Non exhaustive : région d'échec + frontière + sensibilité | +| D3 | Priorité v1 | Cartographie d'abord, puis optimiseur (mêmes briques) | +| D4 | Fournisseur/modèle | Client agnostique ; implémentation distante d'abord, locale ensuite ; mode JSON/schéma requis | +| D5 | Budget | Plafonds petits (≤ 50 appels/campagne, ≤ 20 combats vitrine) + coupe-circuit | +| D6 | Reproductibilité | Cache obligatoire ; clé composite incluant le modèle ; replay `call_seq` | +| D7 | Prompts | Externes / dépôt séparé (voir §14) | + +## Gate de validation + +```bash +uv run pytest -q +uv run ruff check src tests +uv run basedpyright src +``` + +- Ruff : `select = ["ALL"]`, longueur ligne 100 +- Basedpyright : `typeCheckingMode = "strict"` sur `src` et `tests` +- Transcripts épinglés + assertions statistiques (matchup symétrique dans bande 3σ) + +## Feuille de route + +- **Phase 0** ✅ — socle combat (initiative, attaques, critiques, RD, états, déplacement, mêlée/distance) +- **Phase 1** ✅ — magie/états (108 sorts, DD/sauvegardes, concentration, invocation, conditions, manœuvres, dons, capacités de classe) +- **Phase 2** ✅ — couche tactique LLM (few-shot, réparation bornée, contrat Observation/LLMPolicy) +- **Phase 3** ✅ — rapporteur LLM local (agrégation stats + rédaction FR, `report.py`, `--llm-report`) +- **Phase 4** (futur) — optimiseur de scénarios, campagnes multi-rencontres \ No newline at end of file diff --git a/docs/architecture/phase-2-llm-architecture.md b/docs/architecture/phase-2-llm-architecture.md new file mode 100644 index 0000000..33d7274 --- /dev/null +++ b/docs/architecture/phase-2-llm-architecture.md @@ -0,0 +1,183 @@ +# Architecture Phase 2 — Couche tactique LLM + +> Document consolidé depuis `phase-2-llm-architecture.md` (historique complet conservé dans l'original à la racine de `docs/`) + +--- + +## 1. Vision générale + +La Phase 2 ajoute une **couche tactique LLM** au simulateur déterministe Phase 0/1 : + +- **P5.1** ✅ Fournisseur réel (`ProviderClient`) — client OpenAI-compatible HTTP, cache, budget, observabilité +- **P5.2** ✅ Few-shot + réparation bornée — prompts externes versionnés, boucle `max_repairs` tracée par `NLPolicyStats` +- **P5.3** ✅ Rapporteur LLM local — agrégation stats + rédaction FR (`report.py`, `--llm-report`) + +Le contrat d'observation (`Observation` + wrapper `LLMPolicy`) est en place ; la couche LLM elle-même est désormais implémentée. + +--- + +## 2. Contraintes & décisions arbitrées (D-table) + +| ID | Domaine | Décision | +|----|---------|----------| +| D1 | Économie d'action | Résolue par deux étages (budget général + filtres spécialisés) | +| D2 | Cartographie danger | Non exhaustive : région d'échec + frontière + sensibilité | +| D3 | Priorité v1 | Cartographie d'abord, puis optimiseur (mêmes briques) | +| D4 | Fournisseur/modèle | Client agnostique ; implémentation distante d'abord, locale ensuite ; **mode JSON/schéma requis** | +| D5 | Budget | **Plafonds petits** (≤ 50 appels de compilation/campagne, ≤ 20 combats en vitrine) + coupe-circuit dans le client | +| D6 | Reproductibilité | **Cache obligatoire** ; clé composite incluant le modèle ; replay `call_seq` | +| D7 | Prompts | **Externes / dépôt séparé** (voir §14) | + +--- + +## 3. P5.1 — ProviderClient (fournisseur réel) + +**Fichier** : `src/pf1e_simulator/provider.py` — `OpenAILLMClient` + +- Speaks OpenAI chat-completions wire format over HTTPS (stdlib `urllib.request`) +- Base URL, model, API key résolus depuis l'env (`config.py` : `PF1E_LLM_API_KEY`, `PF1E_LLM_BASE_URL`, `PF1E_LLM_MODEL`) +- Aucune dépendance HTTP externe +- Error taxonomy : `ProviderError` (transient 429/5xx), `ProviderRejectedError` (4xx permanent), `ProviderResponseError` (2xx body invalide) + +### Safeguards intégrés (dans `LLMClient` base class) + +- **Cache** : `ResponseCache` (clé composite = modèle|params|prompt_version|schema|prompt_hash), sharded, thread-safe +- **Budget** : `CostBudget` (max_calls, max_total_tokens, max_cost_usd) — appliqué avant chaque appel +- **Retry/Backoff** : `retries` (défaut 2), `backoff_base_s` (0.25s), exponentiel +- **Circuit Breaker** : `CircuitBreaker` (seuil échecs consécutifs, probes après refus) + +### Interface + +```python +class LLMClient(ABC): + def __init__(self, *, model: str, prompt_version: str, ...) + def complete(self, prompt: str, params: ClientParams | None = None) -> str +``` + +`prompt_version` = `version:name:hash12` (pack version + nom prompt + hash contenu) — requis pour validation cache D6. + +--- + +## 4. P5.2 — Few-shot + réparation bornée + +### PromptPack externe (§14) + +- Répertoire pointé par `PF1E_PROMPT_PACK_DIR` (hors repo) +- Layout : `version.txt` + `.md` + `.examples.json` (optionnel) +- `PromptPack.load(root)` : strict, pas de fallback, lève `PromptPackError` si invalide +- `prompt_version = version:name:hash12` (hash couvre prompt + exemples) + +### NLPolicy + +```python +@dataclass(frozen=True) +class NLPolicy: + client: LLMClient + builder: PromptBuilder + prompt_name: str = "nl_default" + fallback: Fallback = fallback_default + max_repairs: int = 1 + stats: NLPolicyStats = field(default_factory=NLPolicyStats) + +def __call__(self, observation: Observation) -> tuple[Action, ...]: + # 1. build prompt via PromptBuilder + # 2. client.complete(prompt) + # 3. parse_actions() → tuple[Action] + # 4. si échec → retry jusqu'à max_repairs (injecte erreur + instruction) + # 5. fallback scripté si épuisé +``` + +- `NLPolicyStats` : `calls`, `repairs_attempted`, `repairs_succeeded`, `fallbacks` — « matière des rapports de P5.3 » +- `FakeLLMClient` pour tests 100% offline (script de réponses, zéro réseau) + +--- + +## 5. P5.3 — Rapporteur LLM local (Phase 3) + +**Module** : `src/pf1e_simulator/report.py` + +### Structures + +- `ReportContext` : agrège `EncounterReport`, `DangerMetrics`, `NLPolicyStats`, `ClientStats`, `MatchupBenchmark`, `DangerCell`, `SensitivityEntry` +- `build_report_context()` : fonction pure, calcule `DangerMetrics` depuis `RunOutcome` si fourni +- `build_report_prompt(ctx, template)` : rend rapport factuel + danger + robustesse + observabilité LLM +- `LLMReportPolicy` : distinct de `NLPolicy` (sortie texte libre, retry simple, `ReportStats`) +- `ReportBudget` : plafonds vitrine (`max_calls=1`, ajusté pour retries via `for_retries()`) +- `format_llm_report(ctx, llm_text)` : préfixe factuel + `=== Récit d'équilibrage (LLM) ===` + narratif + +### CLI + +- `--llm-report` : active mode LLM (exclusif avec `--transcript`) +- `--output FILE.md` : écrit rapport complet (factuel + LLM) dans fichier +- `--llm-model`, `--llm-base-url`, `--llm-api-key` : overrides CLI +- Exige `PF1E_LLM_API_KEY` + `PF1E_PROMPT_PACK_DIR` env + +### Pack FR + +- `tests/fixtures/prompt_pack_fs/report.md` : narrateur FR, structure 4 sections (Bilan, Danger, Robustesse, Observabilité) +- `report.examples.json` : 2 exemples few-shot +- Découvert via `PromptPack` comme `nl_default` + +--- + +## 6. Risques & mitigations + +| Risque | Impact | Mitigation | +|--------|--------|------------| +| Bandes 3σ trompeuses à petit n | Mauvaises conclusions quantitatives | Étage A pour le quantitatif (formule fermée) | +| Événements rares (TPK) | Monte Carlo naïf inefficace | Ciblage par scénarios, cartographie danger | +| Explosion combinatoire | Énumération impossible | Coarse-to-fine (danger_map, frontier, sensitivity) | +| Prompt trop long | Coût/tokens élevés | Vue compacte ~4k tokens, budget ≤ 50 appels | +| Hallucination chiffres | Rapport non fiable | Injection stats verbatim, pas d'invention | +| Tests flaky (texte libre) | CI instable | `FakeLLMClient` scripté + assertions structure | + +--- + +## 7. Intégration CLI (P5.3) + +```bash +# Variables d'env requises +export PF1E_LLM_API_KEY=sk-... +export PF1E_PROMPT_PACK_DIR=/chemin/vers/prompt-pack + +# Rapport LLM + sortie fichier +uv run pf1e-sim \ + --map data/maps/sample_arena.yaml \ + --side players data/monsters/goblin.json \ + --side monsters data/monsters/orc.json \ + --runs 100 --seed 1 \ + --llm-report --output rapport.md +``` + +Options ajoutées : +- `--llm-report` : active LLM (exclusif `--transcript`) +- `--output FILE.md` : écrit rapport complet (factuel + LLM) +- `--llm-model`, `--llm-base-url`, `--llm-api-key` : overrides + +--- + +## 8. Tests & Gate + +### Nouveaux tests (27) +- `test_report_context.py` (4) — `ReportContext` + `build_report_context` +- `test_report_prompt_pack.py` (3) — prompt pack version/render +- `test_llm_report_policy.py` (4) — `LLMReportPolicy` + retry/fallback +- `test_format_llm_report.py` (4) — `format_llm_report` empty/with narrative +- `test_cli_llm_report.py` (4) — flags mutuellement exclusifs, env req +- `test_report_budget.py` (8) — `ReportBudget`, `CostBudget`, retries + +### Gate complète +```bash +uv run pytest -q +uv run ruff check src tests +uv run basedpyright src +``` + +--- + +## 9. Liens transverses + +- [Architecture technique](../architecture/technical-architecture.md) +- [Conformité SRD](../conformance-srd.md) +- [Formats de données](../data-formats/index.md) +- [Règles Phase 0](../rules/phase-0-implemented.md) \ No newline at end of file diff --git a/docs/architecture/technical-architecture.md b/docs/architecture/technical-architecture.md new file mode 100644 index 0000000..f1d0e87 --- /dev/null +++ b/docs/architecture/technical-architecture.md @@ -0,0 +1,64 @@ +# Architecture technique + +## Modules principaux + +| Module | Responsabilité | +|--------|----------------| +| `combat.py` | `CombatEngine` : initiative, actions, résolution attaques, états de vie, incantation, invocation, manœuvres, transcript, politique par défaut, `Observation`/`LLMPolicy` | +| `dice.py` | Parseur notation dés (`2d4+1`) + lancer | +| `rng.py` | `SeededRng` (flux reproductible par graine), `ScriptedRng` (tests) | +| `models.py` | Schémas Pydantic v2 : `Combatant`, `AbilityScores`, `ACProfile`, `AttackSpec`, `DamageComponent`, `Saves`, `DamageReduction` | +| `map.py` | `MapSpec`/`TerrainType`, chargement YAML, validation (dimensions, chars, zones, déploiement, markers) | +| `grid.py` | Grille 5-10-5 : `distance`, `step_cost`, `diagonal_allowed`, Dijkstra `reachable`, `airborne_view` | +| `los.py` | Ligne d'effet, couvert par règle des coins : gate dans `weapon_for`, bonus dans `resolve_attack` | +| `areas.py` | Géométrie gabarits zone : `cone_cells` (quart cercle 90°), `line_cells` (Bresenham 5ft), bursts via `_area_creatures` | +| `combat.py` | `CombatEngine` déterministe : initiative, actions, résolution attaques, tailles/allonges, high ground, actions immédiates opt-in, états de vie, incantation, invocation, manœuvres, capacités de classe, transcripts, politique par défaut, `Observation`/`LLMPolicy` | +| `effects.py` | `StatModifier` + `resolve_modifiers` (empilement PF1e : dodge/racial/trait/sans-type s'additionnent ; moral/sacré/profane/enhancement gardent le max) | +| `conditions.py` | 29 conditions PF1e : `Condition` (modificateurs + flags comportementaux + `miss_chance`) | +| `abilities.py` | Dons : `AbilitySpec` + fabriques (Weapon Focus, Toughness, Power Attack actif −X/+2X, X=BAB//4+1) | +| `spells.py` | `SpellSpec` (niveau, école, portée, save, DC, `requires_ranged_touch`, `harmless`, `cast_time`, `components`) + effets (`DamageEffect`, `ConditionEffect`, `HealEffect`, `BuffEffect`, `AreaEffect`, `OneShotEffect`) + scalage (`scaling` dés, `scaling_flat` bonus plat) | +| `metrics.py` | `win_rate`, `win_rate_sigma`, `win_rate_band` (bande 3σ bornée [0,1]) | +| `runner.py` | `EncounterSpec`/`Side`, `build_states` (placement + désambiguïsation ids), `run_encounter` (1 `SeededRng`/run), `EncounterReport` + attrition | +| `objectives.py` | Primitives Phase 2 : P(survie), P(TPK), P(≥1 mort), PV perdus, rounds, dcile PV restants | +| `phase2_spike.py` | Politique "focus fire" + cartographie danger | +| `cli.py` | `argparse` `pf1e-sim`, rapport FR, codes sortie, flags `--llm-report`/`--output` | +| `loaders/combatant.py` | Chargeur unifié PJ + monstres : résolution dons → `AbilitySpec`, sorts → `Combatant.spells` | +| `loaders/foundry.py` | Migration Foundry one-shot (incomplet — ne pas régénérer `data/pcs/*.json`) | +| `report.py` | LLM report writer P5.3 : `ReportContext`, `LLMReportPolicy`, `ReportBudget`, `format_llm_report` | + +## Flux de données principal + +``` +CLI (args) → _build_spec() → EncounterSpec + ↓ +load_spell_registry() → spell_registry + ↓ +run_encounter(spec, runs, seed, spell_registry, ...) + ↓ +Pour chaque run: SeededRng(seed+i) → CombatEngine → CombatResult + ↓ +Agrégation → EncounterReport (wins, draws, avg_rounds, attrition) + ↓ +format_report() → sortie FR +``` + +## Frontière LLM (Phase 2/3) + +``` +CombatEngine(llm_policy=LLMPolicy) → avant chaque appel politique: + Observation snapshot (to_dict() → JSON isolé) + LLMPolicy(observation) → tuple[Action, ...] +``` + +- LLM ne reçoit **que** le snapshot, jamais `engine.*` +- `PromptPack` externe (`PF1E_PROMPT_PACK_DIR`) : `version.txt` + `.md` + `.examples.json` +- `prompt_version = version:name:hash12` (clé cache composite D6) +- Cache `ResponseCache` (clé = modèle|params|prompt_version|schema|prompt_hash) +- Budget `CostBudget` + `CircuitBreaker` + retry/backoff + +## Tests & qualité + +- Transcripts épinglés (combats 1v1/2v2 scriptés via `ScriptedRng`) +- Assertions statistiques forme fermée (matchup symétrique dans bande 3σ) +- Gate : `uv run pytest -q && uv run ruff check src tests && uv run basedpyright src` +- Ruff `select = ["ALL"]`, ligne 100 ; basedpyright strict sur `src` + `tests` \ No newline at end of file diff --git a/docs/contributing.md b/docs/contributing.md new file mode 100644 index 0000000..b801404 --- /dev/null +++ b/docs/contributing.md @@ -0,0 +1,149 @@ +# Guidelines de contribution + +Merci de votre intérêt pour contribuer à pf1e-simulator ! + +## Standards de code + +### Langage & outils +- **Python ≥ 3.12** — typage strict, pas de `Any`, pas de `# type: ignore` +- **uv** pour la gestion de l'environnement (`uv sync --dev`) +- **Ruff** : `select = ["ALL"]`, longueur de ligne 100 +- **Basedpyright** : mode strict sur `src` et `tests` +- **Pytest** : tests déterministes, transcripts épinglés + +### Conventions Python +- **Dataclasses frozen** par défaut (`@dataclass(frozen=True)`) +- **Types explicites** — pas d'inférence ambiguë +- **Docstrings** sur les API publiques (modules, classes, fonctions publiques) +- **EM101/EM102** : messages d'erreur explicites (`msg = ...; raise X(msg)`) +- **Pas de `type: ignore` / `cast()` / `Any`** — le typage strict est la norme + +### Architecture +- **Modules petits** (< 250 lignes) — séparation des responsabilités +- **Pas de cycles d'import** — dépendances unidirectionnelles +- **Interfaces explicites** — `Protocol` ou classes abstraites pour l'injection +- **Tests purs** — pas de réseau, pas d'état global, `FakeLLMClient` pour LLM + +--- + +## Processus de développement + +### 1. Branche & commits +```bash +git checkout -b feat/ma-fonctionnalité +# Travail... +git add -A +git commit -m "feat(scope): description courte + +Description plus longue si nécessaire, wrapping à 72 caractères. + +Refs: #issue-number" +``` + +**Style de commit** : Conventional Commits +- `feat(scope): ...` — nouvelle fonctionnalité +- `fix(scope): ...` — correction de bug +- `docs(scope): ...` — documentation +- `refactor(scope): ...` — refactoring sans changement fonctionnel +- `test(scope): ...` — ajout/modification de tests +- `chore: ...` — maintenance (deps, config, etc.) + +### 2. Gate de validation (obligatoire avant PR) +```bash +uv run pytest -q +uv run ruff check src tests +uv run basedpyright src +``` +**Tous doivent passer** — pas d'exceptions. + +### 3. Tests +- **Couverture** : nouvelles fonctionnalités = nouveaux tests +- **Determinisme** : `ScriptedRng` / `FakeLLMClient` pour tests LLM +- **Transcripts épinglés** : combats 1v1/2v2 scriptés +- **Assertions statistiques** : forme fermée (bande 3σ sur matchup symétrique) +- **Noms de tests** : `test___` + +### 4. Documentation +- **API publiques** : docstrings complètes +- **Architecture** : mises à jour dans `docs/architecture/` +- **Règles** : conformité SRD dans `docs/rules/conformance-srd.md` +- **Formats** : exemples dans `docs/data-formats/` + +--- + +## Types de contributions + +### Bug fixes +1. Ouvrir une issue (template bug) avec reproduction minimale +2. Écrire un test qui échoue (reproduction) +3. Corriger le code +3. Vérifier gate complète + +### Nouvelles fonctionnalités +1. Ouvrir une issue (template feature) avec design/discussion +2. Découper en tranches atomiques testables (cf. `docs/architecture/phase-2-llm-architecture.md`) +3. Implémenter tranche par tranche, gate à chaque étape +4. Doc + tests + architecture doc mise à jour + +### Refactoring +- Gate complète avant et après +- Pas de changement fonctionnel +- Commits `refactor(scope): ...` + +### Documentation +- Corrections typo/clarification : commits `docs: ...` +- Nouvelles sections : suivi structure `docs/` + +--- + +## Configuration locale recommandée + +```bash +# Pre-commit (optionnel mais recommandé) +uv pip install pre-commit +pre-commit install + +# Éditeur : config basedpyright + ruff +# VS Code : extensions Python, Ruff, Basedpyright +``` + +--- + +## Code review + +### Ce qu'on regarde +- ✅ Gate complète passe +- ✅ Tests pertinents + passants +- ✅ Typage strict (pas de `Any`, `ignore`, `cast`) +- ✅ Architecture respectée (modules, dépendances) +- ✅ Docstrings sur API publiques +- ✅ Messages d'erreur explicites (EM101/EM102) +- ✅ Pas de code mort / code commenté +- ✅ Commits atomiques, messages clairs + +### Red flags (rejettent la PR) +- ❌ Gate qui ne passe pas +- ❌ `type: ignore` / `# type: ignore` / `cast()` +- ❌ Tests supprimés sans remplacement +- ❌ Code non testé (nouvelle logique sans test) +- ❌ Dépendances circulaires +- ❌ Code mort / `pass` / `TODO` sans issue + +--- + +## Signalement de sécurité + +Pour les vulnérabilités potentielles : **ne pas ouvrir d'issue publique**. +Envoyer un email à l'équipe de maintenance (voir `SECURITY.md` ou `AUTHORS`). + +--- + +## Licence + +En contribuant, vous acceptez que votre contribution soit sous la même licence que le projet (voir `LICENSE`). + +--- + +## Questions ? + +Ouvrez une discussion GitHub ou consultez `docs/architecture/` pour l'architecture technique. \ No newline at end of file diff --git a/docs/data-formats/index.md b/docs/data-formats/index.md new file mode 100644 index 0000000..83d95f4 --- /dev/null +++ b/docs/data-formats/index.md @@ -0,0 +1,7 @@ +# Formats de données + +- [Carte (YAML)](map.md) +- [Monstre (JSON)](monster.md) +- [Fiches de personnages PJ](pc-sheets.md) + +Chaque format est documenté en détail dans son fichier respectif. \ No newline at end of file diff --git a/docs/data-formats/map.md b/docs/data-formats/map.md new file mode 100644 index 0000000..3ba4528 --- /dev/null +++ b/docs/data-formats/map.md @@ -0,0 +1,87 @@ +# Format de 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 +``` + +## Détail des champs + +### `square_size_ft` +Taille d'une case en pieds (entier > 0). Par défaut 5 ft (1,5 m). + +### `terrain` (obligatoire) +Bloc ASCII représentant le terrain. Chaque ligne = une rangée de cases. + +### `legend` +Dictionnaire associant chaque caractère du terrain à ses propriétés : +- `type` : identificateur du type de terrain +- `move_cost` : entier > 0 pour terrain praticable, `null` pour infranchissable (mur, pilier) +- `blocks_los` : booléen, bloque la ligne d'effet +- `cover` : booléen, couvert standard (+4) +- `cover_bonus` : optionnel, niveau de couvert (2 = partiel, 4 = standard, 8 = amélioré) + +**Important** : omettre `move_cost` équivaut à `null` (infranchissable). Un terrain praticable doit **toujours** déclarer explicitement son coût. + +### `elevation` (optionnel) +Chiffres = hauteur de sol (marche). Monter d'un niveau coûte +1 case de mouvement par niveau ; la descente est gratuite. Une créature plus haute que sa cible gagne +1 aux jets d'attaque de mêlée et à la confirmation de critique (high ground). La ligne d'effet reste 2D. + +### `zones` (optionnel) +Lettres de zone de déploiement. Chaque lettre = une zone distincte. + +### `markers` (optionnel) +Points nommés (validés à la lecture, pas encore utilisés en jeu). + +### `deployment` (obligatoire si `zones` présent) +Mapping nom de camp -> lettre de zone. Chaque camp du déploiement doit être fourni via `--side`, et toutes les zones doivent être couvertes. Deux camps ne peuvent pas partager la même lettre de zone. + +## 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. + +## Vol et terrain + +Une créature **aérienne** (vol ≥ sol) ignore les coûts de `move_cost` > 1 et **enjambe** les cases `move_cost: null` qui ne portent **pas** `blocks_los` (fosses, eau, gravats, piliers non bloquants). Une case `blocks_los` (barrière solide, hauteur supposée pleine) l'arrête aussi. La règle des coins suit la même vue : un aérien peut passer en diagonale au ras d'une fosse mais pas au ras d'un coin de mur. \ No newline at end of file diff --git a/docs/data-formats/monster.md b/docs/data-formats/monster.md new file mode 100644 index 0000000..ca7df03 --- /dev/null +++ b/docs/data-formats/monster.md @@ -0,0 +1,117 @@ +# Format de 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 + } + ] +} +``` + +## Champs principaux + +| Champ | Type | Requis | Description | +|-------|------|--------|-------------| +| `name` | string | oui | Nom du monstre | +| `level` | int | oui | Niveau / HD | +| `size` | string | oui | `Tiny`, `Diminutive`, `Fine`, `Small`, `Medium`, `Large`, `Huge`, `Gargantuan`, `Colossal` | +| `cr` | string | oui | Challenge Rating (ex: `"1/3"`, `"5"`) | +| `xp` | int | oui | Points d'expérience | +| `source` | string | oui | Source (ex: `"Bestiary > Goblin"`) | +| `abilities` | object | oui | 6 scores : `str_score`, `dex_score`, `con_score`, `int_score`, `wis_score`, `cha_score` | +| `hp_max` | int | oui | PV maximum | +| `ac` | object | oui | `{ total, touch, flat_footed }` — valeurs **finales** (bonus de taille déjà inclus) | +| `bab` | int | oui | Base Attack Bonus | +| `initiative_mod` | int | oui | Modificateur d'initiative | +| `speed_land_ft` | int | oui | Vitesse au sol en ft | +| `speed_fly_ft` | int | non | Vitesse de vol en ft (si absent = pas de vol) | +| `saves` | object | oui | `{ fort, ref, will }` — jets de sauvegarde finaux | +| `attacks` | array | oui | Liste des attaques (voir ci-dessous) | + +## Attaques (`attacks[]`) + +Chaque attaque est un objet avec les champs suivants : + +| Champ | Type | Requis | Description | +|-------|------|--------|-------------| +| `name` | string | oui | Nom de l'arme | +| `kind` | string | oui | `"melee"`, `"ranged"`, ou `"touch"` | +| `attack_bonus` | int | oui | Bonus d'attaque (final, taille incluse) | +| `damage` | array | oui | Liste de composantes : `{ "formula": "1d4", "types": ["slashing"] }` | +| `crit_range` | int | non | Menace de critique (défaut 20) | +| `crit_mult` | int | oui | Multiplicateur de critique (2, 3, etc.) | +| `range_increment_ft` | int | non* | Incrément de portée en ft (> 0), requis pour `ranged` et `touch` | +| `damage_bonus` | int | non | Bonus de dégâts plat | +| `dr` | string | non | Type de contournement de RD (ex: `"magic"`, `"cold_iron"`) | +| `two_handed` | bool | non | Arme à deux mains (Power Attack +3X au lieu de +2X) | +| `reach_weapon` | bool | non | Arme à allonge (anneau, dead zone) | +| `reach_ft` | int | non | Allonge naturelle (pour attaques naturelles) | +| `natural` | bool | non | Attaque naturelle (pas d'itératifs, peut être `secondary`) | +| `secondary` | bool | non | Attaque naturelle secondaire (BAB −5, Multiattack → −2) | +| `firearm` | bool | non | Arme à feu (touch dans 1er incrément, misfire sur 1 naturel) | +| `splash` | object | non | Arme à éclaboussures : `{ "damage": 1, "type": "fire" }` | +| `spell_resistance` | int | non | Résistance aux sorts | +| `resistances` | object | non | Résistances énergétiques : `{ "fire": 5 }` | +| `immunities` | array | non | Immunités : `["fire", "poison"]` | + +* `range_increment_ft` : entier **strictement positif** (zéro ou négatif = erreur). Pénalité −2 par incrément complet au-delà du premier. Arme `ranged` utilisable jusqu'à 10 incréments, `touch` jusqu'à 5. + +## Champs optionnels avancés + +- `carried_items` : array d'objets transportés non maniés/portés serré (volables via manœuvre *Steal*). Armes maniées = dans `attacks` ; équipement porté serré (armure, sac à dos, anneaux) exclus. +- `aware` : bool (défaut `true`) — conscience au début du combat (règle *Surprise*). +- `alignment` : code à deux axes (`LG`, `NG`, `CG`, `LN`, `N`, `CN`, `LE`, `NE`, `CE`), défaut `"N"`. +- `spell_slots` : dict niveau → nombre (ex: `{"1": 4, "2": 3}`) — budget d'encounter, **pas** calculé depuis la caractéristique. +- `spells` : array de noms de sorts connus (doivent exister dans `data/spells/`). +- `metamagic_feats` : array de noms de dons de métamagie connus. +- `features` : noms de capacités de classe / dons (résolus par le chargeur). + +## Taille et allonge + +| Taille | Allonge naturelle | Footprint | +|--------|-------------------|-----------| +| Fine / Diminutive / Tiny | 0 ft | 1×1 | +| Small / Medium | 5 ft | 1×1 | +| Large | 10 ft | 2×2 | +| Huge | 15 ft | 3×3 | +| Gargantuan | 20 ft | 4×4 | +| Colossal | 30 ft | 6×6 | + +Une **arme à allonge** (`reach_weapon: true`) double l'allonge naturelle mais crée une **dead zone** (ne frappe pas à l'allonge naturelle ou moins). Une **arme naturelle** avec `reach_ft` explicite conserve cette allonge (le `reach_ft` d'une attaque naturelle EST son allonge naturelle). + +## Notes importantes + +- Les valeurs `ac` et `attack_bonus` des JSON sont des valeurs **FINALES**, déjà majorées du modificateur de taille PF1e. Le moteur n'applique **jamais** de bonus de taille aux jets d'attaque ni à la CA. +- La taille n'intervient que pour : manœuvres (CMB/CMD), allonge naturelle, footprint, occupation. +- Les JSON de `data/monsters/` et `data/pcs/` sont la référence. Le chargeur `loaders/foundry.py` est un outil de migration **one-shot et incomplet** — ne pas régénérer les JSON de PJ sans réappliquer les corrections SRD (voir `docs/rules/conformance-srd.md`). \ No newline at end of file diff --git a/docs/data-formats/pc-sheets.md b/docs/data-formats/pc-sheets.md new file mode 100644 index 0000000..1992e95 --- /dev/null +++ b/docs/data-formats/pc-sheets.md @@ -0,0 +1,53 @@ +# Fiches de personnages (PJ) + +Les PJ sont définis au format JSON (schéma `Combatant`) dans `data/pcs/`. Huit fiches +sont fournies : `esha.json`, `harvie.json`, `ierlieth.json`, `jeanne.json`, +`misty.json`, `nairda.json`, `oni.json`, `tammara.json`. + +Elles 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 +``` + +## Format JSON + +Le schéma est identique aux monstres (voir [`monster.md`](monster.md)), avec +ces particularités PJ : + +- `source` : pointe vers la fiche Foundry VTT d'origine sous `fiches_personnages/` + (ex: `"fiches_personnages/oni.foundry.json"`). +- `spell_slots` : budget d'encounter (pas progression RAW — voir note ci-dessous). +- `spells` : noms de sorts connus (doivent exister dans `data/spells/`). +- `features` : noms de capacités de classe / dons (résolus par le chargeur). +- `carried_items` : objets volables (filtrés depuis l'inventaire Foundry per APG *Steal*). + +## Note importante : `spell_slots` = budget d'encounter + +Le champ `spell_slots` est une valeur **finale** par encounter. Le moteur ne calcule +**jamais** les sorts bonus de caractéristique d'incantation élevée (SRD Table 1-3). +Les éventuels sorts bonus doivent être **encodés dans le JSON par l'auteur**. + +Preuve : les fiches PJ actuelles encodent la table de classe de BASE sans bonus. +Exemple : Oni (clerc 4, SAG 18) → `{"1":4, "2":3}` = table de base 4/3, +alors que le RAW avec bonus serait 5/4. Le liche (INT 20, niveau 11) porte +`{"1":4, "2":4, "3":4, "4":4, "5":4, "6":3}` qui ne correspond à AUCUNE table +RAW — les slots sont donc un budget d'encounter authored, pas une progression RAW. + +Conséquence assumée : les PJ incanteurs tiennent moins de sorts par encounter +que le RAW ne le permettrait. + +## Fiches Foundry (source) + +`fiches_personnages/` contient les 8 fiches au format Foundry VTT +(`pf1-sheet/v1`) ; le chargeur `loaders/foundry.py` sait les lire. C'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 sabots Jeanne 10 ft, vol Ierlieth 40 ft, morsure Misty 1d6, dons au-delà +de 8 entrées, `spell_slots`/`resources`/`casting_ability`). + +> **`data/pcs/*.json` est la référence canonique des PJ.** Ne pas régénérer +> depuis `foundry.py` sans réappliquer les corrections SRD consignées dans +> [`docs/rules/conformance-srd.md`](conformance-srd.md). \ No newline at end of file