ctan e17c571d7e 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)
2026-08-17 22:49:50 +02:00
2026-08-17 22:49:50 +02:00
2026-08-17 22:49:50 +02:00

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 (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 : 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.

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) : 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.pySeededRng (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.pyMapSpec/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.pyCombatEngine 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.pyEncounterSpec/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) :

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.
S
Description
Scripts de simulation de combats PF 1e
Readme 843 KiB
Languages
Python 100%