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