Files
pf1e-simulator/README.md
T

543 lines
28 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]
```
| Option | Rôle | Défaut |
|---|---|---|
| `--map MAP` | Fichier YAML de la carte (obligatoire) | — |
| `--side NAME [FILE ...]` | Camp `NAME` avec un ou plusieurs JSON de monstres ; répétable pour chaque camp | — |
| `--runs RUNS` | Nombre de combats simulés | `1000` |
| `--seed SEED` | Graine du générateur aléatoire | `1` |
| `--round-cap ROUND_CAP` | Plafond de rounds par combat (au-delà, combat nul) | `100` |
Codes de sortie :
- `0` — simulation terminée, rapport imprimé.
- `2` — erreur d'utilisation ou de données (message sur stderr) : côté
inconnu, côté de déploiement manquant, zone trop petite, fichier invalide…
Règles de construction d'une rencontre :
- Les noms de camps (`--side NAME`) doivent correspondre exactement à la
section `deployment` de la carte, et **tous** les camps du déploiement
doivent être fournis.
- Le même fichier monstre peut être répété pour créer plusieurs combattants
identiques ; les ids sont désambiguïsés globalement dans l'ordre de
déploiement (`goblin`, `goblin-2`, `goblin-3`).
- Les combattants d'un camp remplissent les cases de leur zone dans l'ordre
(ligne, colonne). Une zone trop petite pour le nombre de combattants est une
erreur.
## Interpréter le rapport
Pour chaque camp, le rapport donne le taux de victoire observé et une bande de
confiance à 3σ calculée en forme fermée (modèle binomial) :
- σ = √(p(1−p)/n) ; la bande est [p − 3σ, p + 3σ], bornée à [0, 1].
- Un affrontement symétrique (combattants identiques sur des zones en miroir)
doit rester proche de 50 % ; un écart hors de la bande 3σ signale un
déséquilibre (avantage d'initiative, géométrie, portées…).
- Une bande large indique un échantillon trop petit pour conclure — augmentez
`--runs`.
- L'attrition moyenne par combattant indique qui participe réellement : un
combattant à distance qui n'est jamais rejoint affiche des dégâts subis
proches de zéro, un combattant qui meurt systématiquement affiche des dégâts
subis supérieurs à ses PV maximum (dégâts du coup fatal inclus).
- Les nuls proviennent des combats atteignant le plafond de rounds (combattants
immobiles, camps inaccessibles…).
Exemple vérifié — 1 gobelin (players) contre 2 orcs (monsters), 1000 runs,
seed 1 : players 22,0 %, bande 3σ [18,1 % ; 25,9 %], 0 nuls, 5,2 rounds moyens.
Sans ligne de visée depuis la zone de départ, l'archer gobelin doit contourner
le mur central avant de tirer ; mais avec l'économie d'action activée, les orcs
parcourent toute leur vitesse (30 ft) puis frappent dans le même tour — le
gobelin solitaire ne peut plus les distancer et tombe vite en mêlée sous les
2d4+4 des falchions. C'est ce déséquilibre de cadence qui penche fortement le
face-à-face à 1 contre 2 du côté des monstres.
## Formats de données
### Carte (YAML)
Chaque couche est un bloc ASCII de mêmes dimensions ; un caractère = une case
de `square_size_ft` pieds. Seule `terrain` est obligatoire.
```yaml
name: "Arène d'essai"
square_size_ft: 5
terrain: | # couche obligatoire
####################
#......######......#
#..C...######...C..#
#......~..~........#
#..TT..~..~...TT...#
#......~..~........#
#..C............C..#
####################
legend: # un caractère = un type de terrain
"#": { type: wall, move_cost: null, blocks_los: true } # bloque déplacement + LoS
".": { type: floor, move_cost: 1 }
"T": { type: rubble, move_cost: 2 } # terrain difficile
"~": { type: water, move_cost: 2 }
"C": { type: pillar, move_cost: null, cover: true } # bloque le pas, couvert
elevation: | # optionnel : chiffres = hauteur (validé, pas encore appliqué)
....................
..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 +4 CA.
### 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.
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 ; la RD s'applique après
multiplication, les types perforants annulent la RD, les dégâts plancher à 0.
- Mort quand `hp < min(-10, -CON)` ; `hp ≤ 0` = inactif.
- 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) ne sont pas encore modélisées.
- 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.
- Mêlée : allonge d'une case ; 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.
- 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 +4 CA sur la touche et la
confirmation de critique ; couvert mou : une créature active entre
l'attaquant et la cible octroie aussi +4 CA aux attaques à distance (les
attaques de mêlée ignorent les créatures).
- Flanquement : +2 au jet d'attaque de mêlée si un allié actif (doté d'une arme
de mêlée) menace la cible depuis la bordure ou le coin opposé. Les attaques à
distance ignorent le flanquement ; un allié désarmé ou à distance ne compte
pas pour le flanquement.
- Attaques d'opportunité : un combattant actif doté d'une arme de mêlée menace
les 8 cases adjacentes. Deux déclencheurs sont modélisés : (1) quitter une
case menacée — le mouvement est résolu pas à pas, et chaque ennemi qui menace
la case actuelle porte une AoO avant que le mouvant ne la quitte ; (2) tirer
avec une arme à distance depuis une case menacée — chaque ennemi menaçant
porte une AoO avant chaque tir. Une AoO par combattant par round (piste
`_aoo_used`, réinitialisée au début du round) ; 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).
- 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.
- 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`). Le DD vaut `10 + niveau du
sort + mod de la caractéristique d'incantation` (`casting_ability`), la sauvegarde
est résolue par `resolve_save`, et la RD ne réduit pas les dégâts des sorts
(`test_dr_does_not_reduce_spell_damage`). Incanter dans une
case menacée provoque des AoO ; tout dégât subi pendant l'incantation impose un
jet de concentration dont le DD dépend de la situation (`_concentration_dc`) :
`10 + dégâts + niveau` si blessé, `10 + CMB de l'adversaire + niveau` si agrippé,
sinon `15 + 2×niveau` (incantation défensive), jet = d20 + niveau + mod de CON
(+4 avec le don Combat Casting). 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). `default_policy` choisit soin / offensif / buff via
`_find_heal_spell`, `_find_offensive_spell` et `_find_ally_needing_heal`.
- 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 : 18 conditions prédéfinies (`conditions.py`). Leurs drapeaux
comportementaux sont appliqués par le moteur : `cant_act` (étourdi, paralysé, dazed,
endormi) annule le tour ; `cant_attack` (nauséeux) n'autorise qu'une unique action
simple et bloque toute attaque / manœuvre ; `staggered` restreint à une action
simple (pas d'attaque à outrance) ; `cant_move` bloque le déplacement ;
`denies_dex_to_ac` (à plat ventre, paralysé…) fait perdre le bonus de Dextérité à
la CA (CA flat-footed). À plat ventre : −4 CA et −4 aux attaques à distance.
Agrippé : −4 Dextérité, −2 attaque/CA/Réflexes, sans déplacement ni AoO.
- 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) :
trip → `prone` ; disarm → `disarmed` (−4 attaque) ; bull_rush → pousse la cible ;
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()`. overrun →
`prone` 1 round + passage au travers si la case au-delà est libre ; dirty trick →
`dazzled` 1 round ; steal → l'arme de mêlée de la cible est volée (exclue de
`usable_attacks()`) ; reposition → déplacement de 5 ft de la cible vers le
premier voisin libre ; 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, sur une sauvegarde réussie,
ne font que la moitié des dégâts.
- Dons : les 8 PJ portent 55 noms de dons uniques ; le chargeur résout chacun d'eux
soit vers un effet réel (Power Attack actif, Weapon Focus ciblé par arme, Toughness
lié au niveau, dons passifs via `_stat_modifiers`), soit vers un « no-op » tracé en
toute transparence (noms décoratifs / de race) — aucun n'est silencieusement ignoré
(`tests/test_feats.py`).
- Capacités de classe émises par `default_policy`, chacune conditionnée à une ressource
`> 0` dépensée dans la méthode d'exécution : `channel_energy` (oni, énergie positive :
si un allié est < 50 % de ses PV → soigne tous les êtres vivants à 30 ft ; sinon, si au
moins 2 morts-vivants ennemis sont groupés à 30 ft → les blesse en 2d6 ; le camp de la
cible n'est pas pris en compte — seul `creature_type` compte, `selective_channeling` non
modélisé), sortilège `evil_eye` (nairda), deed `up_close_and_deadly`
(tammara, action bonus +1d6 à ≤ 30 ft), `inspire_courage` + `spell_combat` (misty)
— couvertes par `tests/test_class_abilities.py`.
- Le Liche (`data/monsters/lich.json`) : `creature_type: "undead"` (sensible à l'énergie
positive canalisée), aura de terreur (30 ft, Volonté DD 22 ou
`shaken` 1 round, au début de son tour) et contact paralysant (au toucher,
`paralysé` 1d4+1 rounds), RD 15/magie+contondant ; son JSON porte un dictionnaire
`spell_slots` sans clés dupliquées.
Non modélisé (couches `elevation`/`markers` présentes mais non appliquées
dans la résolution) :
- Effets mécaniques de hauteur/élévation (les hauteurs sont validées mais non
appliquées au déplacement, à la portée ou à la ligne d'effet).
- Tailles Large+ (2×2), allonge > 5 ft (la mêlée reste limitée à une case) et
actions immédiates hors-tour (consommeraient le prochain swift).
- Manœuvres : dirty trick applique toujours `dazzled` (le choix
aveuglé/assourdi/entravé/malade n'est pas modélisé) ; reposition et drag
utilisent une direction déterministe (premier voisin libre / au-delà de la
cible) ; un objet volé n'est pas récupérable en cours de combat. Une arme
brisée ne peut pas être réparée en cours de combat.
## 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`, 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 +4 CA (terrain et 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), états de vie, incantation
(emplacements, concentration par situation, AoO d'incantation), invocation,
manœuvres, capacités de classe, transcripts, politique par défaut. 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) et 18 conditions prédéfinies
(shaken, sickened, fatigued, exhausted, entangled, dazzled, blinded,
deafened, flat-footed, prone, stunned, paralyzed, nauseated, dazed,
staggered, frightened, panicked, cowering). 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, declared as free action at turn
start via `state.power_attack`). Les modificateurs sont collectés par
`_stat_modifiers` avec filtrage par arme (`weapon_filter`). Le champ
`features` de `Combatant` contient les aptitudes permanentes ; le chargeur
(`loaders/combatant.py`) résout les noms de dons des fiches vers des
`AbilitySpec` réels ou des « no-op » tracés.
- `spells.py` — sorts : `SpellSpec` (dataclass avec niveau, école, portée,
sauvegarde, DC de base) et types d'effets (`DamageEffect`, `ConditionEffect`,
`HealEffect`, `BuffEffect`). Portées PF1e (personal, touch, close, medium,
long) calculées par `spell_range_ft`. Chargeur JSON (`load_spell`,
`load_spell_registry`) pour `data/spells/`. Des descripteurs de sauvegarde
(`spell_save_descriptors` / `save_descriptors_for_effects` : types de dégâts,
conditions de peur, écoles enchantement/divination) sont dérivés des effets
pour évaluer les modificateurs conditionnés lors d'une sauvegarde. Le moteur
intègre les sorts via `Action(kind="cast_spell")` et `_cast_spell` :
résolution de sauvegarde, application des dégâts (aucun dégât sur sauvegarde
réussie pour un effet ciblé — la sauvegarde annule ; demi-dégâts seulement si
l'effet déclare `half_on_save` ; la RD ne s'applique pas aux dégâts de sorts),
conditions, soins (plafonnés à hp_max) et buffs. Le champ `spells` de
`Combatant` contient les noms de sorts connus ; le `spell_registry` est passé
au `CombatEngine`.
- `metrics.py` — statistiques en forme fermée : `win_rate`, `win_rate_sigma`,
`win_rate_band` (bande 3σ bornée à [0, 1]).
- `runner.py` — `EncounterSpec`/`Side`, `build_states` (placement en zone +
désambiguïsation des ids), `run_encounter` (un `SeededRng` par run) et
agrégation en `EncounterReport` avec attrition par combattant.
- `cli.py` — front-end argparse `pf1e-sim`, rapport français, code de sortie 2
en cas d'erreur.
- `loaders/` — `combatant.py` (chargeur unifié PJ + monstres avec résolution
des dons → `AbilitySpec` et des sorts → `Combatant.spells`) et
`foundry.py` (outil de migration one-shot depuis les fiches Foundry).
## Développement
La gate de validation complète (tests + lint + types) :
```bash
uv run pytest -q # 480 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/`, 11 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 ». Restent hors du socle (voir « Non modélisé ») : les
hauteurs/élévations, tailles Large+, allonges > 5 ft et actions immédiates
hors-tour.
- **Phase 2** — couche tactique LLM : stratégies en langage naturel traduites
en politiques, balayage de matrices de positionnement. (Non implémentée.)
- **Phase 3** — rapporteur LLM local : agrégation des statistiques et
rédaction du rapport d'équilibrage en français. (Non implémentée.)