19 KiB
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ésmarkers, 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 (gestionnaire de paquets et d'environnements)
Installation :
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 :
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 :
uv run pf1e-sim \
--map data/maps/sample_arena.yaml \
--side players data/monsters/goblin.json data/monsters/goblin.json data/monsters/goblin.json \
--side monsters data/monsters/orc.json data/monsters/orc.json \
--runs 1000 --seed 1
Sortie (extrait) :
=== Rapport d'équilibrage ===
Carte : Arène d'essai
players : 3 combatants
monsters : 2 combatants
1000 combats simulés (seed 1, plafond 100 rounds)
Victoires players : 91.4% (914) — bande 3σ : [88.7%, 94.1%]
Victoires monsters : 8.6% (86) — bande 3σ : [5.9%, 11.3%]
Nuls : 0.0% (0)
Rounds moyens : 6.0
Utilisation de la CLI
usage: pf1e-sim [-h] --map MAP [--side NAME [FILE ...]] [--runs RUNS]
[--seed SEED] [--round-cap ROUND_CAP]
| Option | Rôle | Défaut |
|---|---|---|
--map MAP |
Fichier YAML de la carte (obligatoire) | — |
--side NAME [FILE ...] |
Camp NAME avec un ou plusieurs JSON de monstres ; répétable pour chaque camp |
— |
--runs RUNS |
Nombre de combats simulés | 1000 |
--seed SEED |
Graine du générateur aléatoire | 1 |
--round-cap ROUND_CAP |
Plafond de rounds par combat (au-delà, combat nul) | 100 |
Codes de sortie :
0— simulation terminée, rapport imprimé.2— erreur d'utilisation ou de données (message sur stderr) : côté inconnu, côté de déploiement manquant, zone trop petite, fichier invalide…
Règles de construction d'une rencontre :
- Les noms de camps (
--side NAME) doivent correspondre exactement à la sectiondeploymentde 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.
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)
{
"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) :
incrément de portée en pieds. La pénalité de portée vaut −2 par incrément
complet au-delà du premier ; l'arme reste utilisable jusqu'à 10 incréments,
au-delà elle n'est pas sélectionnable. damage_bonus et dr sont optionnels.
Deux monstres d'exemple sont fournis : gobelin et orc.
Fiches de personnages (PJ)
fiches_personnages/ contient les 8 fiches de PJ au format Foundry VTT
(pf1-sheet/v1) ; le chargeur loaders/foundry.py sait les lire. Elles sont
transposées en JSON au schéma Combatant dans data/pcs/ (esha.json,
harvie.json, ierlieth.json, jeanne.json, misty.json, nairda.json,
oni.json, tammara.json) et se passent directement à la CLI comme les
monstres :
uv run pf1e-sim --map data/maps/sample_arena.yaml \
--side players data/pcs/oni.json \
--side monsters data/pcs/tammara.json --runs 200
Les armes placeholder des fiches sans dégâts encodés sont omises (notées
dans notes le cas échéant) ; le champ source de chaque fichier pointe
vers la fiche Foundry d'origine.
Règles implémentées et limites de la Phase 0
Règles modélisées :
- 1 naturel = échec automatique ; 20 naturel = touche + menace de critique.
- Critique : (dés + bonus) × multiplicateur ; la RD s'applique après multiplication, les types perforants annulent la RD, les dégâts plancher à 0.
- Mort quand
hp < min(-10, -CON);hp ≤ 0= inactif. - Initiative : triée sur (total, modificateur, ordre de liste), sans re-jet.
- Économie d'action : un tour normal donne une action standard + une action de
mouvement (ou une action à round complet), plus swift/free/immediate. La
politique renvoie une séquence ordonnée d'actions exécutées dans l'ordre ;
default_policyrenvoie(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) ne sont pas encore modélisées. - Attaques multiples par action : une arme avec
count> 1 (« 2x Talons ») résoutcountbalayages indépendants dans la même action ; les balayages restants sont perdus si la cible tombe (inconsciente ou morte) en cours de rafale. Attaque à outrance (action à round complet) : à BAB +6/+11/+16, des attaques supplémentaires à -5/-10/-15 ; les armes naturelles (count> 1) ne gagnent pas d'itératifs. Plusieurs armes naturelles en une seule attaque à outrance ne sont pas modélisées. - Mêlée : allonge d'une case ; 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.
- Ligne d'effet : une cible entièrement derrière un terrain
blocks_losne peut pas être attaquée — la politique se déplace jusqu'à gagner une ligne de visée. - Couvert (règle des coins) : la cible gagne +4 CA sur la touche et la confirmation de critique ; couvert mou : une créature active entre l'attaquant et la cible octroie aussi +4 CA aux attaques à distance (les attaques de mêlée ignorent les créatures).
- Flanquement : +2 au jet d'attaque de mêlée si un allié actif (doté d'une arme de mêlée) menace la cible depuis la bordure ou le coin opposé. Les attaques à distance ignorent le flanquement ; un allié désarmé ou à distance ne compte pas pour le flanquement.
- Attaques d'opportunité : un combattant actif doté d'une arme de mêlée menace
les 8 cases adjacentes. Deux déclencheurs sont modélisés : (1) quitter une
case menacée — le mouvement est résolu pas à pas, et chaque ennemi qui menace
la case actuelle porte une AoO avant que le mouvant ne la quitte ; (2) tirer
avec une arme à distance depuis une case menacée — chaque ennemi menaçant
porte une AoO avant chaque tir. Une AoO par combattant par round (piste
_aoo_used, réinitialisée au début du round) ; les AoO sont toujours portées (PF1e permet de les décliner, le simulateur ne le fait pas). Le pas de placement (5 ft) ne provoque jamais d'AoO (voir ci-dessous) ; la retraite ne protège que la case de départ (voir ci-dessous). - Charge : action à round complet. Le combattant se déplace en ligne droite
(Bresenham) jusqu'à 2× sa vitesse vers la case la plus proche d'où il peut
frapper la cible en mêlée, puis porte une seule attaque de mêlée à +2. La
charge impose −2 CA à l'attaquant jusqu'au début de son prochain tour. La
ligne droite doit être dégagée (pas de terrain difficile, d'obstacles ni de
créatures) ; distance minimum 2 cases (10 ft).
default_policychoisit 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_policyne choisit pas la retraite (disponible via une politique personnalisée). - Pas de placement (5-foot step) : action libre qui déplace le combattant d'une
case vers l'ennemi le plus proche sans provoquer d'AoO. Exclusivité mutuelle
avec tout autre mouvement (move, charge, retraite) dans le même tour —
tracked via
moved_this_turn, réinitialisé au début du tour.default_policychoisit(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 ; l'arme ranged reste utilisable jusqu'à 10 incréments. Les armes de jet (5 incréments max) ne sont pas distinguées des armes à projectiles.
- 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 (StatModifieravectargetcorrespondant) sont appliqués viaresolve_modifiers(mêmes règles d'empilement que les attaques). Renvoie unSaveResult(success, roll, total, dc).
Non modélisé (couches elevation/markers présentes mais non appliquées
dans la résolution) :
- Sorts (le système d'effets
effects.py, les règles d'empilement des bonus, les jets de sauvegarderesolve_save, le système de conditionsconditions.pyet les dons passifsabilities.pysont en place — l'intégration des sorts au moteur est en cours). - Manœuvres de combat.
- Effets mécaniques de hauteur/élévation.
- Tailles Large+ (2×2), allonge > 5 ft et actions immédiates hors-tour.
Architecture
dice.py— parseur de notation de dés (2d4+1) et lancer.rng.py—SeededRng(flux reproductible par graine) etScriptedRng(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), Dijkstrareachableavec ou sans budget.los.py— ligne d'effet et couvert par la règle des coins : gate de ligne d'effet dansweapon_for, bonus de couvert +4 CA (terrain et couvert mou des créatures) dansresolve_attack.combat.py—CombatEnginedéterministe : initiative, actions, résolution des attaques (couvert, ligne d'effet, pénalités de portée), états de vie, transcripts, politique par défaut.effects.py— système d'effets :StatModifier(modificateur de caractéristique avec type de bonus) etresolve_modifiers(application des règles d'empilement PF1e). Types de bonus cumulables (dodge, racial, trait, sans type) s'additionnent ; types non-cumulables (morale, sacré, profane, enhancement…) gardent la valeur la plus élevée. Les pénalités suivent les mêmes règles. Fondation pour sorts, conditions, dons et capacités de classe.conditions.py— conditions PF1e :Condition(dataclass avec modificateurs + flags comportementaux) et 18 conditions prédéfinies (shaken, sickened, fatigued, exhausted, entangled, dazzled, blinded, deafened, flat-footed, prone, stunned, paralyzed, nauseated, dazed, staggered, frightened, panicked, cowering). Les modificateurs sont intégrés àresolve_attack(attaque, dégâts, CA) etresolve_save(jets de sauvegarde) via_stat_modifiersqui collecte effets + conditions.abilities.py— dons et capacités :AbilitySpec(dataclass avec catégorie feat/class/racial/trait) et dons passifs prédéfinis (Weapon Focus, Toughness, Iron Will, Great Fortitude, Lightning Reflexes, Alertness, Point-Blank Shot, Dodge). Les modificateurs sont collectés par_stat_modifiersavec filtrage par arme (weapon_filter). Le champfeaturesdeCombatantcontient les aptitudes permanentes.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(unSeededRngpar run) et agrégation enEncounterReportavec attrition par combattant.cli.py— front-end argparsepf1e-sim, rapport français, code de sortie 2 en cas d'erreur.loaders/—foundry.py(fiches Foundrypf1-sheet/v1) etmonster.py(JSON de monstre).
Développement
La gate de validation complète (tests + lint + types) :
uv run pytest -q # 293 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"sursrcettests. - 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 : sorts modélisés comme effets paramétrés,
conditions, manœuvres de combat, dons et capacités de classe. Flanquement,
attaques à outrance, attaques d'opportunité, charge, retraite et pas de
placement sont déjà modélisés. Le système d'effets (
effects.py), les règles d'empilement des bonus, les jets de sauvegarde (resolve_save), le système de conditions (conditions.py) et les dons passifs (abilities.py) sont en place. - 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.