621 lines
34 KiB
Markdown
621 lines
34 KiB
Markdown
# 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 (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 +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) sont modélisées via une politique
|
||
de réaction opt-in (`reaction_policy`) — voir « Actions immédiates » ci-dessous.
|
||
- 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 effective = max(allonge naturelle du gabarit (Small/Medium
|
||
5 ft, Large 10 ft, Huge 15 ft, Gargantuan 20 ft, Colossal 30 ft), `reach_ft`
|
||
de l'arme). Une arme à allonge (`reach_ft` > 5) menace l'anneau de cases à
|
||
distance égale à son allonge — les cases à 5 ft sont une « dead zone » où
|
||
elle ne frappe pas ; une arme sans allonge menace les cases adjacentes.
|
||
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.
|
||
- 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.
|
||
- É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).
|
||
- 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 cases à distance de son allonge effective (les 8 cases adjacentes pour
|
||
une arme sans allonge ; l'anneau à son allonge pour une arme reach — la
|
||
dead zone de 5 ft 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 ; (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 (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 →
|
||
`prone` 1 round + passage au travers si la case au-delà est libre ; 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, 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.
|
||
- 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,
|
||
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), 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, 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)).
|
||
- 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.
|
||
- Top-level : round, acting_combatant, combatants, pairs, terrain, elevation,
|
||
threatened (grille bool des cases menacées par les ennemis),
|
||
terrain_semantics (un dict par type de terrain : type, move_cost,
|
||
blocks_los, cover), condition_definitions (C2 : tuple trié de (nom,
|
||
{mods, flags, speed_mult}) — définitions complètes des 21 conditions de
|
||
CONDITIONS_BY_NAME pour que la LLM interprète les drapeaux et
|
||
modificateurs).
|
||
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_land_ft`
|
||
suffisent), pas de couvert par paire (déductible via `has_line_of_effect` +
|
||
`terrain_semantics` + positions). Les API de décision exposées (items L1-L8)
|
||
s'y retrouvent : `direction`/`trick_condition`/`weapon_id` sur `Action`,
|
||
`can_charge` → `ChargeResult`, kinds `retrieve_weapon`/`repair_weapon`.
|
||
|
||
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 +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), 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) 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 # 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/`, 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 ». 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.
|
||
- **Phase 3** — rapporteur LLM local : agrégation des statistiques et
|
||
rédaction du rapport d'équilibrage en français. (Non implémentée.)
|