Compare commits

..

1 Commits

Author SHA1 Message Date
ctan f61fc5c61f docs: reorganize documentation structure
- Clean README.md with installation, usage, CLI, examples
- Move data formats to docs/data-formats/ (map.md, monster.md, pc-sheets.md, index.md)
- Move architecture to docs/architecture/ (index.md, technical-architecture.md, phase-2-llm-architecture.md)
- Add contributing guidelines in docs/contributing.md
- Clean README.md: install, usage, CLI, data-formats/rules/architecture links, contributing link
2026-09-26 20:12:05 +02:00
9 changed files with 764 additions and 2304 deletions
+37 -2304
View File
File diff suppressed because it is too large Load Diff
+67
View File
@@ -0,0 +1,67 @@
# Architecture & Plan d'implémentation
## Vue d'ensemble
- [Architecture technique](technical-architecture.md) — modules, flux de données, interfaces
- [Plan Phase 2 (LLM)](phase-2-llm-architecture.md) — étapes P5.1–P5.3, décisions, risques
- [Conformité SRD](conformance-srd.md) — écarts documentés, corrigés, hors périmètre
- [Feuille de route](roadmap.md) — Phases 0, 1, 2, 3 et au-delà
## Structure du code
```
src/pf1e_simulator/
├── combat.py # CombatEngine, résolution attaques, actions, states
├── dice.py # parseur notation dés (2d4+1)
├── rng.py # SeededRng, ScriptedRng
├── models.py # schémas Pydantic v2 (Combatant, AttackSpec, etc.)
├── map.py # MapSpec, chargement YAML, validation
├── grid.py # grille 5-10-5, Dijkstra, airborne_view
├── los.py # ligne d'effet, couvert (règle des coins)
├── areas.py # cône, ligne, burst (géométrie zones)
├── effects.py # StatModifier, resolve_modifiers (empilement bonus)
├── conditions.py # 29 conditions PF1e + flags comportementaux
├── abilities.py # dons (Weapon Focus, Power Attack, etc.)
├── spells.py # SpellSpec, effets, scalage, métamagie
├── metrics.py # win_rate, win_rate_sigma, win_rate_band
├── runner.py # EncounterSpec, run_encounter, EncounterReport
├── objectives.py # primitives Phase 2 (P(survie), P(TPK), etc.)
├── phase2_spike.py # politique "focus fire", danger cartography
├── cli.py # argparse, rapport FR, flags LLM
├── loaders/
│ ├── combatant.py # chargeur PJ + monstres + dons/sorts
│ └── foundry.py # migration Foundry (one-shot, incomplet)
└── report.py # LLM report writer (Phase 3)
```
## Décisions clés (D-table)
| ID | Domaine | Décision |
|----|---------|----------|
| D1 | Économie d'action | Résolue par deux étages (budget général + filtres spécialisés) |
| D2 | Cartographie danger | Non exhaustive : région d'échec + frontière + sensibilité |
| D3 | Priorité v1 | Cartographie d'abord, puis optimiseur (mêmes briques) |
| D4 | Fournisseur/modèle | Client agnostique ; implémentation distante d'abord, locale ensuite ; mode JSON/schéma requis |
| D5 | Budget | Plafonds petits (≤ 50 appels/campagne, ≤ 20 combats vitrine) + coupe-circuit |
| D6 | Reproductibilité | Cache obligatoire ; clé composite incluant le modèle ; replay `call_seq` |
| D7 | Prompts | Externes / dépôt séparé (voir §14) |
## Gate de validation
```bash
uv run pytest -q
uv run ruff check src tests
uv run basedpyright src
```
- Ruff : `select = ["ALL"]`, longueur ligne 100
- Basedpyright : `typeCheckingMode = "strict"` sur `src` et `tests`
- Transcripts épinglés + assertions statistiques (matchup symétrique dans bande 3σ)
## Feuille de route
- **Phase 0** ✅ — socle combat (initiative, attaques, critiques, RD, états, déplacement, mêlée/distance)
- **Phase 1** ✅ — magie/états (108 sorts, DD/sauvegardes, concentration, invocation, conditions, manœuvres, dons, capacités de classe)
- **Phase 2** ✅ — couche tactique LLM (few-shot, réparation bornée, contrat Observation/LLMPolicy)
- **Phase 3** ✅ — rapporteur LLM local (agrégation stats + rédaction FR, `report.py`, `--llm-report`)
- **Phase 4** (futur) — optimiseur de scénarios, campagnes multi-rencontres
@@ -0,0 +1,183 @@
# Architecture Phase 2 — Couche tactique LLM
> Document consolidé depuis `phase-2-llm-architecture.md` (historique complet conservé dans l'original à la racine de `docs/`)
---
## 1. Vision générale
La Phase 2 ajoute une **couche tactique LLM** au simulateur déterministe Phase 0/1 :
- **P5.1** ✅ Fournisseur réel (`ProviderClient`) — client OpenAI-compatible HTTP, cache, budget, observabilité
- **P5.2** ✅ Few-shot + réparation bornée — prompts externes versionnés, boucle `max_repairs` tracée par `NLPolicyStats`
- **P5.3** ✅ Rapporteur LLM local — agrégation stats + rédaction FR (`report.py`, `--llm-report`)
Le contrat d'observation (`Observation` + wrapper `LLMPolicy`) est en place ; la couche LLM elle-même est désormais implémentée.
---
## 2. Contraintes & décisions arbitrées (D-table)
| ID | Domaine | Décision |
|----|---------|----------|
| D1 | Économie d'action | Résolue par deux étages (budget général + filtres spécialisés) |
| D2 | Cartographie danger | Non exhaustive : région d'échec + frontière + sensibilité |
| D3 | Priorité v1 | Cartographie d'abord, puis optimiseur (mêmes briques) |
| D4 | Fournisseur/modèle | Client agnostique ; implémentation distante d'abord, locale ensuite ; **mode JSON/schéma requis** |
| D5 | Budget | **Plafonds petits** (≤ 50 appels de compilation/campagne, ≤ 20 combats en vitrine) + coupe-circuit dans le client |
| D6 | Reproductibilité | **Cache obligatoire** ; clé composite incluant le modèle ; replay `call_seq` |
| D7 | Prompts | **Externes / dépôt séparé** (voir §14) |
---
## 3. P5.1 — ProviderClient (fournisseur réel)
**Fichier** : `src/pf1e_simulator/provider.py` — `OpenAILLMClient`
- Speaks OpenAI chat-completions wire format over HTTPS (stdlib `urllib.request`)
- Base URL, model, API key résolus depuis l'env (`config.py` : `PF1E_LLM_API_KEY`, `PF1E_LLM_BASE_URL`, `PF1E_LLM_MODEL`)
- Aucune dépendance HTTP externe
- Error taxonomy : `ProviderError` (transient 429/5xx), `ProviderRejectedError` (4xx permanent), `ProviderResponseError` (2xx body invalide)
### Safeguards intégrés (dans `LLMClient` base class)
- **Cache** : `ResponseCache` (clé composite = modèle|params|prompt_version|schema|prompt_hash), sharded, thread-safe
- **Budget** : `CostBudget` (max_calls, max_total_tokens, max_cost_usd) — appliqué avant chaque appel
- **Retry/Backoff** : `retries` (défaut 2), `backoff_base_s` (0.25s), exponentiel
- **Circuit Breaker** : `CircuitBreaker` (seuil échecs consécutifs, probes après refus)
### Interface
```python
class LLMClient(ABC):
def __init__(self, *, model: str, prompt_version: str, ...)
def complete(self, prompt: str, params: ClientParams | None = None) -> str
```
`prompt_version` = `version:name:hash12` (pack version + nom prompt + hash contenu) — requis pour validation cache D6.
---
## 4. P5.2 — Few-shot + réparation bornée
### PromptPack externe (§14)
- Répertoire pointé par `PF1E_PROMPT_PACK_DIR` (hors repo)
- Layout : `version.txt` + `<name>.md` + `<name>.examples.json` (optionnel)
- `PromptPack.load(root)` : strict, pas de fallback, lève `PromptPackError` si invalide
- `prompt_version = version:name:hash12` (hash couvre prompt + exemples)
### NLPolicy
```python
@dataclass(frozen=True)
class NLPolicy:
client: LLMClient
builder: PromptBuilder
prompt_name: str = "nl_default"
fallback: Fallback = fallback_default
max_repairs: int = 1
stats: NLPolicyStats = field(default_factory=NLPolicyStats)
def __call__(self, observation: Observation) -> tuple[Action, ...]:
# 1. build prompt via PromptBuilder
# 2. client.complete(prompt)
# 3. parse_actions() → tuple[Action]
# 4. si échec → retry jusqu'à max_repairs (injecte erreur + instruction)
# 5. fallback scripté si épuisé
```
- `NLPolicyStats` : `calls`, `repairs_attempted`, `repairs_succeeded`, `fallbacks` — « matière des rapports de P5.3 »
- `FakeLLMClient` pour tests 100% offline (script de réponses, zéro réseau)
---
## 5. P5.3 — Rapporteur LLM local (Phase 3)
**Module** : `src/pf1e_simulator/report.py`
### Structures
- `ReportContext` : agrège `EncounterReport`, `DangerMetrics`, `NLPolicyStats`, `ClientStats`, `MatchupBenchmark`, `DangerCell`, `SensitivityEntry`
- `build_report_context()` : fonction pure, calcule `DangerMetrics` depuis `RunOutcome` si fourni
- `build_report_prompt(ctx, template)` : rend rapport factuel + danger + robustesse + observabilité LLM
- `LLMReportPolicy` : distinct de `NLPolicy` (sortie texte libre, retry simple, `ReportStats`)
- `ReportBudget` : plafonds vitrine (`max_calls=1`, ajusté pour retries via `for_retries()`)
- `format_llm_report(ctx, llm_text)` : préfixe factuel + `=== Récit d'équilibrage (LLM) ===` + narratif
### CLI
- `--llm-report` : active mode LLM (exclusif avec `--transcript`)
- `--output FILE.md` : écrit rapport complet (factuel + LLM) dans fichier
- `--llm-model`, `--llm-base-url`, `--llm-api-key` : overrides CLI
- Exige `PF1E_LLM_API_KEY` + `PF1E_PROMPT_PACK_DIR` env
### Pack FR
- `tests/fixtures/prompt_pack_fs/report.md` : narrateur FR, structure 4 sections (Bilan, Danger, Robustesse, Observabilité)
- `report.examples.json` : 2 exemples few-shot
- Découvert via `PromptPack` comme `nl_default`
---
## 6. Risques & mitigations
| Risque | Impact | Mitigation |
|--------|--------|------------|
| Bandes 3σ trompeuses à petit n | Mauvaises conclusions quantitatives | Étage A pour le quantitatif (formule fermée) |
| Événements rares (TPK) | Monte Carlo naïf inefficace | Ciblage par scénarios, cartographie danger |
| Explosion combinatoire | Énumération impossible | Coarse-to-fine (danger_map, frontier, sensitivity) |
| Prompt trop long | Coût/tokens élevés | Vue compacte ~4k tokens, budget ≤ 50 appels |
| Hallucination chiffres | Rapport non fiable | Injection stats verbatim, pas d'invention |
| Tests flaky (texte libre) | CI instable | `FakeLLMClient` scripté + assertions structure |
---
## 7. Intégration CLI (P5.3)
```bash
# Variables d'env requises
export PF1E_LLM_API_KEY=sk-...
export PF1E_PROMPT_PACK_DIR=/chemin/vers/prompt-pack
# Rapport LLM + sortie fichier
uv run pf1e-sim \
--map data/maps/sample_arena.yaml \
--side players data/monsters/goblin.json \
--side monsters data/monsters/orc.json \
--runs 100 --seed 1 \
--llm-report --output rapport.md
```
Options ajoutées :
- `--llm-report` : active LLM (exclusif `--transcript`)
- `--output FILE.md` : écrit rapport complet (factuel + LLM)
- `--llm-model`, `--llm-base-url`, `--llm-api-key` : overrides
---
## 8. Tests & Gate
### Nouveaux tests (27)
- `test_report_context.py` (4) — `ReportContext` + `build_report_context`
- `test_report_prompt_pack.py` (3) — prompt pack version/render
- `test_llm_report_policy.py` (4) — `LLMReportPolicy` + retry/fallback
- `test_format_llm_report.py` (4) — `format_llm_report` empty/with narrative
- `test_cli_llm_report.py` (4) — flags mutuellement exclusifs, env req
- `test_report_budget.py` (8) — `ReportBudget`, `CostBudget`, retries
### Gate complète
```bash
uv run pytest -q
uv run ruff check src tests
uv run basedpyright src
```
---
## 9. Liens transverses
- [Architecture technique](../architecture/technical-architecture.md)
- [Conformité SRD](../conformance-srd.md)
- [Formats de données](../data-formats/index.md)
- [Règles Phase 0](../rules/phase-0-implemented.md)
@@ -0,0 +1,64 @@
# Architecture technique
## Modules principaux
| Module | Responsabilité |
|--------|----------------|
| `combat.py` | `CombatEngine` : initiative, actions, résolution attaques, états de vie, incantation, invocation, manœuvres, transcript, politique par défaut, `Observation`/`LLMPolicy` |
| `dice.py` | Parseur notation dés (`2d4+1`) + lancer |
| `rng.py` | `SeededRng` (flux reproductible par graine), `ScriptedRng` (tests) |
| `models.py` | Schémas Pydantic v2 : `Combatant`, `AbilityScores`, `ACProfile`, `AttackSpec`, `DamageComponent`, `Saves`, `DamageReduction` |
| `map.py` | `MapSpec`/`TerrainType`, chargement YAML, validation (dimensions, chars, zones, déploiement, markers) |
| `grid.py` | Grille 5-10-5 : `distance`, `step_cost`, `diagonal_allowed`, Dijkstra `reachable`, `airborne_view` |
| `los.py` | Ligne d'effet, couvert par règle des coins : gate dans `weapon_for`, bonus dans `resolve_attack` |
| `areas.py` | Géométrie gabarits zone : `cone_cells` (quart cercle 90°), `line_cells` (Bresenham 5ft), bursts via `_area_creatures` |
| `combat.py` | `CombatEngine` déterministe : initiative, actions, résolution attaques, tailles/allonges, high ground, actions immédiates opt-in, états de vie, incantation, invocation, manœuvres, capacités de classe, transcripts, politique par défaut, `Observation`/`LLMPolicy` |
| `effects.py` | `StatModifier` + `resolve_modifiers` (empilement PF1e : dodge/racial/trait/sans-type s'additionnent ; moral/sacré/profane/enhancement gardent le max) |
| `conditions.py` | 29 conditions PF1e : `Condition` (modificateurs + flags comportementaux + `miss_chance`) |
| `abilities.py` | Dons : `AbilitySpec` + fabriques (Weapon Focus, Toughness, Power Attack actif −X/+2X, X=BAB//4+1) |
| `spells.py` | `SpellSpec` (niveau, école, portée, save, DC, `requires_ranged_touch`, `harmless`, `cast_time`, `components`) + effets (`DamageEffect`, `ConditionEffect`, `HealEffect`, `BuffEffect`, `AreaEffect`, `OneShotEffect`) + scalage (`scaling` dés, `scaling_flat` bonus plat) |
| `metrics.py` | `win_rate`, `win_rate_sigma`, `win_rate_band` (bande 3σ bornée [0,1]) |
| `runner.py` | `EncounterSpec`/`Side`, `build_states` (placement + désambiguïsation ids), `run_encounter` (1 `SeededRng`/run), `EncounterReport` + attrition |
| `objectives.py` | Primitives Phase 2 : P(survie), P(TPK), P(≥1 mort), PV perdus, rounds, dcile PV restants |
| `phase2_spike.py` | Politique "focus fire" + cartographie danger |
| `cli.py` | `argparse` `pf1e-sim`, rapport FR, codes sortie, flags `--llm-report`/`--output` |
| `loaders/combatant.py` | Chargeur unifié PJ + monstres : résolution dons → `AbilitySpec`, sorts → `Combatant.spells` |
| `loaders/foundry.py` | Migration Foundry one-shot (incomplet — ne pas régénérer `data/pcs/*.json`) |
| `report.py` | LLM report writer P5.3 : `ReportContext`, `LLMReportPolicy`, `ReportBudget`, `format_llm_report` |
## Flux de données principal
```
CLI (args) → _build_spec() → EncounterSpec
↓
load_spell_registry() → spell_registry
↓
run_encounter(spec, runs, seed, spell_registry, ...)
↓
Pour chaque run: SeededRng(seed+i) → CombatEngine → CombatResult
↓
Agrégation → EncounterReport (wins, draws, avg_rounds, attrition)
↓
format_report() → sortie FR
```
## Frontière LLM (Phase 2/3)
```
CombatEngine(llm_policy=LLMPolicy) → avant chaque appel politique:
Observation snapshot (to_dict() → JSON isolé)
LLMPolicy(observation) → tuple[Action, ...]
```
- LLM ne reçoit **que** le snapshot, jamais `engine.*`
- `PromptPack` externe (`PF1E_PROMPT_PACK_DIR`) : `version.txt` + `<name>.md` + `<name>.examples.json`
- `prompt_version = version:name:hash12` (clé cache composite D6)
- Cache `ResponseCache` (clé = modèle|params|prompt_version|schema|prompt_hash)
- Budget `CostBudget` + `CircuitBreaker` + retry/backoff
## Tests & qualité
- Transcripts épinglés (combats 1v1/2v2 scriptés via `ScriptedRng`)
- Assertions statistiques forme fermée (matchup symétrique dans bande 3σ)
- Gate : `uv run pytest -q && uv run ruff check src tests && uv run basedpyright src`
- Ruff `select = ["ALL"]`, ligne 100 ; basedpyright strict sur `src` + `tests`
+149
View File
@@ -0,0 +1,149 @@
# Guidelines de contribution
Merci de votre intérêt pour contribuer à pf1e-simulator !
## Standards de code
### Langage & outils
- **Python ≥ 3.12** — typage strict, pas de `Any`, pas de `# type: ignore`
- **uv** pour la gestion de l'environnement (`uv sync --dev`)
- **Ruff** : `select = ["ALL"]`, longueur de ligne 100
- **Basedpyright** : mode strict sur `src` et `tests`
- **Pytest** : tests déterministes, transcripts épinglés
### Conventions Python
- **Dataclasses frozen** par défaut (`@dataclass(frozen=True)`)
- **Types explicites** — pas d'inférence ambiguë
- **Docstrings** sur les API publiques (modules, classes, fonctions publiques)
- **EM101/EM102** : messages d'erreur explicites (`msg = ...; raise X(msg)`)
- **Pas de `type: ignore` / `cast()` / `Any`** — le typage strict est la norme
### Architecture
- **Modules petits** (< 250 lignes) — séparation des responsabilités
- **Pas de cycles d'import** — dépendances unidirectionnelles
- **Interfaces explicites** — `Protocol` ou classes abstraites pour l'injection
- **Tests purs** — pas de réseau, pas d'état global, `FakeLLMClient` pour LLM
---
## Processus de développement
### 1. Branche & commits
```bash
git checkout -b feat/ma-fonctionnalité
# Travail...
git add -A
git commit -m "feat(scope): description courte
Description plus longue si nécessaire, wrapping à 72 caractères.
Refs: #issue-number"
```
**Style de commit** : Conventional Commits
- `feat(scope): ...` — nouvelle fonctionnalité
- `fix(scope): ...` — correction de bug
- `docs(scope): ...` — documentation
- `refactor(scope): ...` — refactoring sans changement fonctionnel
- `test(scope): ...` — ajout/modification de tests
- `chore: ...` — maintenance (deps, config, etc.)
### 2. Gate de validation (obligatoire avant PR)
```bash
uv run pytest -q
uv run ruff check src tests
uv run basedpyright src
```
**Tous doivent passer** — pas d'exceptions.
### 3. Tests
- **Couverture** : nouvelles fonctionnalités = nouveaux tests
- **Determinisme** : `ScriptedRng` / `FakeLLMClient` pour tests LLM
- **Transcripts épinglés** : combats 1v1/2v2 scriptés
- **Assertions statistiques** : forme fermée (bande 3σ sur matchup symétrique)
- **Noms de tests** : `test_<fonctionnalité>_<scénario>_<résultat_attendu>`
### 4. Documentation
- **API publiques** : docstrings complètes
- **Architecture** : mises à jour dans `docs/architecture/`
- **Règles** : conformité SRD dans `docs/rules/conformance-srd.md`
- **Formats** : exemples dans `docs/data-formats/`
---
## Types de contributions
### Bug fixes
1. Ouvrir une issue (template bug) avec reproduction minimale
2. Écrire un test qui échoue (reproduction)
3. Corriger le code
3. Vérifier gate complète
### Nouvelles fonctionnalités
1. Ouvrir une issue (template feature) avec design/discussion
2. Découper en tranches atomiques testables (cf. `docs/architecture/phase-2-llm-architecture.md`)
3. Implémenter tranche par tranche, gate à chaque étape
4. Doc + tests + architecture doc mise à jour
### Refactoring
- Gate complète avant et après
- Pas de changement fonctionnel
- Commits `refactor(scope): ...`
### Documentation
- Corrections typo/clarification : commits `docs: ...`
- Nouvelles sections : suivi structure `docs/`
---
## Configuration locale recommandée
```bash
# Pre-commit (optionnel mais recommandé)
uv pip install pre-commit
pre-commit install
# Éditeur : config basedpyright + ruff
# VS Code : extensions Python, Ruff, Basedpyright
```
---
## Code review
### Ce qu'on regarde
- ✅ Gate complète passe
- ✅ Tests pertinents + passants
- ✅ Typage strict (pas de `Any`, `ignore`, `cast`)
- ✅ Architecture respectée (modules, dépendances)
- ✅ Docstrings sur API publiques
- ✅ Messages d'erreur explicites (EM101/EM102)
- ✅ Pas de code mort / code commenté
- ✅ Commits atomiques, messages clairs
### Red flags (rejettent la PR)
- ❌ Gate qui ne passe pas
- ❌ `type: ignore` / `# type: ignore` / `cast()`
- ❌ Tests supprimés sans remplacement
- ❌ Code non testé (nouvelle logique sans test)
- ❌ Dépendances circulaires
- ❌ Code mort / `pass` / `TODO` sans issue
---
## Signalement de sécurité
Pour les vulnérabilités potentielles : **ne pas ouvrir d'issue publique**.
Envoyer un email à l'équipe de maintenance (voir `SECURITY.md` ou `AUTHORS`).
---
## Licence
En contribuant, vous acceptez que votre contribution soit sous la même licence que le projet (voir `LICENSE`).
---
## Questions ?
Ouvrez une discussion GitHub ou consultez `docs/architecture/` pour l'architecture technique.
+7
View File
@@ -0,0 +1,7 @@
# Formats de données
- [Carte (YAML)](map.md)
- [Monstre (JSON)](monster.md)
- [Fiches de personnages PJ](pc-sheets.md)
Chaque format est documenté en détail dans son fichier respectif.
+87
View File
@@ -0,0 +1,87 @@
# Format de 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
```
## Détail des champs
### `square_size_ft`
Taille d'une case en pieds (entier > 0). Par défaut 5 ft (1,5 m).
### `terrain` (obligatoire)
Bloc ASCII représentant le terrain. Chaque ligne = une rangée de cases.
### `legend`
Dictionnaire associant chaque caractère du terrain à ses propriétés :
- `type` : identificateur du type de terrain
- `move_cost` : entier > 0 pour terrain praticable, `null` pour infranchissable (mur, pilier)
- `blocks_los` : booléen, bloque la ligne d'effet
- `cover` : booléen, couvert standard (+4)
- `cover_bonus` : optionnel, niveau de couvert (2 = partiel, 4 = standard, 8 = amélioré)
**Important** : omettre `move_cost` équivaut à `null` (infranchissable). Un terrain praticable doit **toujours** déclarer explicitement son coût.
### `elevation` (optionnel)
Chiffres = hauteur de sol (marche). Monter d'un niveau coûte +1 case de mouvement par niveau ; la descente est gratuite. Une créature plus haute que sa cible gagne +1 aux jets d'attaque de mêlée et à la confirmation de critique (high ground). La ligne d'effet reste 2D.
### `zones` (optionnel)
Lettres de zone de déploiement. Chaque lettre = une zone distincte.
### `markers` (optionnel)
Points nommés (validés à la lecture, pas encore utilisés en jeu).
### `deployment` (obligatoire si `zones` présent)
Mapping nom de camp -> lettre de zone. Chaque camp du déploiement doit être fourni via `--side`, et toutes les zones doivent être couvertes. Deux camps ne peuvent pas partager la même lettre de zone.
## Sémantique de `blocks_los`
**Ligne d'effet, non ligne de vision** (SRD « Aiming a Spell — Line of Effect ») :
> « A line of effect is a straight, unblocked path that indicates what a spell can affect. A line of effect is canceled by a solid barrier. It's like line of sight for ranged weapons, except that it's not blocked by fog, darkness, and other factors that limit normal sight. »
Le drapeau encode donc une **barrière solide** ; un terrain qui ne limite que la vision normale (brouillard, obscurité, dissimulation) n'est pas modélisé — le moteur n'a qu'un seul drapeau, et la dissimulation passe par les conditions (`miss_chance`), pas par la carte.
## Vol et terrain
Une créature **aérienne** (vol ≥ sol) ignore les coûts de `move_cost` > 1 et **enjambe** les cases `move_cost: null` qui ne portent **pas** `blocks_los` (fosses, eau, gravats, piliers non bloquants). Une case `blocks_los` (barrière solide, hauteur supposée pleine) l'arrête aussi. La règle des coins suit la même vue : un aérien peut passer en diagonale au ras d'une fosse mais pas au ras d'un coin de mur.
+117
View File
@@ -0,0 +1,117 @@
# Format de 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
}
]
}
```
## Champs principaux
| Champ | Type | Requis | Description |
|-------|------|--------|-------------|
| `name` | string | oui | Nom du monstre |
| `level` | int | oui | Niveau / HD |
| `size` | string | oui | `Tiny`, `Diminutive`, `Fine`, `Small`, `Medium`, `Large`, `Huge`, `Gargantuan`, `Colossal` |
| `cr` | string | oui | Challenge Rating (ex: `"1/3"`, `"5"`) |
| `xp` | int | oui | Points d'expérience |
| `source` | string | oui | Source (ex: `"Bestiary > Goblin"`) |
| `abilities` | object | oui | 6 scores : `str_score`, `dex_score`, `con_score`, `int_score`, `wis_score`, `cha_score` |
| `hp_max` | int | oui | PV maximum |
| `ac` | object | oui | `{ total, touch, flat_footed }` — valeurs **finales** (bonus de taille déjà inclus) |
| `bab` | int | oui | Base Attack Bonus |
| `initiative_mod` | int | oui | Modificateur d'initiative |
| `speed_land_ft` | int | oui | Vitesse au sol en ft |
| `speed_fly_ft` | int | non | Vitesse de vol en ft (si absent = pas de vol) |
| `saves` | object | oui | `{ fort, ref, will }` — jets de sauvegarde finaux |
| `attacks` | array | oui | Liste des attaques (voir ci-dessous) |
## Attaques (`attacks[]`)
Chaque attaque est un objet avec les champs suivants :
| Champ | Type | Requis | Description |
|-------|------|--------|-------------|
| `name` | string | oui | Nom de l'arme |
| `kind` | string | oui | `"melee"`, `"ranged"`, ou `"touch"` |
| `attack_bonus` | int | oui | Bonus d'attaque (final, taille incluse) |
| `damage` | array | oui | Liste de composantes : `{ "formula": "1d4", "types": ["slashing"] }` |
| `crit_range` | int | non | Menace de critique (défaut 20) |
| `crit_mult` | int | oui | Multiplicateur de critique (2, 3, etc.) |
| `range_increment_ft` | int | non* | Incrément de portée en ft (> 0), requis pour `ranged` et `touch` |
| `damage_bonus` | int | non | Bonus de dégâts plat |
| `dr` | string | non | Type de contournement de RD (ex: `"magic"`, `"cold_iron"`) |
| `two_handed` | bool | non | Arme à deux mains (Power Attack +3X au lieu de +2X) |
| `reach_weapon` | bool | non | Arme à allonge (anneau, dead zone) |
| `reach_ft` | int | non | Allonge naturelle (pour attaques naturelles) |
| `natural` | bool | non | Attaque naturelle (pas d'itératifs, peut être `secondary`) |
| `secondary` | bool | non | Attaque naturelle secondaire (BAB −5, Multiattack → −2) |
| `firearm` | bool | non | Arme à feu (touch dans 1er incrément, misfire sur 1 naturel) |
| `splash` | object | non | Arme à éclaboussures : `{ "damage": 1, "type": "fire" }` |
| `spell_resistance` | int | non | Résistance aux sorts |
| `resistances` | object | non | Résistances énergétiques : `{ "fire": 5 }` |
| `immunities` | array | non | Immunités : `["fire", "poison"]` |
* `range_increment_ft` : entier **strictement positif** (zéro ou négatif = erreur). Pénalité −2 par incrément complet au-delà du premier. Arme `ranged` utilisable jusqu'à 10 incréments, `touch` jusqu'à 5.
## Champs optionnels avancés
- `carried_items` : array d'objets transportés non maniés/portés serré (volables via manœuvre *Steal*). Armes maniées = dans `attacks` ; équipement porté serré (armure, sac à dos, anneaux) exclus.
- `aware` : bool (défaut `true`) — conscience au début du combat (règle *Surprise*).
- `alignment` : code à deux axes (`LG`, `NG`, `CG`, `LN`, `N`, `CN`, `LE`, `NE`, `CE`), défaut `"N"`.
- `spell_slots` : dict niveau → nombre (ex: `{"1": 4, "2": 3}`) — budget d'encounter, **pas** calculé depuis la caractéristique.
- `spells` : array de noms de sorts connus (doivent exister dans `data/spells/`).
- `metamagic_feats` : array de noms de dons de métamagie connus.
- `features` : noms de capacités de classe / dons (résolus par le chargeur).
## Taille et allonge
| Taille | Allonge naturelle | Footprint |
|--------|-------------------|-----------|
| Fine / Diminutive / Tiny | 0 ft | 1×1 |
| Small / Medium | 5 ft | 1×1 |
| Large | 10 ft | 2×2 |
| Huge | 15 ft | 3×3 |
| Gargantuan | 20 ft | 4×4 |
| Colossal | 30 ft | 6×6 |
Une **arme à allonge** (`reach_weapon: true`) double l'allonge naturelle mais crée une **dead zone** (ne frappe pas à l'allonge naturelle ou moins). Une **arme naturelle** avec `reach_ft` explicite conserve cette allonge (le `reach_ft` d'une attaque naturelle EST son allonge naturelle).
## Notes importantes
- Les valeurs `ac` et `attack_bonus` des JSON sont des valeurs **FINALES**, déjà majorées du modificateur de taille PF1e. Le moteur n'applique **jamais** de bonus de taille aux jets d'attaque ni à la CA.
- La taille n'intervient que pour : manœuvres (CMB/CMD), allonge naturelle, footprint, occupation.
- Les JSON de `data/monsters/` et `data/pcs/` sont la référence. Le chargeur `loaders/foundry.py` est un outil de migration **one-shot et incomplet** — ne pas régénérer les JSON de PJ sans réappliquer les corrections SRD (voir `docs/rules/conformance-srd.md`).
+53
View File
@@ -0,0 +1,53 @@
# Fiches de personnages (PJ)
Les PJ sont définis au format JSON (schéma `Combatant`) dans `data/pcs/`. Huit fiches
sont fournies : `esha.json`, `harvie.json`, `ierlieth.json`, `jeanne.json`,
`misty.json`, `nairda.json`, `oni.json`, `tammara.json`.
Elles 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
```
## Format JSON
Le schéma est identique aux monstres (voir [`monster.md`](monster.md)), avec
ces particularités PJ :
- `source` : pointe vers la fiche Foundry VTT d'origine sous `fiches_personnages/`
(ex: `"fiches_personnages/oni.foundry.json"`).
- `spell_slots` : budget d'encounter (pas progression RAW — voir note ci-dessous).
- `spells` : noms de sorts connus (doivent exister dans `data/spells/`).
- `features` : noms de capacités de classe / dons (résolus par le chargeur).
- `carried_items` : objets volables (filtrés depuis l'inventaire Foundry per APG *Steal*).
## Note importante : `spell_slots` = budget d'encounter
Le champ `spell_slots` est une valeur **finale** par encounter. Le moteur ne calcule
**jamais** les sorts bonus de caractéristique d'incantation élevée (SRD Table 1-3).
Les éventuels sorts bonus doivent être **encodés dans le JSON par l'auteur**.
Preuve : les fiches PJ actuelles encodent la table de classe de BASE sans bonus.
Exemple : Oni (clerc 4, SAG 18) → `{"1":4, "2":3}` = table de base 4/3,
alors que le RAW avec bonus serait 5/4. Le liche (INT 20, niveau 11) porte
`{"1":4, "2":4, "3":4, "4":4, "5":4, "6":3}` qui ne correspond à AUCUNE table
RAW — les slots sont donc un budget d'encounter authored, pas une progression RAW.
Conséquence assumée : les PJ incanteurs tiennent moins de sorts par encounter
que le RAW ne le permettrait.
## Fiches Foundry (source)
`fiches_personnages/` contient les 8 fiches au format Foundry VTT
(`pf1-sheet/v1`) ; le chargeur `loaders/foundry.py` sait les lire. C'est un
outil de migration **one-shot et incomplet** : il ne reproduit pas les corrections
manuelles/SRD appliquées aux JSON (drapeaux `natural`/`secondary`/`firearm`,
allonge sabots Jeanne 10 ft, vol Ierlieth 40 ft, morsure Misty 1d6, dons au-delà
de 8 entrées, `spell_slots`/`resources`/`casting_ability`).
> **`data/pcs/*.json` est la référence canonique des PJ.** Ne pas régénérer
> depuis `foundry.py` sans réappliquer les corrections SRD consignées dans
> [`docs/rules/conformance-srd.md`](conformance-srd.md).