79ac190728
Movement budget is now max(land, fly) speed. Updates the harvie golden: Harvie (land 10 / fly 80) is a flyer and now flies at 80 ft, so he wins vs the goblin instead of being pinned crawling at 10 ft.
906 lines
55 KiB
Markdown
906 lines
55 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.
|
||
Vitesse de déplacement (V4) : le budget de tout mouvement (déplacement,
|
||
charge 2×, retraite 2×, course 4×) est le **meilleur entre la vitesse au
|
||
sol (`speed_land_ft`) et la vitesse de vol (`speed_fly_ft`)** — une créature
|
||
dotée d'une vitesse de vol (harvie : vol 80 vs sol 30, ierlieth : vol 40)
|
||
se déplace donc à sa vitesse de vol. Simplification documentée : voler
|
||
PAR-DESSUS les obstacles ou le terrain difficile n'est PAS modélisé — la
|
||
praticabilité et les coûts de terrain de la grille s'appliquent à tout le
|
||
budget (pas de vol 3D ni de maniabilité) ; le snapshot expose ce budget
|
||
sous la clé additive `speed_effective_ft`.
|
||
Destination explicite : `Action(kind="move", destination=(r, c))` et
|
||
`Action(kind="5foot_step", destination=(r, c))` déplacent le combattant vers
|
||
la case `(r, c)` désignée (monter au couvert, quitter un défilé, se
|
||
désengager) au lieu de auto-diriger vers `target_id`. Pour `move`, la case
|
||
doit être praticable, libre et atteignable dans le budget de vitesse (sinon
|
||
rejet `unreachable_destination`) ; le trajet réutilise la mécanique pas à pas
|
||
avec provocation d'AoO. Pour `5foot_step`, la case doit être exactement
|
||
adjacente (sinon `unreachable_destination`) et le pas ne provoque jamais
|
||
d'AoO. Sans `destination`, le comportement auto-dirigé est inchangé ; avec
|
||
`target_id` et `destination` absents, rejet `missing_target` (U1).
|
||
`default_policy` ne choisit pas de destination (disponible via une politique
|
||
personnalisée).
|
||
- Tailles et occupation multi-cases : une créature occupe un footprint carré à
|
||
son ancre (`pos`) selon sa taille — Small/Medium 1×1, Large 2×2, Huge 3×3,
|
||
Gargantuan 4×4, Colossal 6×6 (taille inconnue → 1×1). Distance, ligne
|
||
d'effet/couvert, flanquement, zones menacées, AoO, charge, mouvement,
|
||
manœuvres et invocation sont généralisés aux cellules occupées (distance =
|
||
minimum entre cellules, LoS/couvert = meilleure paire) ; le comportement 1×1
|
||
reste bit-identique.
|
||
- Taille et attaques/CA : les valeurs `ac` (total/touch/flat_footed) et
|
||
`attack_bonus` des JSON de créatures sont des valeurs FINALES, déjà majorées
|
||
du modificateur de taille PF1e aux attaques/CA (Small +1, Medium 0, Large −1,
|
||
Tiny +2…) — vérifié sur toutes les fiches (`data/monsters/*.json`,
|
||
`data/pcs/*.json`, verrouillé par `tests/test_size_is_baked_in.py`). Le
|
||
moteur n'applique donc JAMAIS de bonus de taille aux jets d'attaque ni à la
|
||
CA (ce serait un double comptage) ; la taille n'intervient dans la
|
||
résolution que pour les manœuvres de combat (CMB/CMD, table
|
||
`_SIZE_MODIFIERS` Small −1 … Colossal +8 — colonne distincte de la table
|
||
attaques/CA), l'allonge naturelle, le footprint et l'occupation.
|
||
- Élévation : monter d'un niveau de hauteur coûte +1 case de mouvement par
|
||
niveau (la descente est gratuite) ; une créature située plus haut que sa
|
||
cible gagne +1 à ses jets d'attaque de mêlée et à la confirmation de
|
||
critique (règle du high ground). La ligne d'effet reste 2D (pas de LoS 3D).
|
||
- Actions immédiates hors-tour : politique de réaction optionnelle
|
||
`reaction_policy(engine, state, event)` (défaut : aucune) ; sur événement
|
||
(`attack_resolved`, `movement_step`, `spell_cast`, `damage_taken`) une
|
||
réaction `Action(kind="immediate")` peut être exécutée hors tour — une par
|
||
événement — et consomme le prochain swift de l'état (immédiate pendant le
|
||
tour : exécutée, consomme aussi le prochain swift — simplification
|
||
documentée). Les actions préparées (`ready`) sont traitées sur ces mêmes
|
||
événements **avant** et indépendamment de la politique de réaction (voir
|
||
« Préparation » plus haut).
|
||
- Ligne d'effet : une cible entièrement derrière un terrain `blocks_los` ne
|
||
peut pas être attaquée — la politique se déplace jusqu'à gagner une ligne de
|
||
visée.
|
||
- Couvert (règle des coins) : la cible gagne le bonus de couvert du terrain
|
||
(+2 partiel, +4 standard, +8 amélioré via `cover_bonus`) sur la touche et la
|
||
confirmation de critique ; couvert mou : une créature active entre
|
||
l'attaquant et la cible octroie aussi +4 CA aux attaques à distance (les
|
||
attaques de mêlée ignorent les créatures).
|
||
- 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,
|
||
speed_effective_ft (V4 : budget de déplacement effectif = max(land, fly),
|
||
en pieds), ac {total,
|
||
touch, flat_footed}, saves {fort, ref, will}, cmb, cmd, attacks
|
||
(référentiel complet : id, name, kind, attack_bonus, damage, damage_bonus,
|
||
crit_range, crit_mult, reach_ft, range_increment_ft, count, firearm —
|
||
y compris les armes broken/stolen), features (noms des capacités),
|
||
feature_details (C4 : tuple de {name, category, source_detail, effects:
|
||
tuple de {target, value, bonus_type, condition, weapon_filter}}),
|
||
spells (résumés enrichis du registre : name, level, school, range_ft,
|
||
save, dc, summary, half_on_save, save_negates (E4), area {shape,
|
||
radius_ft} | None (U7a : premier AreaEffect de niveau supérieur du sort,
|
||
gabarit de zone structuré, None si le sort n'a pas de zone)),
|
||
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_effective_ft`
|
||
suffisent). Le couvert par paire est exposé via la clé `cover_bonus`
|
||
(calcul identique à `resolve_attack`, couvert mou des créatures inclus).
|
||
Les API de décision exposées (items L1-L8)
|
||
s'y retrouvent : `direction`/`trick_condition`/`weapon_id` sur `Action`,
|
||
`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.)
|