885 lines
54 KiB
Markdown
885 lines
54 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 (+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.
|
||
`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 ; la RD s'applique après
|
||
multiplication, les types perforants annulent la RD, 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 −
|
||
|hp|` 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 au seuil existant `hp < min(-10, -CON)` — divergence RAW
|
||
documentée : la règle du jeu meurt quand les pv négatifs atteignent le score
|
||
de CON (le moteur est donc d'un pv plus permissif) ; sémantique inchangée.
|
||
- 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.
|
||
- 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. `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` (−4 CA, −4 aux
|
||
attaques à distance) s'appliquent immédiatement. 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 les filtres staggered/nauseated (ou swift consommé par une immédiate) →
|
||
`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) ; 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 : 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.
|
||
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.
|
||
- É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).
|
||
- 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) ; 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). À 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.
|
||
- 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). 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 (`resistances` : soustraction du maximum parmi les types du dégât,
|
||
plancher 0) ; les types physiques (contondant/perforant/tranchant) restent sur le
|
||
chemin de la RD. La résistance énergétique s'applique au dégât final (même étape
|
||
que la RD), y compris aux dégâts de sorts et aux composantes énergétiques des
|
||
armes.
|
||
- Concealment (chance de rater) : les conditions `blurred` (20 %, sort flou) et
|
||
`displaced` (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.
|
||
- 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) 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.
|
||
- 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).
|
||
- 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 : 23 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) ; `miss_chance` (flou 20 %, déplacement 50 %) impose la
|
||
chance de rater du concealment. À 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 — 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)
|
||
— 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,
|
||
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, 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)),
|
||
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).
|
||
- 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 23
|
||
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_land_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`,
|
||
`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 23 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, activé comme action libre via le
|
||
kind `activate_power_attack` qui pose `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. 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.)
|