feat(combat): wire corner-rule LoS/cover into attack resolution

- weapon_for: gate attacks on has_line_of_effect (no LoE -> policy moves)
- resolve_attack: +4 AC cover bonus on hit and crit-confirm (ranged flag)
- tests: cover bonus (ranged pillar, melee wall corner), no-LoE move/attack,
  no-LoE unreachable wait; 11 resolve_attack call sites updated
- README: quick-start and verified example re-measured (55.7% 1x2), rules
  and architecture updated (los.py wired)
This commit is contained in:
2026-08-17 22:49:50 +02:00
parent a21e234b41
commit e17c571d7e
3 changed files with 436 additions and 18 deletions
+323
View File
@@ -0,0 +1,323 @@
# 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(1p)/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 : 100.0% (1000) — bande 3σ : [100.0%, 100.0%]
Victoires monsters : 0.0% (0) — bande 3σ : [0.0%, 0.0%]
Nuls : 0.0% (0)
Rounds moyens : 5.8
```
## 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(1p)/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 55,7 %, bande 3σ [51,0 % ; 60,4 %], 0 nuls, 13,1 rounds moyens.
Sans ligne de visée depuis la zone de départ, l'archer gobelin doit contourner
le mur central avant de tirer — c'est ce qui coûte des rounds.
## Formats de données
### Carte (YAML)
Chaque couche est un bloc ASCII de mêmes dimensions ; un caractère = une case
de `square_size_ft` pieds. Seule `terrain` est obligatoire.
```yaml
name: "Arène d'essai"
square_size_ft: 5
terrain: | # couche obligatoire
####################
#......######......#
#..C...######...C..#
#......~..~........#
#..TT..~..~...TT...#
#......~..~........#
#..C............C..#
####################
legend: # un caractère = un type de terrain
"#": { type: wall, move_cost: null, blocks_los: true } # bloque déplacement + LoS
".": { type: floor, move_cost: 1 }
"T": { type: rubble, move_cost: 2 } # terrain difficile
"~": { type: water, move_cost: 2 }
"C": { type: pillar, move_cost: null, cover: true } # bloque le pas, couvert
elevation: | # optionnel : chiffres = hauteur (validé, pas encore appliqué)
....................
..1111..............
..1111..............
....................
zones: | # optionnel : lettres de zone de déploiement
....................
.AAAA..........BBBB.
.AAAA..........BBBB.
....................
markers: # optionnel : points nommés (validés, pas encore utilisés)
autel: [1, 16]
deployment: # obligatoire si zones : nom de camp -> lettre de zone
players: A
monsters: B
```
Valeur du `move_cost` : entier > 0 pour un terrain praticable, `null` pour un
terrain infranchissable (mur, pilier). `blocks_los` et `cover` sont appliqués
par la résolution des attaques (module `los.py`, règle des coins) : pas de
ligne d'effet ⇒ l'attaque est impossible ; couvert ⇒ bonus de +4 CA.
### Monstre (JSON)
```json
{
"name": "Goblin",
"level": 1,
"size": "Small",
"cr": "1/3",
"xp": 135,
"source": "Bestiary > Goblin",
"abilities": {
"str_score": 11, "dex_score": 15, "con_score": 12,
"int_score": 10, "wis_score": 9, "cha_score": 6
},
"hp_max": 6,
"ac": { "total": 16, "touch": 13, "flat_footed": 14 },
"bab": 1,
"initiative_mod": 6,
"speed_land_ft": 30,
"saves": { "fort": 3, "ref": 2, "will": -1 },
"attacks": [
{
"name": "short sword",
"kind": "melee",
"attack_bonus": 2,
"damage": [{ "formula": "1d4", "types": ["slashing"] }],
"crit_range": 19,
"crit_mult": 2
},
{
"name": "short bow",
"kind": "ranged",
"attack_bonus": 4,
"damage": [{ "formula": "1d4", "types": ["piercing"] }],
"crit_mult": 3,
"range_increment_ft": 60
}
]
}
```
Champ `range_increment_ft` (optionnel, uniquement pour `kind: ranged`) : portée
maximale de tir — au-delà, l'arme n'est pas utilisable. `damage_bonus` et `dr`
sont optionnels. Deux monstres d'exemple sont fournis : gobelin et orc.
### Fiches de personnages
`fiches_personnages/` contient 8 fiches de PJ au format Foundry VTT
(`pf1-sheet/v1`) ; le chargeur `loaders/foundry.py` sait les lire. Les
rencontres CLI se construisent pour l'instant avec des monstres JSON — les
fiches Foundry alimenteront la couche PJ dans une phase ultérieure.
## Règles implémentées et limites de la Phase 0
Règles modélisées :
- 1 naturel = échec automatique ; 20 naturel = touche + menace de critique.
- Critique : (dés + bonus) × multiplicateur ; la RD s'applique après
multiplication, les types perforants annulent la RD, les dégâts plancher à 0.
- Mort quand `hp < min(-10, -CON)` ; `hp ≤ 0` = inactif.
- Initiative : triée sur (total, modificateur, ordre de liste), sans re-jet.
- Une action par tour : se déplacer OU attaquer.
- Mêlée : allonge d'une case ; mouvement : un pas par action, le long du plus
court chemin réel (champ de coût Dijkstra depuis la cible — les combattants
contournent les murs au lieu d'osciller contre eux).
- Ligne d'effet : une cible entièrement derrière un terrain `blocks_los` ne
peut pas être attaquée — la politique se déplace jusqu'à gagner une ligne de
visée.
- Couvert (règle des coins) : la cible gagne +4 CA sur la touche et la
confirmation de critique ; le couvert octroyé par les créatures n'est pas
modélisé.
- Distance : l'arme doit être à portée (range_increment_ft) ; aucune pénalité
de portée au-delà du premier incrément.
Non modélisé en Phase 0 (couches `elevation`/`markers` présentes mais non
appliquées dans la résolution) :
- Sorts, jets de sauvegarde, conditions et états.
- Attaques d'opportunité, flanquement, manœuvres de combat.
- Pénalités de portée (distance) et couvert mou des créatures.
- Effets mécaniques de hauteur/élévation.
- Tailles Large+ (2×2), allonge > 5 ft, attaques itératives (une attaque par
tour), économie d'action complète (charge, pas de placement…).
## 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`, `DamageComponent`, `Saves`, `DamageReduction`.
- `map.py``MapSpec`/`TerrainType`, chargement YAML et validation
(dimensions, caractères connus, zones, déploiement, markers).
- `grid.py` — grille 5-10-5 : `distance`, `step_cost`, règle des coins
(`diagonal_allowed`), Dijkstra `reachable` avec ou sans budget.
- `los.py` — ligne d'effet et couvert par la règle des coins : gate de ligne
d'effet dans `weapon_for`, bonus de couvert +4 CA dans `resolve_attack`.
- `combat.py``CombatEngine` déterministe : initiative, actions, résolution
des attaques (couvert, ligne d'effet), états de vie, transcripts, politique
par défaut.
- `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/``foundry.py` (fiches Foundry `pf1-sheet/v1`) et `monster.py`
(JSON de monstre).
## Développement
La gate de validation complète (tests + lint + types) :
```bash
uv run pytest -q # 170 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 : jets de sauvegarde, sorts modélisés comme
effets paramétrés, conditions, flanquement, attaques d'opportunité.
- **Phase 2** — couche tactique LLM : stratégies en langage naturel traduites
en politiques, balayage de matrices de positionnement.
- **Phase 3** — rapporteur LLM local : agrégation des statistiques et
rédaction du rapport d'équilibrage en français.