Files
pf1e-simulator/README.md
T
ctan be2a755d17 fix(rules): stop damage reduction from eating energy damage
PF1e (CRB Damage Reduction): "Damage reduction does not negate ...
energy damage dealt along with an attack." _resolve_hit_damage now
splits the post-crit total and applies DR only to the non-energy share
(physical dice + flat bonuses + precision dice); energy components are
reduced solely by the per-component energy resistance/immunity step.
DR-bypass semantics (multi-component "and" requires every component)
are unchanged. Pure-physical weapons keep bit-identical arithmetic and
the golden transcripts are untouched.

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-11 23:17:34 +02:00

1283 lines
82 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# pf1e-simulator
Simulateur de combat Monte Carlo déterministe pour Pathfinder 1re édition.
Le moteur joue une rencontre définie (carte + camps) un grand nombre de fois —
chaque combat est reproductible grâce à un générateur aléatoire seedé — puis
produit un rapport d'équilibrage en français : taux de victoire par camp avec
bande de confiance 3σ, nuls, rounds moyens et attrition par combattant.
Phase 0 : le moteur de combat couvre le socle des règles (initiative, attaques,
critiques, RD, états de vie, déplacement, mêlée/distance). La couche LLM
(tactiques et rédaction du rapport) n'est pas encore implémentée.
## Fonctionnalités
- Carte en grille de cases de 5 ft (1,5 m) au format YAML : couches ASCII
`terrain`, `elevation`, `zones`, points nommés `markers`, déploiement par camp.
- Comptage des diagonales selon la règle 5-10-5, coûts de déplacement selon le
terrain, règle des coins pour les diagonales le long des murs.
- Monstres définis en JSON (bestiaire) ; combattants déployés dans les zones de
la carte ; ids dupliqués automatiquement désambiguïsés (`goblin`, `goblin-2`…).
- Combats déterministes : chaque run utilise un flux `SeededRng(seed + i)` ;
même seed ⇒ mêmes combats.
- Rapport d'équilibrage en français : taux de victoire + bande 3σ
(σ = √(p(1−p)/n)), nuls, rounds moyens, attrition moyenne par combattant
(touches, critiques, dégâts infligés/subis).
- Interface en ligne de commande `pf1e-sim`.
## Prérequis et installation
Prérequis :
- Python ≥ 3.12
- [uv](https://docs.astral.sh/uv/) (gestionnaire de paquets et d'environnements)
Installation :
```bash
git clone <url-du-dépôt> pf1e-simulator
cd pf1e-simulator
uv sync --dev # inclut pytest, ruff et basedpyright
```
`uv sync` seul suffit pour n'utiliser que la CLI (pas d'outillage de dev).
Vérification :
```bash
uv run pf1e-sim --help
```
## Démarrage rapide
La carte d'exemple `data/maps/sample_arena.yaml` et les monstres
`data/monsters/goblin.json` / `data/monsters/orc.json` permettent de lancer une
première simulation immédiatement. 3 gobelins (camp `players`) contre 2 orcs
(camp `monsters`), 1000 combats, seed 1 :
```bash
uv run pf1e-sim \
--map data/maps/sample_arena.yaml \
--side players data/monsters/goblin.json data/monsters/goblin.json data/monsters/goblin.json \
--side monsters data/monsters/orc.json data/monsters/orc.json \
--runs 1000 --seed 1
```
Sortie (extrait) :
```
=== Rapport d'équilibrage ===
Carte : Arène d'essai
players : 3 combatants
monsters : 2 combatants
1000 combats simulés (seed 1, plafond 100 rounds)
Victoires players : 91.4% (914) — bande 3σ : [88.7%, 94.1%]
Victoires monsters : 8.6% (86) — bande 3σ : [5.9%, 11.3%]
Nuls : 0.0% (0)
Rounds moyens : 6.0
```
## Utilisation de la CLI
```
usage: pf1e-sim [-h] --map MAP [--side NAME [FILE ...]] [--runs RUNS]
[--seed SEED] [--round-cap ROUND_CAP] [--transcript]
[--simple-monster-death]
```
| Option | Rôle | Défaut |
|---|---|---|
| `--map MAP` | Fichier YAML de la carte (obligatoire) | — |
| `--side NAME [FILE ...]` | Camp `NAME` avec un ou plusieurs JSON de monstres ; répétable pour chaque camp | — |
| `--runs RUNS` | Nombre de combats simulés | `1000` |
| `--seed SEED` | Graine du générateur aléatoire | `1` |
| `--round-cap ROUND_CAP` | Plafond de rounds par combat (au-delà, combat nul) | `100` |
| `--transcript` | Imprimer le déroulé du premier combat **en tableau** avant le rapport (voir ci-dessous) | désactivé |
| `--simple-monster-death` | Mort simplifiée des monstres : `hp ≤ 0` au lieu de `hp ≤ -CON` (les PJ ne sont jamais affectés) | désactivé |
Affichage du transcript (`--transcript`) : les lignes du moteur sont présentées
dans un tableau à trois colonnes alignées — `Round`, `Combattant`, `Message` —
précédé d'un en-tête et d'une ligne de séparation. Les largeurs de colonnes
sont calculées sur le contenu affiché et l'alignement tient compte de la
largeur d'affichage réelle des caractères (accents, CJK). Les lignes du
transcript qui ne suivent pas le format `round N id: message` (jets
d'initiative, fin de combat) restent affichées telles quelles, en ligne brute
intercalée à leur position d'origine — aucune entrée n'est jamais perdue ni
tronquée. Exemple :
```
=== Déroulé du premier combat ===
Round Combattant Message
----- ---------- ---------------------------------------------------------
initiative: orc d20=19+0=19
1 orc move (1,15)->(2,14)->(3,13)->(3,12)->(3,11)->(3,10)
1 goblin short bow vs orc d20=9+4=13 AC 17 -> MISS
battle over: players win in 4 rounds
```
Les données du transcript (contenu et ordre des lignes du moteur) ne sont pas
modifiées par cette présentation.
Codes de sortie :
- `0` — simulation terminée, rapport imprimé.
- `2` — erreur d'utilisation ou de données (message sur stderr) : côté
inconnu, côté de déploiement manquant, zone trop petite, fichier invalide…
Règles de construction d'une rencontre :
- Les noms de camps (`--side NAME`) doivent correspondre exactement à la
section `deployment` de la carte, et **tous** les camps du déploiement
doivent être fournis.
- Le même fichier monstre peut être répété pour créer plusieurs combattants
identiques ; les ids sont désambiguïsés globalement dans l'ordre de
déploiement (`goblin`, `goblin-2`, `goblin-3`).
- Les combattants d'un camp remplissent les cases de leur zone dans l'ordre
(ligne, colonne). Une zone trop petite pour le nombre de combattants est une
erreur.
## Interpréter le rapport
Pour chaque camp, le rapport donne le taux de victoire observé et une bande de
confiance à 3σ calculée en forme fermée (modèle binomial) :
- σ = √(p(1−p)/n) ; la bande est [p − 3σ, p + 3σ], bornée à [0, 1].
- Un affrontement symétrique (combattants identiques sur des zones en miroir)
doit rester proche de 50 % ; un écart hors de la bande 3σ signale un
déséquilibre (avantage d'initiative, géométrie, portées…).
- Une bande large indique un échantillon trop petit pour conclure — augmentez
`--runs`.
- L'attrition moyenne par combattant indique qui participe réellement : un
combattant à distance qui n'est jamais rejoint affiche des dégâts subis
proches de zéro, un combattant qui meurt systématiquement affiche des dégâts
subis supérieurs à ses PV maximum (dégâts du coup fatal inclus).
- Les nuls proviennent des combats atteignant le plafond de rounds (combattants
immobiles, camps inaccessibles…).
Exemple vérifié — 1 gobelin (players) contre 2 orcs (monsters), 1000 runs,
seed 1 : players 22,0 %, bande 3σ [18,1 % ; 25,9 %], 0 nuls, 5,2 rounds moyens.
Sans ligne de visée depuis la zone de départ, l'archer gobelin doit contourner
le mur central avant de tirer ; mais avec l'économie d'action activée, les orcs
parcourent toute leur vitesse (30 ft) puis frappent dans le même tour — le
gobelin solitaire ne peut plus les distancer et tombe vite en mêlée sous les
2d4+4 des falchions. C'est ce déséquilibre de cadence qui penche fortement le
face-à-face à 1 contre 2 du côté des monstres.
## Formats de données
### Carte (YAML)
Chaque couche est un bloc ASCII de mêmes dimensions ; un caractère = une case
de `square_size_ft` pieds. Seule `terrain` est obligatoire.
```yaml
name: "Arène d'essai"
square_size_ft: 5
terrain: | # couche obligatoire
####################
#......######......#
#..C...######...C..#
#......~..~........#
#..TT..~..~...TT...#
#......~..~........#
#..C............C..#
####################
legend: # un caractère = un type de terrain
"#": { type: wall, move_cost: null, blocks_los: true } # bloque déplacement + LoS
".": { type: floor, move_cost: 1 }
"T": { type: rubble, move_cost: 2 } # terrain difficile
"~": { type: water, move_cost: 2 }
"C": { type: pillar, move_cost: null, cover: true } # bloque le pas, couvert (+4)
"W": { type: ruin, move_cost: null, cover_bonus: 2 } # couvert partiel (+2) ; 4 = standard, 8 = amélioré
elevation: | # optionnel : chiffres = hauteur (appliqué : coût de pas + high ground)
....................
..1111..............
..1111..............
....................
zones: | # optionnel : lettres de zone de déploiement
....................
.AAAA..........BBBB.
.AAAA..........BBBB.
....................
markers: # optionnel : points nommés (validés, pas encore utilisés)
autel: [1, 16]
deployment: # obligatoire si zones : nom de camp -> lettre de zone
players: A
monsters: B
```
Valeur du `move_cost` : entier > 0 pour un terrain praticable, `null` pour un
terrain infranchissable (mur, pilier). `blocks_los` et `cover` sont appliqués
par la résolution des attaques (module `los.py`, règle des coins) : pas de
ligne d'effet ⇒ l'attaque est impossible ; couvert ⇒ bonus de CA. Le champ
optionnel `cover_bonus` (`2`, `4` ou `8`) encode le niveau de couvert PF1e
(partiel +2, standard +4, amélioré +8) ; `cover: true` sans `cover_bonus`
vaut couvert standard (+4), idem pour un terrain `blocks_los`.
### Monstre (JSON)
```json
{
"name": "Goblin",
"level": 1,
"size": "Small",
"cr": "1/3",
"xp": 135,
"source": "Bestiary > Goblin",
"abilities": {
"str_score": 11, "dex_score": 15, "con_score": 12,
"int_score": 10, "wis_score": 9, "cha_score": 6
},
"hp_max": 6,
"ac": { "total": 16, "touch": 13, "flat_footed": 14 },
"bab": 1,
"initiative_mod": 6,
"speed_land_ft": 30,
"saves": { "fort": 3, "ref": 2, "will": -1 },
"attacks": [
{
"name": "short sword",
"kind": "melee",
"attack_bonus": 2,
"damage": [{ "formula": "1d4", "types": ["slashing"] }],
"crit_range": 19,
"crit_mult": 2
},
{
"name": "short bow",
"kind": "ranged",
"attack_bonus": 4,
"damage": [{ "formula": "1d4", "types": ["piercing"] }],
"crit_mult": 3,
"range_increment_ft": 60
}
]
}
```
Champ `range_increment_ft` (optionnel, uniquement pour `kind: ranged` ou
`kind: touch`) : incrément de portée en pieds. La pénalité de portée vaut −2
par incrément complet au-delà du premier ; une arme `ranged` reste utilisable
jusqu'à 10 incréments, une arme `touch` jusqu'à 5 (jet/firearm), au-delà elle
n'est pas sélectionnable. `damage_bonus` et `dr` sont optionnels.
Le booléen `two_handed` (optionnel, défaut `false`) marque une arme tenue à
deux mains : Power Attack donne alors +3X (et non +2X) aux dégâts (conformance
B16). Le booléen `reach_weapon` (optionnel, défaut `false`) marque une arme
dotée de la qualité « reach » du CRB (lance, glaive, longspear, etc.) : elle
double l'allonge naturelle du porteur mais ne frappe pas à cette allonge
naturelle ou moins — anneau avec dead zone (conformance R1, voir « Mêlée »).
Le `reach_ft` d'une attaque naturelle (morsure, sabots) n'est **pas** une arme
à allonge : il encode l'allonge naturelle de la créature, qui reste un disque.
`spell_resistance` (entier), `resistances` (dict type → montant) et
`immunities` (liste de types) sont optionnels.
Deux monstres d'exemple sont fournis : gobelin et orc.
### Fiches de personnages (PJ)
`fiches_personnages/` contient les 8 fiches de PJ au format Foundry VTT
(`pf1-sheet/v1`) ; le chargeur `loaders/foundry.py` sait les lire. Elles sont
transposées en JSON au schéma `Combatant` dans `data/pcs/` (`esha.json`,
`harvie.json`, `ierlieth.json`, `jeanne.json`, `misty.json`, `nairda.json`,
`oni.json`, `tammara.json`) et se passent directement à la CLI comme les
monstres :
```bash
uv run pf1e-sim --map data/maps/sample_arena.yaml \
--side players data/pcs/oni.json \
--side monsters data/pcs/tammara.json --runs 200
```
Les armes placeholder des fiches sans dégâts encodés sont omises (notées
dans `notes` le cas échéant) ; le champ `source` de chaque fichier pointe
vers la fiche Foundry d'origine.
## Règles implémentées et limites de la Phase 0
Règles modélisées :
- 1 naturel = échec automatique ; 20 naturel = touche + menace de critique.
- Critique : (dés + bonus) × multiplicateur ; le 20 naturel au jet de
confirmation confirme automatiquement la critique (conformance P2 — le jet
de confirmation est un jet d'attaque) ; la RD s'applique après
multiplication, un type de contournement annule la RD — une RD à plusieurs
composants (« DR X/Y and Z », conformance P2) exige que l'attaque porte
TOUS les composants (le contournement n'est plus traité comme un OU), les
dégâts plancher à 0.
- Critique améliorée (« Improved Critical ») : le don double la plage de
menace — plage effective = `21 - 2 × (21 - crit_range)` (20 → 19-20,
19 → 17-20, 18 → 15-20). Il n'affecte que le jet de menace, jamais le jet de
confirmation. Simplification moteur : le don s'applique à toutes les armes
de l'attaquant (pas de sélection par type d'arme comme sur table).
- Mourir et stabilisation : une créature à `hp < 0` mais pas morte est
mourante — inconsciente (inactive), elle perd 1 pv par tour si elle échoue
son jet de Constitution DD 10 à son tour d'initiative : `d20 + mod de CON −
|pv négatifs|` contre 10 ; un 20 naturel réussit automatiquement. En cas d'échec, elle
perd 1 pv de plus ; en cas de réussite, elle est stabilisée (plus de perte de
pv, reste inconsciente). Une nouvelle blessure sous le seuil de stabilisation
(`hp < stabilized_hp`) fait reprendre l'agonie (nouveau jet) ; un soin
ramenant à `hp ≥ 0` réinitialise la stabilisation pour toute chute future.
La mort survient quand les pv négatifs atteignent le score de CON
(`hp ≤ -CON`) — conformance P2 : la règle du −10 de D&D 3.5 n'existe pas
en PF1e et n'est plus modélisée. Une créature mourante qui subit des dégâts
continus (saignement) perd ses dégâts de saignement au début de son tour,
puis **échoue automatiquement** son jet de stabilisation (aucun d20 jeté) et
perd donc 1 pv de plus par tour en plus des dégâts continus (CRB « Stable
Characters and Recovery ») — elle meurt ainsi plus vite qu'une créature
mourante non saignante.
- Mort simplifiée des monstres (option `--simple-monster-death`, défaut
désactivé) : quand l'option est présente dans la commande, un **monstre**
meurt dès `hp ≤ 0` — ni bande d'invalidité à 0 pv, ni bande d'agonie entre
0 et `-CON`. Les **personnages joueurs ne sont jamais affectés**, quel que
soit le réglage : ils gardent la règle PF1e complète (`hp ≤ -CON` pour
mourir, invalidité à 0 pv, agonie et stabilisation en dessous). La
distinction monstre/PJ repose sur une seule marque fiable issue du
chargeur : le champ `source` des JSON de PJ pointe vers la fiche Foundry
d'origine sous `fiches_personnages/` (convention documentée plus bas),
alors que les JSON de monstres portent des sources « Bestiary », « summon
monster » ou « bestiary » ; le nom de camp (`--side`) ne peut pas servir
de critère puisqu'il est choisi par l'utilisateur et que la carte d'exemple
oppose des monstres déployés côté `players`. Sans l'option, le comportement
du moteur est bit-identique à celui d'aujourd'hui (transcripts dorés
intacts). Équivalent Python : `run_encounter(..., monsters_die_at_zero=True)`
et `CombatEngine(..., monsters_die_at_zero=True)`.
- Invalide (conformance P2, CRB « Effects of Death ») : à `hp = 0`
exactement, une créature est **invalide** — elle reste active mais
**staggered** (une action de mouvement OU une action standard par tour, pas
d'action à round complet, mais les actions swift, libre et immédiate restent
permises — CRB « Staggered »), se déplace à **moitié vitesse**, et achever une
action non-mouvement (standard ou pénible) lui coûte **1 pv** à la fin de
cette action (pv → −1, agonie). Les actions de mouvement — dont
`retrieve_weapon` (sortir/récupérer une arme) et `repair_weapon` (raccommoder
une arme brisée), classées mouvement — ne risquent pas de blessure
supplémentaire et coûtent 0 pv (CRB « Disabled »). Simplification documentée :
si l'action elle-même soigne la créature au-dessus de 0 pv, ce coût n'est pas
appliqué.
- `hp < 0` = inactive (mourante ou morte) ; `hp = 0` = active mais invalide.
- Initiative : triée sur (total, modificateur, ordre de liste), sans re-jet.
- Économie d'action : un tour normal donne une action standard + une action de
mouvement (ou une action à round complet), plus swift/free/immediate. La
politique renvoie une séquence ordonnée d'actions exécutées dans l'ordre ;
`default_policy` renvoie `(full_attack,)` si l'ennemi est en portée, `(charge,)`
si une ligne droite existe, `(5foot_step, full_attack)` si un seul pas
suffit à entrer en portée, ou `(move, attack)` sinon. Les actions immédiates
(hors-tour, consomment le prochain swift) 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) ;
**mouvement** = `move`, `stand_up`,
`retrieve_weapon` (sortir une arme est une action de mouvement),
`repair_weapon` ; **swift** = `swift`, `deed`,
`inspire_courage` ; **round complet** = `full_attack`, `charge`, `withdraw`,
`full_round`, `spell_combat` (sort + attaque à outrance dans le même round),
`run` (round complet en RAW — il consacre le standard ET le mouvement, tout
en posant `moved_this_turn` et `running`) ;
**libre/conditionnel (hors budget)** = `wait`, `free`, `immediate` (hors-tour ;
il consomme le PROCHAIN swift via `immediate_used`, pas les compartiments du
tour courant), `5foot_step` (pas libre, exclusif avec tout autre mouvement via
`moved_this_turn`), `drop_prone`, `activate_power_attack` et `fight_defensively`
(des déclarations de mode appliquées à l'attaque qui les suit, comme le
basculement Power Attack). Ordre de composition : le pré-filtre des temps
d'incantation « longs » est appliqué EN PREMIER (voir « Temps d'incantation »
ci-dessous), puis le budget général, puis les filtres spécialisés
(immédiat/accablé/nausée) s'exercent sur le reste déjà légal (leur sortie est
une action unique, donc jamais re-filtrée par le budget). L'ordre de
dégradation est celui de la politique : la première action légale d'un
compartiment gagne, les suivantes sont excédentaires.
- Temps d'incantation (`SpellSpec.cast_time`, conformance économie d'action) :
chaque sort porte un temps d'incantation normalisé en codes de budget —
`standard`, `swift`, `immediate`, `free`, `full_round`, `one_round`, `long`
(défaut `standard`). Le chargeur JSON accepte aussi les formulations RAW :
`"1 round"`/`"one round"` → `one_round`, `"1 full-round action"`/
`"full-round action"`/`"full-round"` → `full_round`, `"1 minute"`/
`"10 minutes"`/`"1 hour"`/`"long"` → `long`, `"standard action"`,
`"swift action"`, `"immediate action"`, `"free action"` ; une valeur
inconnue est une erreur de données (`ValueError`). Mapping vers le budget :
`standard` → standard ; `swift` → swift (il concurrence donc deed/inspire
courage et est aussi retiré quand le swift est consommé par une immédiate,
via `immediate_used`) ; `immediate` et `free` → libre (l'immédiate reste
hors-tour et consomme le prochain swift) ; `full_round` → round complet ;
`one_round` → **round complet par clamp** — approximation documentée : le
moteur ne tient pas d'incantation en cours entre les tours, le sort
d'1 round se résout au bout du tour courant au lieu du suivant ;
`long` (> 1 round : 3 rounds, 10 minutes, 1 heure — cast multi-round non
planifiable dans un tour) → **jeté avant le filtre de budget**, rapporté comme
`ActionResult(kind="cast_spell", ok=False, reason="downgraded:cast_time")`
(seule `default_policy` s'en préserve : `_choose_spell_action` ne sélectionne
jamais un sort à temps long). Le moteur résout le code via sa registry
(`_cast_time_of`, repli `standard` pour un nom inconnu) et le passe aux
filtres purs sous forme de résolveur `CastTimeResolver`. Les 107 sorts
importés de `data/spells/` déclarent tous leur `cast_time` (codes normalisés,
backfillés depuis le SRD) : 95 `standard`, 6 `one_round` (Enhance Water,
Silence, Sleep, Summon Instrument, Summon Monster I/II), 2 `immediate`
(Grasp, Saving Finale) et 4 `long` (Make Whole, Masterwork Transformation,
Mending, Restoration Lesser). Hors du périmètre (hook futur, commenté dans
`_cast_time_of`) : la métamagie d'incanteur spontané qui allonge le temps
d'incantation à un round complet sans don Quickened — la signature
`Callable[[Action], CastTime]` du résolveur l'accommodera sans changement.
- Retard (`Action(kind="delay")`, action standard) : le combattant renonce à
agir maintenant et agit plus tard dans le **même round** — en fin d'ordre si
`target_id` est absent, sinon juste avant le combattant désigné. La nouvelle
position est encodée dans la clé d'initiative par un total égal à celui de la
cible − 0,5 (ou au minimum des totaux − 0,5 sans cible) et **persiste aux
rounds suivants** ; le reste du tour est perdu, un second retard dans le même
round est rejeté (`already_delayed`), et le drapeau se réinitialise au début
de chaque round. Le tour réinséré plus tard dans le même round **ne rejoue
pas le début de tour** : la décrément des durées (buffs/conditions), les
dégâts continus (saignement), l'effet de terreur et les réinitialisations de
drapeaux par tour ne s'appliquent qu'une fois par round (le tour retardé garde
néanmoins son appel de politique, son budget et son exécution normaux).
`default_policy` ne choisit pas le retard (disponible via
une politique personnalisée).
- Préparation (`Action(kind="ready", ready_trigger=...)`, action standard) : le
combattant se prépare à agir quand un événement déclencheur survient. Le
payload est encodé dans les champs porteurs de l'action, comme l'immédiate :
`spell_name` → incantation, `maneuver` → manœuvre (avec `direction` /
`trick_condition`), sinon attaque simple sur `target_id` (les capacités de
classe en payload ne sont pas modélisées). Quatre déclencheurs, mappés aux
événements de réaction existants : `enemy_enters_reach` ← pas de mouvement
d'un ennemi (simplification documentée : la portée menacée n'est pas
recalculée, tout pas ennemi déclenche), `enemy_casts` ← incantation d'un
ennemi, `enemy_attacks` ← attaque résolue d'un ennemi, `enemy_hits_ally` ←
dégâts subis par un allié du préparant infligés par un ennemi. Quand
l'événement correspond, le moteur exécute le payload **hors-tour, sans coût
de swift** (au plus une action préparée par événement, sans cascade), puis
l'action préparée est **consommée** (one-shot). Une nouvelle préparation
remplace la précédente ; le reste du tour (action de mouvement) reste
exécutable. Le snapshot expose l'état via la clé `readied`
(`{kind, trigger}` ou `None`). `default_policy` ne se prépare pas
(disponible via une politique personnalisée).
- Défense totale (`Action(kind="total_defense")`, action standard) : le
combattant se couvre entièrement et gagne **+4 d'esquive à la CA** jusqu'au
début de son prochain tour (champ runtime `defensive_ac_bonus`, réinitialisé
avec les autres drapeaux de tour). Le kind ne requiert pas de cible.
Simplification documentée : la progression d'Acrobatie (+6 avec 3 rangs)
n'est pas modélisée. `default_policy` ne la choisit pas (disponible via une
politique personnalisée).
- Combat défensif (`Action(kind="fight_defensively")`, action standard) : le
combattant se bat avec prudence — **−4 à ses jets d'attaque et de
confirmation de critique**, et **+2 d'esquive à la CA** jusqu'au début de son
prochain tour (drapeaux runtime `fight_defensively` + `defensive_ac_bonus`).
Simplification documentée : la progression d'Acrobatie (−2 / +3 avec 3
rangs) n'est pas modélisée. `default_policy` ne le choisit pas (disponible
via une politique personnalisée).
- Se relever (`Action(kind="stand_up")`, action de mouvement) : le combattant à
plat ventre (renversé par trip/overrun ou après s'être jeté à terre) retire la
condition `prone` des **deux** représentations (`active_conditions` durée,
`conditions` sans durée) et redevient valide. Le relèvement **provoque une
attaque d'opportunité** de chaque ennemi menaçant (résolue via la mécanique de
mouvement `_check_move_aoo`, pendant qu'il est encore à plat ventre, CA −4).
Sans condition `prone`, l'action est rejetée (`not_prone`). Le kind ne requiert
pas de cible. `default_policy` ne la choisit pas (disponible via une politique
personnalisée).
- Se jeter à terre (`Action(kind="drop_prone")`, action libre) : le combattant
ajoute `prone` à la liste `conditions` **sans durée**, donc la condition
persiste jusqu'à `stand_up` (contrairement à trip/overrun qui posent
`active_conditions` avec 1 round). Les modificateurs de `prone` s'appliquent
immédiatement : l'attaquant à plat ventre subit −4 à ses attaques (de mêlée
selon le CRB ; les armes à distance sont traitées comme des arbalètes —
utilisables à −4, simplification documentée), la cible à plat ventre subit
−4 CA contre les attaques de mêlée et gagne +4 CA contre les attaques à
distance. Si la condition est déjà
présente, l'action est rejetée (`already_prone`). Le kind ne requiert pas de
cible. `default_policy` ne la choisit pas (disponible via une politique
personnalisée).
- Aider autrui (`Action(kind="aid_another", aid_mode=...)`, action standard,
U5e) : l'aidant **doit avoir une arme de mêlée utilisable** ; il fait un jet
d'attaque `d20 + bonus d'attaque de sa première arme de mêlée utilisable`
contre le CA 10 (20 naturel réussit toujours, 1 naturel échoue toujours —
convention des jets d'attaque du moteur). Sur succès, la cible empile **+2**
soit à son prochain jet d'attaque (`aid_mode="attack"` ou absent — consommé
one-shot par `resolve_attack`, attaque et confirmation de critique), soit à
sa CA contre la prochaine attaque la ciblant (`aid_mode="ac"`). Plusieurs
aides s'additionnent (le bonus s'ajoute, ne se remplace pas) ; tout bonus
inutilisé expire au début du prochain tour du bénéficiaire. Simplifications
documentées : pas de vérification d'adjacence/ligne d'effet vers l'allié,
toute cible résolue peut être aidée, et la progression de compétence d'aide
(+5 avec 5 rangs, +8 avec 10) n'est pas modélisée. Sans cible →
`missing_target` ; sans arme de mêlée utilisable → `no_weapon`.
`default_policy` ne la choisit pas (disponible via une politique
personnalisée).
- Retour d'exécution des actions : chaque action produit un `ActionResult`
explicite (kind, ok, reason) consigné dans le snapshot (`last_turn_results`
au tour suivant). Les actions invalides sont rejetées et journalisées au lieu
d'être des no-op silencieux : cible absente → `missing_target` ; kinds
déclarés mais non implémentés (`swift`, `free`, `full_round` — leur
implémentation générique reste à faire) → `unsupported_kind` ; dégradation
par le budget général d'action (X4 — action excédentaire retirée de la
séquence de la politique), par le pré-filtre des temps d'incantation longs
(sort `long` jamais planifiable dans un tour) ou par les filtres
staggered/nauseated (ou swift consommé par une immédiate) →
`downgraded:action_economy` / `downgraded:cast_time` /
`downgraded:staggered` / `downgraded:nauseated` /
`downgraded:immediate_swift` ;
retard valide → `ok=True, reason="delayed"`, second retard du même round →
`ok=False, reason="already_delayed"` ; préparation valide →
`ok=True, reason="ready"` ; relèvement sans `prone` → `ok=False,
reason="not_prone"` ; se jeter à terre déjà `prone` → `ok=False,
reason="already_prone"` ; attaque avec un `weapon_id` choisi inutilisable →
`ok=False, reason="weapon_unavailable"` ; incantation avec un `target_pos`
hors carte ou sans ligne d'effet → `ok=False, reason="invalid_destination"`
(U7b) ; incantation sans emplacement disponible au niveau requis — niveau
de base ou majoré par la métamagie (B7) → `ok=False, reason="no_slot"` ;
course ou aide sans cible **et** sans destination →
`missing_target` ; aide sans arme de mêlée utilisable → `no_weapon` ;
course vers une `destination` hors du budget 4× →
`unreachable_destination` (U5e).
- Attaques multiples par action : une arme avec `count` > 1 (« 2x Talons »)
résout `count` balayages indépendants dans la même action ; les balayages
restants sont perdus si la cible tombe (inconsciente ou morte) en cours de
rafale. Attaque à outrance (action à round complet) : à BAB +6/+11/+16, des
attaques supplémentaires à -5/-10/-15 ; les armes naturelles (`count` > 1)
ne gagnent pas d'itératifs. Plusieurs armes naturelles en une seule attaque
à outrance ne sont pas modélisées.
Choix de l'arme : `Action(kind="attack", weapon_id=...)` et
`Action(kind="full_attack", weapon_id=...)` frappent avec l'arme désignée
(l'arc plutôt que l'épée, l'arme principale plutôt que la secondaire) au
lieu de l'arme auto-sélectionnée par `weapon_for` (première arme utilisable
capable de toucher la cible à cette distance avec ligne d'effet).
L'`id` doit désigner une arme utilisable (non brisée, non volée) pouvant
atteindre la cible ; sinon l'action est rejetée (`weapon_unavailable`) et
aucun jet n'est effectué. Sans `weapon_id`, la sélection automatique est
inchangée. `default_policy` ne choisit pas d'arme (disponible via une
politique personnalisée).
- Mêlée (conformance R1, CRB « Big and Little Creatures in Combat ») :
l'**allonge naturelle** du gabarit (Small/Medium 5 ft, Large 10 ft, Huge
15 ft, Gargantuan 20 ft, Colossal 30 ft) est un **disque** : la créature
frappe et menace toutes les cases jusqu'à son allonge, l'adjacence
comprise — une créature Large dont l'attaque naturelle a `reach_ft` 10
menace donc aussi les cases adjacentes (le `reach_ft` d'une attaque
naturelle EST son allonge naturelle). Une **arme à allonge**
(`reach_weapon: true` dans le JSON) est un **anneau** : elle double
l'allonge naturelle du porteur mais ne frappe pas à l'allonge naturelle ou
moins — la zone utilisable est `allonge naturelle < distance ≤ 2 ×
allonge naturelle`, la dead zone vaut donc 5 ft pour un porteur
Small/Medium et 10 ft pour un porteur Large. Une arme ordinaire sans
allonge menace les cases adjacentes (disque de 5 ft).
Mouvement : un déplacement parcourt jusqu'à la
vitesse du combattant en cases le long du plus court chemin réel (champ de
coût Dijkstra depuis la cible, diagonales 5-10-5), en s'arrêtant adjacent à
l'ennemi le plus proche — les combattants contournent les murs au lieu
d'osciller contre eux.
Vitesse de déplacement (V4) : le budget de tout mouvement (déplacement,
charge 2×, retraite 2×, course 4×) est le **meilleur entre la vitesse au
sol (`speed_land_ft`) et la vitesse de vol (`speed_fly_ft`)** — une créature
dotée d'une vitesse de vol (harvie : vol 80 vs sol 30, ierlieth : vol 40)
se déplace donc à sa vitesse de vol. Simplification documentée : voler
PAR-DESSUS les obstacles ou le terrain difficile n'est PAS modélisé — la
praticabilité et les coûts de terrain de la grille s'appliquent à tout le
budget (pas de vol 3D ni de maniabilité) ; le snapshot expose ce budget
sous la clé additive `speed_effective_ft`.
Destination explicite : `Action(kind="move", destination=(r, c))` et
`Action(kind="5foot_step", destination=(r, c))` déplacent le combattant vers
la case `(r, c)` désignée (monter au couvert, quitter un défilé, se
désengager) au lieu de auto-diriger vers `target_id`. Pour `move`, la case
doit être praticable, libre et atteignable dans le budget de vitesse (sinon
rejet `unreachable_destination`) ; le trajet réutilise la mécanique pas à pas
avec provocation d'AoO. Pour `5foot_step`, la case doit être exactement
adjacente (sinon `unreachable_destination`) et le pas ne provoque jamais
d'AoO. Sans `destination`, le comportement auto-dirigé est inchangé ; avec
`target_id` et `destination` absents, rejet `missing_target` (U1).
`default_policy` ne choisit pas de destination (disponible via une politique
personnalisée).
- Tailles et occupation multi-cases : une créature occupe un footprint carré à
son ancre (`pos`) selon sa taille — Small/Medium 1×1, Large 2×2, Huge 3×3,
Gargantuan 4×4, Colossal 6×6 (taille inconnue → 1×1). Distance, ligne
d'effet/couvert, flanquement, zones menacées, AoO, charge, mouvement,
manœuvres et invocation sont généralisés aux cellules occupées (distance =
minimum entre cellules, LoS/couvert = meilleure paire) ; le comportement 1×1
reste bit-identique.
- Taille et attaques/CA : les valeurs `ac` (total/touch/flat_footed) et
`attack_bonus` des JSON de créatures sont des valeurs FINALES, déjà majorées
du modificateur de taille PF1e aux attaques/CA (Small +1, Medium 0, Large −1,
Tiny +2…) — vérifié sur toutes les fiches (`data/monsters/*.json`,
`data/pcs/*.json`, verrouillé par `tests/test_size_is_baked_in.py`). Le
moteur n'applique donc JAMAIS de bonus de taille aux jets d'attaque ni à la
CA (ce serait un double comptage) ; la taille n'intervient dans la
résolution que pour les manœuvres de combat (CMB/CMD, table
`_SIZE_MODIFIERS` Small −1 … Colossal +8 — colonne distincte de la table
attaques/CA), l'allonge naturelle, le footprint et l'occupation.
- Élévation : monter d'un niveau de hauteur coûte +1 case de mouvement par
niveau (la descente est gratuite) ; une créature située plus haut que sa
cible gagne +1 à ses jets d'attaque de mêlée et à la confirmation de
critique (règle du high ground). La ligne d'effet reste 2D (pas de LoS 3D).
- Actions immédiates hors-tour : politique de réaction optionnelle
`reaction_policy(engine, state, event)` (défaut : aucune) ; sur événement
(`attack_resolved`, `movement_step`, `spell_cast`, `damage_taken`) une
réaction `Action(kind="immediate")` peut être exécutée hors tour — une par
événement — et consomme le prochain swift de l'état (immédiate pendant le
tour : exécutée, consomme aussi le prochain swift — simplification
documentée). Les actions préparées (`ready`) sont traitées sur ces mêmes
événements **avant** et indépendamment de la politique de réaction (voir
« Préparation » plus haut).
- Ligne d'effet : une cible entièrement derrière un terrain `blocks_los` ne
peut pas être attaquée — la politique se déplace jusqu'à gagner une ligne de
visée.
- Couvert (règle des coins) : la cible gagne le bonus de couvert du terrain
(+2 partiel, +4 standard, +8 amélioré via `cover_bonus`) sur la touche et la
confirmation de critique ; couvert mou : une créature active entre
l'attaquant et la cible octroie aussi +4 CA aux attaques à distance (les
attaques de mêlée ignorent les créatures). Conformance B20 (CRB « Cover »,
règle des coins) : c'est **l'attaquant qui choisit son meilleur coin** —
une créature multi-cases (Large +) ne subit que le couvert de SA meilleure
case occupée (minimum sur ses cases), pas le pire. Le côté cible reste
évalué par la règle RAW : toute ligne vers tout coin des cases de la cible
compte (maximum sur les coins de la cible).
- 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). Le pas de
placement (5 ft) ne provoque jamais d'AoO (voir ci-dessous) ; la retraite ne
protège que la case de départ (voir ci-dessous).
- Charge : action à round complet. Le combattant se déplace en ligne droite
(Bresenham) jusqu'à 2× sa vitesse vers la case la plus proche d'où il peut
frapper la cible en mêlée, puis porte une seule attaque de mêlée à +2. La
charge impose −2 CA à l'attaquant jusqu'au début de son prochain tour. La
ligne droite doit être dégagée (pas de terrain difficile, d'obstacles ni de
créatures) ; distance minimum 2 cases (10 ft). `default_policy` choisit la
charge quand l'ennemi est hors de portée mais joignable en ligne droite.
- Retraite : action à round complet. Le combattant se déplace jusqu'à 2× sa
vitesse en s'éloignant de l'ennemi le plus proche (ascension gloutonne du
champ de coût Dijkstra depuis la menace). La case de départ n'est pas
considérée comme menacée — aucune AoO en la quittant. Les cases
ultérieures provoquent des AoO normalement (résolues pas à pas, comme pour
le mouvement). `default_policy` ne choisit pas la retraite (disponible via
une politique personnalisée).
- Course (`Action(kind="run")`, action à round complet, U5e) : le combattant
se déplace jusqu'à **4× sa vitesse** vers `target_id` (auto-dirigé,
s'arrête adjacent à la cible) ou vers une `destination` explicite (U5c), en
réutilisant exactement la mécanique de `move` (descente du champ de coût
Dijkstra, pas à pas avec provocation d'AoO — courir ne protège pas des
AoO), **sauf que la course ne traverse pas le terrain difficile**
(conformance R2 — CRB « Movement, Position and Distance — Difficult
Terrain » : « You can't run or charge across difficult terrain ») : la
descente refuse tout pas dont le footprint toucherait une case de coût > 1
(mécanisme `avoid_difficult` de `_descend_path`, symétrique du refus de la
charge dans `_charge_path_clear`) ; le combattant **avance jusqu'à
l'obstacle et s'y arrête** (comportement déterministe, retenu plutôt que
le rejet de l'action). À l'achèvement, le combattant est `running` jusqu'au
début de son prochain tour : il **perd son bonus de Dextérité à la CA**
(résolu par le même chemin flat-footed/`denies_dex_to_ac` que les
conditions). Une `destination` hors budget 4× (ou invalide) est rejetée
(`unreachable_destination`) ; sans cible ni destination, rejet
(`missing_target`). `default_policy` ne choisit pas la course (disponible
via une politique personnalisée).
- Pas de placement (5-foot step) : action libre qui déplace le combattant d'une
case vers l'ennemi le plus proche sans provoquer d'AoO. Exclusivité mutuelle
avec tout autre mouvement (move, charge, retraite) dans le même tour —
tracked via `moved_this_turn`, réinitialisé au début du tour. `default_policy`
choisit `(5foot_step, full_attack)` quand un seul pas suffit à entrer en
portée.
- Distance : pénalité cumulative de −2 par incrément de portée complet au-delà
du premier, appliquée au jet d'attaque et à la confirmation de critique ;
les armes ranged restent utilisables jusqu'à 10 incréments, les armes touch
avec incrément (armes de jet, firearms) jusqu'à 5.
- Touch AC et armes à feu : les attaques `kind: touch` résolvent leur touche
contre la CA de contact de la cible (`ac.touch`) ; si la cible perd son bonus
de Dextérité à la CA (flat-footed, `denies_dex_to_ac`), la CA de contact est
amputée du modificateur de Dextérité. Les armes marquées `firearm: true`
(pistolets) résolvent contre la CA de contact uniquement dans leur premier
incrément de portée — au-delà, elles frappent la CA totale. Une attaque touch
avec incrément est une attaque à distance : elle provoque des attaques
d'opportunité depuis une case menacée, subit −4 si l'attaquant est à plat
ventre et bénéficie du couvert mou ; un contact de mêlée (sans incrément,
ex. le contact paralysant du liche) n'en fait rien. Conformance P2 : le
contact de mêlée (touch sans incrément) est une **attaque de mêlée** — il
bénéficie du flanquement, de la hauteur (+1), du +4 contre une cible inerme
et peut porter une attaque sournoise ; Power Attack reste réservé aux armes
de mêlée (le contact n'en bénéficie pas).
- Jets de sauvegarde : `resolve_save(state, save_type, dc)` — fort/ref/will.
1 naturel = échec automatique, 20 naturel = succès automatique. Les
modificateurs d'effets (`StatModifier` avec `target` correspondant) sont
appliqués via `resolve_modifiers` (mêmes règles d'empilement que les
attaques). Renvoie un `SaveResult` (success, roll, total, dc).
- Modificateurs conditionnés : un `StatModifier` avec `condition=` n'est appliqué
que si la condition tient au moment de la résolution (`_modifier_condition_holds`) :
`target_within_30ft_ranged` (arme à distance à ≤ 30 ft), `axe` (arme dont le nom
contient « axe »), et les descripteurs de sauvegarde (`fear`, `cold`,
`electricity`, `fey`, `divination`, `poison`, `disease`, `mind_affecting`) — les
descripteurs sont portés par l'aura de terreur, les sorts (types de dégâts,
conditions de peur, écoles enchantement/divination) et les effets de zone. Toute
condition inconnue ou non supportée reste exclue (False).
- Incantation : chaque `CombatantState` porte `spell_slots_left` (initialisé depuis
le champ `spell_slots` du JSON, ex. `{"1": 4, ...}`) ; `_cast_spell` dépense
l'emplacement du niveau concerné (`spend_spell_slot`) — **sans emplacement
disponible, l'incantation est refusée** (journalisée `NO SPELL SLOT`, feedback
`ok=False, reason="no_slot"`, aucun jet consommé) ; les cantrips (niveau 0) s'incantent librement. Le DD vaut `10 + niveau du
sort (niveau d'emplacement effectif en cas de métamagie, voir ci-dessous) +
mod de la caractéristique d'incantation` (`casting_ability`), la sauvegarde
est résolue par `resolve_save`, et la RD ne réduit pas les dégâts des sorts
(`test_dr_does_not_reduce_spell_damage`). Métamagie (conformance B7, CRB
« Metamagic Feats ») : la métamagie se choisit **à chaque incantation** via
`Action(kind="cast_spell", metamagic=("Empower Spell", ...))` — seuls les
dons demandés ET connus du lanceur (`metamagic_feats`) s'appliquent (un don
inconnu est ignoré, un don connu non demandé n'est pas appliqué) ; le sort
occupe alors un emplacement de niveau de base + ajustement (Empower +2,
Maximize +3, Extend +1) et son DD utilise ce niveau d'emplacement ; sans
emplacement au niveau requis, l'incantation est refusée (`no_slot`). Avec
`metamagic` vide (défaut), le comportement est inchangé. Incanter dans une
case menacée provoque des AoO. Incantation défensive (conformance B24, CRB
« Concentration ») : `Action(kind="cast_spell", cast_defensively=True)`
évite la provocation d'AoO mais impose un jet de concentration DD
`15 + 2×niveau` (+4 avec Combat Casting) ; un échec perd le sort
(emplacement brûlé, journalisé `casting defensively: concentration failed`).
Agrippé (`grappled_with`) : un jet de concentration DD
`10 + CMB de l'adversaire + niveau` (+4 Combat Casting) est requis pour
incanter, défensivement ou non — ce jet remplace la provocation (un
agrippé ne peut pas porter d'AoO). Tout dégât subi pendant l'incantation
(AoO) impose un jet DD `10 + dégâts + niveau` **sans** le bonus Combat
Casting (conformance P1). Jet de concentration = d20 + niveau d'incanteur +
mod de la caractéristique d'incantation (Int/Wis/Cha — jamais Constitution,
CRB). Sorts de contact (conformance B14, CRB « Magic — Range: Touch ») :
délivrer un sort de portée `touch` à une créature **hostile** (camp
différent) exige une attaque de contact de mêlée — d20 + BAB + mod de Force
du lanceur contre la CA de contact de la cible (amputée du modificateur de
Dextérité si la cible perd son bonus de Dextérité, même chemin que
`resolve_attack`) ; en cas d'échec le sort n'a aucun effet (l'emplacement
est brûlé). Simplifications documentées : Weapon Finesse et les autres
bonus situationnels de jet de contact ne sont pas modélisés. Une cible
volontaire (soi ou un allié du même camp) ne demande aucun jet. Sorts à
contact à distance (CRB « Magic — Ranged Touch Attacks ») : un sort dont le
JSON déclare `requires_ranged_touch: true` (les rayons du SRD — Ray of
Frost, Ray of Enfeeblement, Scorching Ray, Acid Arrow, Acid Splash, Disrupt
Undead, Shard of Chaos, Spear of Purity) impose une attaque de contact à
distance contre une cible **hostile** (hors effet de zone) — d20 + BAB + mod
de **Dextérité** du lanceur contre la CA de contact de la cible (amputée du
modificateur de Dextérité si la cible perd son bonus de Dextérité, même
chemin que `resolve_attack`) ; la portée du sort est une valeur absolue, il
n'y a donc **pas** de pénalité par incrément de portée ; en cas d'échec le
sort n'a aucun effet (l'emplacement est brûlé). Simplifications documentées
(miroir du contact de mêlée) : le modificateur de taille et Weapon Finesse
ne sont pas modélisés. Magic Missile (touche automatiquement), les sorts de
zone et les sorts à sauvegarde simple ne sont pas concernés. Un sort ciblé avec sauvegarde fait pleins dégâts
sur un échec et **aucun dégât sur un succès** (la sauvegarde annule) ; le
demi-dégât sur sauvegarde réussie ne s'applique que là où l'effet le déclare
(`half_on_save: true` pour les rayons/lignes/cônes, `AreaEffect.save_for_half`
pour les explosions). Ciblage par point (U7b) : `Action(kind="cast_spell",
target_pos=(r, c))` centre l'`AreaEffect` de niveau supérieur du sort sur la
case `(r, c)` désignée au lieu de la position de la cible créature — poser un
Fireball au sol, pas seulement sur une tête. Le point doit être dans la carte
et avoir une ligne d'effet depuis le jeteur (sinon rejet `invalid_destination`,
aucun sort lancé). Avec `target_pos` et pas de cible résolue, l'incantation se
fait sur le point : la sauvegarde primaire est sautée et chaque créature de la
zone effectue sa propre sauvegarde (SR incluse). `target_pos` n'a de sens que
pour un sort à zone : pour un sort ciblé sans `AreaEffect`, il est ignoré et
la cible résolue est utilisée normalement. Sans `target_pos`, le comportement
centré sur la créature est inchangé. `default_policy` ne choisit pas de point
(disponible via une politique personnalisée). `default_policy` choisit soin / offensif / buff via
`_find_heal_spell`, `_find_offensive_spell` et `_find_ally_needing_heal`.
- Résistance aux sorts (SR) et résistances/immunités énergétiques : un combattant
avec `spell_resistance > 0` oppose sa SR à chaque sort qui le cible — jet
`d20 + niveau du jeteur` contre la SR ; si le total est inférieur, le sort est
résisté (journalisé, tous les effets annulés pour cette cible). Pour les sorts à
zone, la SR est résolue par créature touchée (pas seulement sur la cible
primaire). Les dégâts énergétiques (feu, froid, électricité, acide, son…) sont
annulés par l'immunité (`immunities`) ou réduits par la résistance du type
correspondant, **composante par composante** (conformance B21, CRB « Energy
Resistance ») : chaque composante de dégâts (`DamageComponent`) est réduite
uniquement par la résistance correspondant à SES types (maximum en cas de types
multiples — simplification documentée : une même formule de dés n'est pas
répartie entre les types listés), plancher 0 par composante ; une arme
1d6 tranchant + 1d6 feu contre résistance feu 5 ne perd que ses dés de feu, les
dés tranchants, les bonus plats et les dés de précision (attaque sournoise, non
typés) restent intacts ; l'immunité à un type annule uniquement les composantes
de ce type. Les types physiques (contondant/perforant/tranchant) restent sur le
chemin de la RD, appliquée **uniquement à la part physique** du dégât (dés
physiques + bonus plats + précision) — conformance A3 (CRB « Damage
Reduction » : « Damage reduction does not negate … energy damage dealt along
with an attack ») : la RD ne mange plus la composante énergétique d'une
attaque mixée (ex. arme à feu infligeant des dégâts de feu contre RD 15) ;
les composantes énergétiques ne sont réduites que par résistance/immunité
énergétique. La résistance énergétique
s'applique à l'étape du dégât final (même étape que la RD), y compris aux dégâts
de sorts (un `DamageEffect` est une composante unique — comportement inchangé).
- Concealment (chance de rater) : les conditions `blurred` (20 %, sort flou),
`displaced` (50 %, concealment total) et `invisible` (50 %, concealment total)
portent un champ `miss_chance` ; quand la
cible en porte une, après la touche et la confirmation de critique, le moteur
tire un d100 : si `d100 ≤ miss_chance`, l'attaque rate complètement (aucun
dégât, pas de critique, deed non consommé — journalisé « concealment »). Une
cible avec concealment ne peut pas être la victime d'une attaque sournoise.
Attaquant aveugle (V5a) : un attaquant portant `blinded` considère tous ses
adversaires comme ayant un concealment total — le même jet unique de d100 est
tiré avec `miss_chance = max(concealment de la cible, 50)` et journalisé
« attacker blinded » ; l'attaque peut donc rater même contre une cible sans
concealment.
- Attaquant invisible (V5a) : un attaquant portant `invisible` gagne **+2 à ses
jets d'attaque et de confirmation de critique** et sa cible **perd son bonus
de Dextérité à la CA** (même chemin `denies_dex_to_ac` que flat-footed/course).
L'invisibilité est symétrique : subi comme cible, la condition accorde le
concealment total de 50 % ci-dessus.
- Cible inerme (helpless, V5a + conformance P1) : sont traitées comme
**inertes** les conditions `helpless`, `paralyzed`, `asleep` et
`unconscious` (CRB : inerme = paralysé, immobilisé, ligoté, endormi,
inconscient — `stunned` **ne** rend **pas** inerme). Les attaques de
**mêlée** contre une cible inerme gagnent **+4 au jet d'attaque et à la
confirmation** (aucun bonus à distance) ; la cible a une **Dextérité
effective de 0** : son modificateur de Dextérité est entièrement remplacé
par la pénalité Dex 0 (−5), en plus de la perte du bonus de Dextérité
(CA flat-footed via `denies_dex_to_ac`).
Simplification documentée : l'attaque sournoise automatique du roublard sur
cible inerme n'est pas modélisée (la cible, privée de Dextérité, la déclenche
déjà via le chemin `denies_dex_to_ac`).
- Attaque sournoise : un combattant avec la capacité « Sneak Attack » inflige des
d6 de dégâts de précision ((niveau + 1) // 2 dés : 1d6 au niveau 1, 2d6 au
niveau 3…) si la cible est flanquée (attaque de mêlée uniquement — toucher de
mêlée inclus, conformance P2) ou privée de
son bonus de Dextérité à la CA (condition `denies_dex_to_ac` ; mêlée, ou
attaque à distance à ≤ 30 ft). Les dés sont jetés avec le RNG du moteur et
ajoutés APRÈS le multiplicateur de critique (la précision n'est jamais
multipliée) et AVANT la RD et les résistances énergétiques ; jamais contre une
cible avec concealment. Conformance P2 : les morts-vivants (`undead`) et les
constructeurs (`construct`) sont immunisés aux dégâts de précision — la cible
ne peut pas être la victime d'une attaque sournoise ; les autres immunités RAW
(vase, plante) ne sont pas modélisées (pas de tels types dans le modèle).
- Tirer dans une mêlée / Precise Shot : toute attaque à distance (kind `ranged`
ou `touch` avec incrément) contre une cible engagée dans une mêlée subit −4 au
jet d'attaque ET au jet de confirmation (la cible est « engagée » quand un
ennemi actif autre que l'attaquant, doté d'une arme de mêlée, menace ses
cases) ; la pénalité tombe à −2 si la cible ou l'adversaire menaçant est à
≥ 10 ft (2 cases) de l'attaquant. Le don « Precise Shot » annule la pénalité.
Les attaques de mêlée ne sont pas concernées.
- Couvert : les trois niveaux PF1e sont modélisés via `cover_bonus` sur le
terrain — partiel +2, standard +4 (ou `cover: true`), amélioré +8 ; le
couvert mou des créatures reste +4 (couvert standard). B20 (conformance) :
pour un attaquant multi-cases, le bonus effectif est le **meilleur** (plus
faible) couvert parmi ses cases occupées — l'attaquant choisit son meilleur
coin/case selon le CRB ; les créatures 1×1 sont inchangées.
- Invocation : les créatures invoquées agissent (calées sur la case d'initiative de
leur conjurateur via `_build_round_order`) puis expirent après leur `summon_duration`
(`_expire_summons` décrémente puis retire).
- Conditions effectives : 28 conditions prédéfinies (`conditions.py`). Leurs drapeaux
comportementaux sont appliqués par le moteur : `cant_act` (étourdi, paralysé, dazed,
endormi, inerme, fasciné) annule le tour ; `cant_attack` (nauséeux) n'autorise qu'une unique action
de mouvement (`move`, `5foot_step`, `stand_up`, `drop_prone`) et bloque toute
attaque, manœuvre **et incantation** (CRB : un nauséeux ne peut ni attaquer,
ni incanter, ni se concentrer) ; `staggered` restreint à une action
simple (une action de mouvement OU standard, pas d'attaque à outrance ni
d'action à round complet, mais swift/libre/immédiate conservées) ;
`cant_move` bloque le déplacement ;
`denies_dex_to_ac` (flat-footed, paralysé, aveuglé…) fait perdre le bonus de Dextérité à
la CA (CA flat-footed) ; `miss_chance` (flou 20 %, déplacement 50 %, invisible
50 %) impose la
chance de rater du concealment. À plat ventre : l'attaquant à plat ventre
subit −4 à ses attaques (mêlée selon le CRB ; distance traitée arbalète à
−4, simplification documentée) ; la cible à plat ventre subit −4 CA contre
la mêlée et gagne +4 CA contre la distance (le CRB inverse du « −4 CA
contre tout » précédemment modélisé).
Agrippé : −4 Dextérité — le modificateur cible `dex_skills` s'applique
désormais réellement aux compétences de Dexterity (Acrobaties, Escalade,
Équilibre, Évasion, Vol, Équitation, Manipulation de corde, Discrétion,
Natation, Désamorçage — conformance P2) —, −2 attaque/CA/Réflexes, sans
déplacement ni AoO. Épinglé : même −4 `dex_skills`.
Aveuglé (conformance P2) : −2 CA, perte du bonus de Dextérité à la CA, −4
aux compétences de Dexterity (`dex_skills`) et **aucun malus plat
d'attaque** — la pénalité RAW est la dissimulation totale (50 % d'échec,
modélisée) ; le −2 d'attaque non-RAW qui s'y superposait a été supprimé.
Simplification documentée : la moitié Force du −4 RAW (compétences de
Force) n'est pas modélisée (pas de cible `str_skills`).
Inerme (`helpless`, `paralyzed`, `asleep`, `unconscious`) : ne peut pas agir,
Dextérité effective 0 (−5 CA en plus de la perte du bonus), +4 en mêlée
contre lui (voir « Cible inerme ») ; `stunned` ne rend pas inerte. Fasciné (`fascinated`) : ne peut pas agir.
Invisible (`invisible`) : concealment total subi +2 attaquant / perte de Dex
de la cible (voir « Attaquant invisible »).
Saignement (`bleeding`, V5b) : une créature qui saigne subit `bleed_damage`
points de dégâts au **début de son tour** (avant politique et flags) ; tout
effet restaurant des points de vie (sort de soin, channel energy) stoppe le
saignement (`bleed_damage = 0`) et retire la condition. Simplification
documentée : le jet de compétence Soin DD 15 n'est pas modélisé (le moteur
n'a pas d'action Soin).
Confus (`confused`, V5b) : au début de chaque tour, jet d% — 01-25 agit
normalement (le tour se poursuit) ; 26-50 bave incohéremment (tour perdu) ;
51-75 se inflige 1d8 + modificateur de Force avec l'objet tenu ; 76-100
attaque d'une seule attaque de mêlée la créature la plus proche (allié OU
ennemi, une seule si en allonge ; sinon le tour est perdu, journalisé).
Simplification documentée : la clause « si elle est blessée, riposte
l'agresseur » est réduite à « attaque la créature la plus proche » ; le coup
de grâce n'est pas modélisé.
- Manœuvres de combat : trip, disarm, bull_rush, sunder, grapple, escape,
overrun, dirty trick, steal, reposition et drag sont modélisées, chacune
comme une tentative unique (1d20 + CMB contre CMD) : le jet de manœuvre est
un jet d'attaque — 20 naturel réussit toujours, 1 naturel échoue toujours
(conformance P2). Conformance P2 : tenter une manœuvre (sauf `escape`)
**provoque une attaque d'opportunité** de la cible, résolue **avant** le jet
de CMB ; le don « Improved <Maneuver> » 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).
trip → `prone` ; disarm → `disarmed` (−4 attaque) ; bull_rush → pousse la
cible (direction sélectionnable via `Action.direction`) ;
grapple → `grappled`/`pinned` ; escape → **une** sauvegarde (bonus statique de la
compétence `escape_artist` ou CMB, + un d20) contre CMD — une seule tentative,
sans boucle multi-tour. Un sunder endommage l'**arme**, pas les PV : `str_mod + 2`
de dégâts à l'arme, qui se brise à 10 d'usure cumulée (`_WEAPON_DURABILITY`) →
`disarmed` ; une arme brisée est exclue de `usable_attacks()` et réparable via
`Action(kind="repair_weapon")`. overrun →
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 ; dirty trick →
condition choisie par `Action.trick_condition` (défaut `dazzled` 1 round ;
`blinded`/`deafened`/`entangled`/`nauseated` possibles) ; steal → l'arme de
mêlée de la cible est volée (exclue de `usable_attacks()`, récupérable via
`Action(kind="retrieve_weapon")`) ; reposition → déplacement de 5 ft de la
cible vers le premier voisin libre (direction sélectionnable via
`Action.direction`) ; drag → traction de 5 ft de la cible, l'attaquant
occupant sa case d'origine. `default_policy` ne
choisit pas ces manœuvres (utilisables via `Action(kind="maneuver")` ou une
politique personnalisée).
- Combat Reflexes : 1 AoO par combattant par round par défaut ; avec le don Combat
Reflexes, `1 + mod de Dextérité` AoO par round (`_aoo_allowance`, plancher 1).
- Sortilèges du sorcier (witch hex) : le DD vaut `10 + niveau//2 + mod d'Intelligence`
(evil_eye, slumber, cackle) ; `cackle` prolonge d'un round les sortilèges déjà
actifs, sur une liste instantanée pour ne pas s'étendre à l'infini. Les sorts à
zone (AoE) utilisent le DD d'incantation du jeteur et chaque créature de la
zone effectue la sauvegarde listée par le sort (Volonté / Réflexes /
Constitution — le sort primaire réutilise son jet déjà effectué) ; un succès
annule ou divise par deux selon ce que l'effet déclare (`save_negates` /
`half_on_save` / `AreaEffect.save_for_half`) — les zones « sauvegarde
annule » (Sleep, Color Spray, Web) sauvent donc aussi.
- Dons : les 8 PJ portent 55 noms de dons uniques ; le chargeur résout chacun d'eux
soit vers un effet réel (Power Attack actif, Weapon Focus ciblé par arme, Toughness
lié au niveau, dons passifs via `_stat_modifiers`), soit vers un « no-op » tracé en
toute transparence (noms décoratifs / de race) — aucun n'est silencieusement ignoré
(`tests/test_feats.py`).
- Capacités de classe émises par `default_policy`, chacune conditionnée à une ressource
`> 0` dépensée dans la méthode d'exécution : `channel_energy` (oni, énergie positive :
si un allié est < 50 % de ses PV → soigne tous les êtres vivants à 30 ft ; sinon, si au
moins 2 morts-vivants ennemis sont groupés à 30 ft → les blesse en 2d6 ; le camp de la
cible n'est pas pris en compte — seul `creature_type` compte, `selective_channeling` non
modélisé ; les créatures blessées obtiennent un jet de Volonté DD `10 +
demi-niveau du canaliseur + modificateur de Charisme` pour demi-dégâts
— CRB Channel Energy ; le mode soin n'accorde pas de sauvegarde), sortilège `evil_eye` (nairda), deed `up_close_and_deadly`
(tammara, action bonus +1d6 à ≤ 30 ft — consommé sur la première attaque
résolue : si elle est à distance ET à ≤ 30 ft, +1d6 s'ajoute aux dégâts ;
sinon le bonus est perdu ; le drapeau est réinitialisé dans tous les cas),
`inspire_courage` + `spell_combat` (misty — Spell Combat : action de round
complet, incante un sort standard et porte toutes ses attaques de mêlée à
**−2** (CRB Magus, conformance — la pénalité s'applique à chaque jet
d'attaque et de confirmation ; simplification : le coût de pool arcanique
n'est pas modélisé) ; Inspire Courage (CRB Bardic Performance) : **+1
compétence** (competence) aux jets d'attaque et de dégâts — type qui se
cumule donc avec les sources de moral (Heroism) — et **+1 moral** aux jets
de sauvegarde contre les effets de charme et de peur, portée par deux
modificateurs conditionnés (`fear` / `mind_affecting`, dédupliqués par la
règle d'empilement du moral) ; simplifications documentées : la
progression (+1 par quatre niveaux) n'est pas modélisée (plat +1) et le
moteur n'ayant pas de descripteur « charme », `mind_affecting` en tient
lieu (surcouverture : les effets de compulsion comptent aussi))
— couvertes par `tests/test_class_abilities.py`.
- Le Liche (`data/monsters/lich.json`) : `creature_type: "undead"` (sensible à l'énergie
positive canalisée), aura de terreur (30 ft, Volonté DD 22 ou
`shaken` 1 round, au début de son tour) et contact paralysant (au toucher,
`paralysé` 1d4+1 rounds), RD 15/contondant **et** magie (conformance P2 :
les deux composants sont requis — une arme contondante non magique ne
contourne plus la RD ; lacune documentée : le modèle d'arme n'expose pas de
propriété « magique », donc aucun équipement ne contourne cette RD) ; son JSON porte un dictionnaire
`spell_slots` sans clés dupliquées.
- Snapshot d'observation (contrat LLM, Phase 2) : avant chaque appel de
politique, le moteur construit une `Observation` sérialisable (`to_dict()` →
JSON) figée — le snapshot est la SEULE source d'info de la LLM (aucune
référence `engine.*` exposée, collections copiées/figées). Une politique LLM
se branche via `CombatEngine(..., llm_policy=...)` (`LLMPolicy(observation,
state) -> tuple[Action, ...]`). Schéma enrichi (E1-E5) :
- Par combattant : id, side, pos, occupied_cells, hp/hp_max, active/dead,
dying/stabilized, conditions (noms + rounds_left), resources_left (+ `spell_slot_{level}`),
weapon_broken/weapon_stolen, grappled_with, moved_this_turn, power_attack,
name, level, size, dr {amount, bypass} ou None (C1), creature_type (C3),
bab (E1), initiative_mod (E2) et initiative_total (U2 : total du jet
d'initiative courant = d20 + mod, 0 si pas encore de clé — invocation
fraîche), casting_ability ou None pour non-jeteur
(E3), abilities {str/dex/con/int/wis/cha: score brut} (Medium), skills
{nom: {rank, class_skill, armor_penalty}} uniquement rank > 0 (Medium),
metamagic_feats (tuple trié), weapon_damage {weapon_id: dégâts courants},
summon_duration (int ou None), immediate_used, deed_up_close_and_deadly
(flags actuels), active_buffs {source, rounds_left, mods: tuple de
{target, value, bonus_type}} (E5), speed_land_ft, speed_fly_ft,
speed_effective_ft (V4 : budget de déplacement effectif = max(land, fly),
en pieds), ac {total,
touch, flat_footed}, saves {fort, ref, will}, cmb, cmd, attacks
(référentiel complet : id, name, kind, attack_bonus, damage, damage_bonus,
crit_range, crit_mult, reach_ft, range_increment_ft, count, firearm —
y compris les armes broken/stolen), features (noms des capacités),
feature_details (C4 : tuple de {name, category, source_detail, effects:
tuple de {target, value, bonus_type, condition, weapon_filter}}),
spells (résumés enrichis du registre : name, level, school, range_ft,
save, dc, summary, half_on_save, save_negates (E4), area {shape,
radius_ft} | None (U7a : premier AreaEffect de niveau supérieur du sort,
gabarit de zone structuré, None si le sort n'a pas de zone), cast_time
(code de temps d'incantation, « standard » pour un sort inconnu du
registre)),
spell_resistance (int), resistances {type: montant}, immunities
(tuple triée des types immunisés), sneak_dice (dés de précision disponibles :
(niveau + 1) // 2 avec « Sneak Attack », sinon 0), engaged_in_melee (bool —
une cible engagée dans une mêlée, signal pour le tir dans la mêlée et
l'attaque sournoise), dying (bool — `hp < 0` et pas morte : agonisante,
inconsciente), stabilized (bool — stabilisée pendant son agonie, ne perd
plus de pv tant qu'aucune nouvelle blessure ne la remet en agonie),
last_turn_results (tuple de {kind, ok, reason} — résultat d'exécution du
dernier tour, pour que la policy apprenne les actions rejetées/dégradées),
readied ({kind, trigger} de l'action préparée ou None — préparation en
cours, consommée quand le déclencheur la fait tirer),
defensive_ac_bonus (int — bonus d'esquive à la CA de la défense totale
(+4) ou du combat défensif (+2), réinitialisé au début du tour du
combattant), fight_defensively (bool — combat défensif actif : −4 aux
jets d'attaque et de confirmation jusqu'au prochain tour), aoo_remaining
(int — attaques d'opportunité restantes ce round : `_aoo_allowance`
(1 par défaut, 1 + mod de Dex avec Combat Reflexes, plancher 1) moins les
AoO déjà consommées, réinitialisé à chaque nouveau round, jamais négatif),
aid_attack_bonus (int — bonus d'aide autrui (+2 par aide réussie, cumulatif)
ajouté au prochain jet d'attaque du combattant puis consommé, expiré au
début de son tour s'il est inutilisé), aid_ac_bonus (int — bonus d'aide
autrui (+2 par aide réussie, cumulatif) ajouté à sa CA, expiré au début de
son tour), running (bool — course en cours jusqu'au prochain tour du
combattant : il perd son bonus de Dextérité à la CA), bleed_damage (int —
dégâts de saignement subis au début du tour du combattant, 0 = ne saigne
pas ; V5b ; `bleeding`/`confused` sont visibles via `conditions`).
- Par paire combattant actif × cible : target_index, distance_ft, weapon_for,
range_penalty, can_charge, can_step5_to_attack, has_line_of_effect,
flanking, movement_cost_ft, target_threatens_me, enemies_threatening_me,
cover_bonus (int — bonus de couvert que la cible gagne contre une attaque
de l'acteur, calculé comme dans `resolve_attack` : terrain +2/+4/+8 via la
règle des coins, couvert mou +4 des créatures interposées pour les seules
attaques à distance ; 0 si `weapon_for` est None), weapon_reason (str |
None — None si `weapon_for` a choisi une arme ; sinon la raison normalisée
de l'indisponibilité d'attaque, dans le vocabulaire `ACTION_RESULT_REASONS` :
`no_weapon` (aucune arme utilisable), `no_line_of_effect` (ligne d'effet
bloquée), `out_of_range` (arme existante mais cible hors de portée)),
charge_reason (str | None — None si `can_charge` est vrai ; sinon la
raison de `ChargeResult` verbatim (`no_line_of_effect` / `no_charge_path`)
ou `no_weapon` si aucune arme de mêlée utilisable).
- Top-level : round, acting_combatant, combatants, pairs, terrain, elevation,
threatened (grille bool des cases menacées au contact par les ennemis —
AoO), ranged_threatened (U6d : grille bool des cases sous tir ennemi à
distance — champ de tir des armes à distance utilisables des ennemis
actifs (portée maximale + ligne d'effet, bornée à la carte) ; les armes à
distance ne menacent pas pour les AoO en PF1e : c'est une carte de champ
de tir, pas une menace d'AoO ; la grille `threatened` de mêlée reste
inchangée),
initiative_order (U2 : tuple d'ids dans l'ordre d'initiative du round
courant — le premier est le prochain à agir ; les invocations héritent de la
case de leur conjurateur),
terrain_semantics (un dict par type de terrain : type, move_cost,
blocks_los, cover, cover_bonus), condition_definitions (C2 : tuple trié de (nom,
{mods, flags, speed_mult, miss_chance}) — définitions complètes des 28
conditions de CONDITIONS_BY_NAME pour que la LLM interprète les drapeaux,
les modificateurs et les chances de rater (concealment)).
Les helpers publics du moteur (L5) sont désormais embarqués EN DONNÉES dans
le snapshot (`threatened`, `terrain_semantics`, `movement_cost_ft`,
`target_threatens_me`, `enemies_threatening_me`) — une `LLMPolicy` ne peut
pas appeler `engine.*`, elle lit leurs résultats. Mapping des clés
`resources_left` (vérifié par grep sur `spend_resource`/`resources_left[` :
`channel_energy`, `hexes`, `grit`, `arcane_pool`, `bardic_rounds`, plus
`spell_slot_{level}` ajouté par le snapshot). Limites documentées : pas de
grille `reachable_ft` budget=speed (délibérément rejetée —
`movement_cost_ft` par paire + `terrain_semantics` + `speed_effective_ft`
suffisent). Le couvert par paire est exposé via la clé `cover_bonus`
(calcul identique à `resolve_attack`, couvert mou des créatures inclus).
Les API de décision exposées (items L1-L8)
s'y retrouvent : `direction`/`trick_condition`/`weapon_id` sur `Action`,
ainsi que `metamagic` (dons de métamagie choisis pour cette incantation,
B7) et `cast_defensively` (incantation défensive, B24) sur l'action
`cast_spell` ;
`can_charge` → `ChargeResult`, kinds `retrieve_weapon`/`repair_weapon`.
Le kind `activate_power_attack` (action libre) active le don Power Attack
(`state.power_attack = True`) si le combattant possède le don ; sans le don,
c'est un no-op journalisé. Le snapshot expose l'état courant via la clé
`power_attack` (bool) par combattant.
Non modélisé : la couche `markers` (points nommés de la carte) est validée à
la lecture mais non appliquée dans la résolution.
## Architecture
- `dice.py` — parseur de notation de dés (`2d4+1`) et lancer.
- `rng.py` — `SeededRng` (flux reproductible par graine) et `ScriptedRng`
(séquence de dés scriptée pour les tests de transcripts).
- `models.py` — schémas Pydantic v2 : `Combatant`, `AbilityScores`,
`ACProfile`, `AttackSpec` (avec `firearm` pour les armes touch à feu),
`DamageComponent`, `Saves`, `DamageReduction`.
- `map.py` — `MapSpec`/`TerrainType`, chargement YAML et validation
(dimensions, caractères connus, zones, déploiement, markers).
- `grid.py` — grille 5-10-5 : `distance`, `step_cost` (terrain + élévation),
règle des coins (`diagonal_allowed`), Dijkstra `reachable` avec ou sans
budget.
- `los.py` — ligne d'effet et couvert par la règle des coins : gate de ligne
d'effet dans `weapon_for`, bonus de couvert par niveau (+2/+4/+8 terrain via
`cover_bonus`, +4 couvert mou des créatures) dans `resolve_attack`.
- `combat.py` — `CombatEngine` déterministe : initiative, actions, résolution
des attaques (couvert, ligne d'effet, pénalités de portée, CA de contact
pour les attaques touch et firearms), tailles et allonges (occupation
multi-cases, zones menacées en anneau), high ground, actions immédiates
opt-in (réactions hors-tour), états de vie, incantation
(emplacements, concentration par situation, AoO d'incantation), invocation,
manœuvres (avec sélection directionnelle et choix de condition d'état),
capacités de classe, transcripts, politique par défaut, snapshot
d'observation sérialisable `Observation`/`LLMPolicy` (frontière LLM). Les
modificateurs conditionnés (`StatModifier.condition`) sont évalués via
`_modifier_condition_holds` avec le contexte d'action (arme, portée,
descripteurs de sauvegarde) ; les conditions inconnues restent exclues.
- `effects.py` — système d'effets : `StatModifier` (modificateur de
caractéristique avec type de bonus) et `resolve_modifiers` (application des
règles d'empilement PF1e). Types de bonus cumulables (dodge, racial, trait,
sans type) s'additionnent ; types non-cumulables (morale, sacré, profane,
enhancement…) gardent la valeur la plus élevée. Les pénalités suivent les
mêmes règles. Fondation pour sorts, conditions, dons et capacités de classe.
- `conditions.py` — conditions PF1e : `Condition` (dataclass avec
modificateurs + flags comportementaux + `miss_chance`) et 28 conditions prédéfinies
(shaken, sickened, fatigued, exhausted, entangled, dazzled, blinded,
deafened, flat-footed, prone, stunned, paralyzed, nauseated, dazed,
staggered, frightened, panicked, cowering, helpless, fascinated, invisible,
bleeding, confused).
Les modificateurs sont intégrés
à `resolve_attack` (attaque, dégâts, CA) et `resolve_save` (jets de
sauvegarde) via `_stat_modifiers` qui collecte effets + conditions ; les
drapeaux comportementaux (`cant_act`, `cant_attack`, `cant_move`, staggered,
etc.) sont appliqués par le moteur pendant le tour.
- `abilities.py` — dons et capacités : `AbilitySpec` (dataclass avec
catégorie feat/class/racial/trait) et fabriques de dons (Weapon Focus ciblé
par arme, Toughness lié au niveau, Power Attack actif −X attaque / +2X
dégâts, X = BAB//4 + 1, mêlée uniquement, activé comme action libre via le
kind `activate_power_attack` qui pose `state.power_attack`). Conformance
B16 (CRB « Power Attack ») : le bonus de dégâts passe à **+3X (×1,5)** pour
une arme marquée `two_handed: true` dans le JSON de l'attaque (arme à deux
mains, arme à une main tenue à deux mains, ou attaque naturelle primaire à
×1,5 de Force) ; la pénalité −X s'applique aussi **aux jets de manœuvre de
combat** (CMB, hors escape par compétence) ; la division par deux (×0,5,
+1X) pour une arme tenue à la main secondaire ou une attaque naturelle
secondaire n'est **pas modélisée** (le moteur ne gère pas le combat à deux
armes). Les modificateurs sont collectés par
`_stat_modifiers` avec filtrage par arme (`weapon_filter`). Le champ
`features` de `Combatant` contient les aptitudes permanentes ; le chargeur
(`loaders/combatant.py`) résout les noms de dons des fiches vers des
`AbilitySpec` réels ou des « no-op » tracés.
- `spells.py` — sorts : `SpellSpec` (dataclass avec niveau, école, portée,
sauvegarde, DC de base, exigence de contact à distance `requires_ranged_touch`
(rayons), temps d'incantation `cast_time`) et types d'effets
(`DamageEffect`, `ConditionEffect`,
`HealEffect`, `BuffEffect`). Portées PF1e (personal, touch, close, medium,
long) calculées par `spell_range_ft`. Chargeur JSON (`load_spell`,
`load_spell_registry`) pour `data/spells/`, acceptant les codes de budget
`cast_time` et leurs alias RAW (« 1 round », « 1 minute »…). Des
descripteurs de sauvegarde
(`spell_save_descriptors` / `save_descriptors_for_effects` : types de dégâts,
conditions de peur, écoles enchantement/divination) sont dérivés des effets
pour évaluer les modificateurs conditionnés lors d'une sauvegarde. Le moteur
intègre les sorts via `Action(kind="cast_spell")` et `_cast_spell` :
résolution de sauvegarde, application des dégâts (aucun dégât sur sauvegarde
réussie pour un effet ciblé — la sauvegarde annule ; demi-dégâts seulement si
l'effet déclare `half_on_save` ; la RD ne s'applique pas aux dégâts de sorts),
conditions, soins (plafonnés à hp_max) et buffs. Le champ `spells` de
`Combatant` contient les noms de sorts connus ; le `spell_registry` est passé
au `CombatEngine`.
- `metrics.py` — statistiques en forme fermée : `win_rate`, `win_rate_sigma`,
`win_rate_band` (bande 3σ bornée à [0, 1]).
- `runner.py` — `EncounterSpec`/`Side`, `build_states` (placement en zone +
désambiguïsation des ids), `run_encounter` (un `SeededRng` par run) et
agrégation en `EncounterReport` avec attrition par combattant.
- `cli.py` — front-end argparse `pf1e-sim`, rapport français, code de sortie 2
en cas d'erreur.
- `loaders/` — `combatant.py` (chargeur unifié PJ + monstres avec résolution
des dons → `AbilitySpec` et des sorts → `Combatant.spells`) et
`foundry.py` (outil de migration one-shot depuis les fiches Foundry).
## Développement
La gate de validation complète (tests + lint + types) :
```bash
uv run pytest -q # 714 tests
uv run ruff check src tests
uv run basedpyright src # mode strict
```
- Ruff est configuré avec `select = ["ALL"]`, longueur de ligne 100.
- Basedpyright tourne en `typeCheckingMode = "strict"` sur `src` et `tests`.
- Le moteur est testé par des transcripts épinglés (combats 1v1 et 2v2
scriptés) et par des assertions statistiques en forme fermée (matchup
symétrique dans la bande 3σ).
## Feuille de route
- **Phase 1** — magie et états : largement réalisée. Sorts par emplacements
(`spells.py` + base `data/spells/`, 107 sorts), DD et sauvegardes, RD non
appliquée aux dégâts de sorts, concentration par situation, invocation,
conditions effectives (`conditions.py`), manœuvres de combat, dons (55 noms
résolus, `abilities.py` + chargeur) et capacités de classe (channel energy,
hex, deeds, inspire courage / spell combat) sont en place et documentés dans
« Règles implémentées ». Le socle B5 est en place : hauteurs/élévations
(coût de déplacement + high ground), tailles Large+ (occupation multi-cases),
allonges > 5 ft (allonge naturelle + `reach_ft`, zones menacées en anneau) et
actions immédiates hors-tour (politique de réaction opt-in). Restent hors du
socle (voir « Non modélisé ») : les markers.
- **Phase 2** — couche tactique LLM : stratégies en langage naturel traduites
en politiques, balayage de matrices de positionnement. Le contrat
d'observation (snapshot sérialisable `Observation` + wrapper `LLMPolicy`)
est en place ; la couche LLM elle-même reste à implémenter. Les capacités
désormais débloquées du moteur (ready, delay, défense totale, destination
de déplacement, choix d'arme, boucle de feedback) sont démontrées par une
politique lisant uniquement l'`Observation` dans
`tests/test_llm_engine_capabilities.py` (démo de capacités, pas une
implémentation de la couche LLM).
- **Phase 3** — rapporteur LLM local : agrégation des statistiques et
rédaction du rapport d'équilibrage en français. (Non implémentée.)