Compare commits
3 Commits
c454a67b84
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| f61fc5c61f | |||
| b0bd0b95f4 | |||
| 98985ee6fc |
@@ -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`
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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`).
|
||||||
@@ -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).
|
||||||
@@ -204,7 +204,8 @@ P4.1 `AdversarySpec` + politiques de monstres fortes · P4.2 `robustness.py`
|
|||||||
|
|
||||||
### P5 — Fournisseur réel et rapport (frontière phase 3)
|
### P5 — Fournisseur réel et rapport (frontière phase 3)
|
||||||
P5.1 `ProviderClient` (clé env, coût/observabilité) · P5.2 few-shot + réparation
|
P5.1 `ProviderClient` (clé env, coût/observabilité) · P5.2 few-shot + réparation
|
||||||
complète · P5.3 rapport FR (phase 3).
|
complète · **P5.3** ✅ rapport FR (phase 3) — module `report.py`, prompt FR `report.md`,
|
||||||
|
CLI `--llm-report`/`--output`, budget `ReportBudget`.
|
||||||
|
|
||||||
## 11. Risques
|
## 11. Risques
|
||||||
|
|
||||||
@@ -698,4 +699,7 @@ envoyé — trail vérifié tentative par tentative). Gate verte : **2211 tests*
|
|||||||
**P0.5** (ReplayLog `call_seq` + `replay()` identique) ✅.
|
**P0.5** (ReplayLog `call_seq` + `replay()` identique) ✅.
|
||||||
- **Frontière P0 atteinte** : gate verte (2022 tests) ; un run enregistré se
|
- **Frontière P0 atteinte** : gate verte (2022 tests) ; un run enregistré se
|
||||||
rejoue à l'identique.
|
rejoue à l'identique.
|
||||||
- Suite : **P5.3** (rapport FR).
|
- **P5.3** ✅ (rapport FR) — module `report.py` (`ReportContext`, `LLMReportPolicy`,
|
||||||
|
`ReportBudget`, `format_llm_report`), prompt pack FR `report.md`, CLI
|
||||||
|
`--llm-report`/`--output`, budget `ReportBudget` intégré à `CostBudget`,
|
||||||
|
27 tests ajoutés — gate verte (**2238 tests**).
|
||||||
|
|||||||
+119
-12
@@ -19,11 +19,24 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
from pf1e_simulator.runner import EncounterReport, EncounterSpec
|
from pf1e_simulator.runner import EncounterReport, EncounterSpec
|
||||||
|
|
||||||
|
from pf1e_simulator.config import ENV_PROMPT_PACK_DIR, env_path
|
||||||
from pf1e_simulator.loaders import CombatantLoadError, SheetLoadError, load_combatant
|
from pf1e_simulator.loaders import CombatantLoadError, SheetLoadError, load_combatant
|
||||||
from pf1e_simulator.map import MapValidationError, load_map
|
from pf1e_simulator.map import MapValidationError, load_map
|
||||||
from pf1e_simulator.runner import EncounterSpec, Side, run_encounter
|
from pf1e_simulator.prompt import PromptPack
|
||||||
|
from pf1e_simulator.prompt_builder import PromptBuilder
|
||||||
|
from pf1e_simulator.report import (
|
||||||
|
LLMReportPolicy,
|
||||||
|
build_report_context,
|
||||||
|
format_llm_report,
|
||||||
|
)
|
||||||
|
from pf1e_simulator.runner import EncounterSpec, RunOutcome, Side, run_encounter
|
||||||
from pf1e_simulator.spells import SPELLS_DIR, load_spell_registry
|
from pf1e_simulator.spells import SPELLS_DIR, load_spell_registry
|
||||||
|
|
||||||
|
try:
|
||||||
|
from pf1e_simulator.provider import OpenAILLMClient
|
||||||
|
except ImportError: # pragma: no cover - optional dependency
|
||||||
|
OpenAILLMClient = None # type: ignore[assignment,misc]
|
||||||
|
|
||||||
|
|
||||||
class CliError(Exception):
|
class CliError(Exception):
|
||||||
"""User-facing CLI error (bad arguments or unusable files)."""
|
"""User-facing CLI error (bad arguments or unusable files)."""
|
||||||
@@ -129,7 +142,7 @@ def _build_spec(args: argparse.Namespace) -> EncounterSpec:
|
|||||||
raise CliError(msg)
|
raise CliError(msg)
|
||||||
unknown = sorted(provided_names - set(map_spec.deployment))
|
unknown = sorted(provided_names - set(map_spec.deployment))
|
||||||
if unknown:
|
if unknown:
|
||||||
msg = f"--side names not in map deployment: {', '.join(unknown)}"
|
msg = f"--side names not in map deployment: {', '.join(sorted(unknown))}"
|
||||||
raise CliError(msg)
|
raise CliError(msg)
|
||||||
sides: list[Side] = []
|
sides: list[Side] = []
|
||||||
for raw in provided:
|
for raw in provided:
|
||||||
@@ -172,6 +185,56 @@ def format_report(report: EncounterReport, spec: EncounterSpec) -> str:
|
|||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_llm_report(
|
||||||
|
spec: EncounterSpec,
|
||||||
|
args: argparse.Namespace,
|
||||||
|
registry: dict[str, object] | None,
|
||||||
|
) -> str:
|
||||||
|
"""Generate the LLM-powered report."""
|
||||||
|
if OpenAILLMClient is None:
|
||||||
|
print("pf1e-sim: LLM provider not available", file=sys.stderr)
|
||||||
|
raise SystemExit(2)
|
||||||
|
|
||||||
|
pack_dir = env_path(ENV_PROMPT_PACK_DIR)
|
||||||
|
if pack_dir is None:
|
||||||
|
print(f"pf1e-sim: {ENV_PROMPT_PACK_DIR} environment variable not set", file=sys.stderr)
|
||||||
|
raise SystemExit(2)
|
||||||
|
|
||||||
|
pack = PromptPack.load(pack_dir)
|
||||||
|
prompt_version = pack.prompt_version("report")
|
||||||
|
builder = PromptBuilder(pack)
|
||||||
|
|
||||||
|
client = OpenAILLMClient(
|
||||||
|
prompt_version=prompt_version,
|
||||||
|
model=args.llm_model,
|
||||||
|
api_key=args.llm_api_key,
|
||||||
|
base_url=args.llm_base_url,
|
||||||
|
)
|
||||||
|
|
||||||
|
report: EncounterReport
|
||||||
|
outcomes: tuple[RunOutcome, ...]
|
||||||
|
report, outcomes = run_encounter( # type: ignore[misc]
|
||||||
|
spec,
|
||||||
|
runs=args.runs,
|
||||||
|
seed=args.seed,
|
||||||
|
spell_registry=registry, # type: ignore[arg-type]
|
||||||
|
capture_first_transcript=args.transcript,
|
||||||
|
monsters_die_at_zero=args.simple_monster_death,
|
||||||
|
collect_runs=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
ctx = build_report_context(
|
||||||
|
encounter=report, # type: ignore[arg-type]
|
||||||
|
spec_name=spec.map.name,
|
||||||
|
outcomes=outcomes, # type: ignore[arg-type]
|
||||||
|
party="players",
|
||||||
|
)
|
||||||
|
|
||||||
|
policy = LLMReportPolicy(client=client, builder=builder, prompt_name="report")
|
||||||
|
narrative = policy(ctx)
|
||||||
|
return format_llm_report(ctx, narrative)
|
||||||
|
|
||||||
|
|
||||||
def main(argv: list[str] | None = None) -> int:
|
def main(argv: list[str] | None = None) -> int:
|
||||||
"""CLI entry point; returns the process exit code."""
|
"""CLI entry point; returns the process exit code."""
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
@@ -207,9 +270,39 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
"(player characters are never affected)"
|
"(player characters are never affected)"
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--llm-report",
|
||||||
|
action="store_true",
|
||||||
|
help="generate an LLM-powered French narrative report (requires PF1E_LLM_API_KEY)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--output",
|
||||||
|
type=Path,
|
||||||
|
help="write the full report (factual + LLM narrative) to this file",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--llm-model",
|
||||||
|
type=str,
|
||||||
|
help="override the LLM model (default: PF1E_LLM_MODEL env or gpt-4o-mini)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--llm-base-url",
|
||||||
|
type=str,
|
||||||
|
help="override the LLM base URL (default: PF1E_LLM_BASE_URL env or https://api.openai.com/v1)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--llm-api-key",
|
||||||
|
type=str,
|
||||||
|
help="override the LLM API key (default: PF1E_LLM_API_KEY env)",
|
||||||
|
)
|
||||||
args = parser.parse_args(argv)
|
args = parser.parse_args(argv)
|
||||||
if not args.side:
|
if not args.side:
|
||||||
parser.error("at least one --side is required")
|
parser.error("at least one --side is required")
|
||||||
|
if args.llm_report and args.transcript:
|
||||||
|
parser.error("--llm-report and --transcript are mutually exclusive")
|
||||||
|
if args.output and not args.llm_report:
|
||||||
|
parser.error("--output requires --llm-report")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
spec = _build_spec(args)
|
spec = _build_spec(args)
|
||||||
except CliError as exc:
|
except CliError as exc:
|
||||||
@@ -218,22 +311,36 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
except (MapValidationError, CombatantLoadError, SheetLoadError, OSError) as exc:
|
except (MapValidationError, CombatantLoadError, SheetLoadError, OSError) as exc:
|
||||||
print(f"pf1e-sim: {exc}", file=sys.stderr)
|
print(f"pf1e-sim: {exc}", file=sys.stderr)
|
||||||
return 2
|
return 2
|
||||||
|
|
||||||
try:
|
try:
|
||||||
registry = load_spell_registry(SPELLS_DIR)
|
registry = load_spell_registry(SPELLS_DIR)
|
||||||
report = run_encounter(
|
|
||||||
spec,
|
report = None
|
||||||
runs=args.runs,
|
if args.llm_report:
|
||||||
seed=args.seed,
|
full_report = _run_llm_report(spec, args, registry) # type: ignore[arg-type]
|
||||||
spell_registry=registry,
|
else:
|
||||||
capture_first_transcript=args.transcript,
|
report = run_encounter(
|
||||||
monsters_die_at_zero=args.simple_monster_death,
|
spec,
|
||||||
)
|
runs=args.runs,
|
||||||
|
seed=args.seed,
|
||||||
|
spell_registry=registry,
|
||||||
|
capture_first_transcript=args.transcript,
|
||||||
|
monsters_die_at_zero=args.simple_monster_death,
|
||||||
|
)
|
||||||
|
full_report = format_report(report, spec)
|
||||||
|
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
print(f"pf1e-sim: {exc}", file=sys.stderr)
|
print(f"pf1e-sim: {exc}", file=sys.stderr)
|
||||||
return 2
|
return 2
|
||||||
if args.transcript and report.transcript is not None:
|
|
||||||
|
if args.transcript and report is not None and report.transcript is not None:
|
||||||
print("=== Déroulé du premier combat ===")
|
print("=== Déroulé du premier combat ===")
|
||||||
print(render_transcript_table(report.transcript))
|
print(render_transcript_table(report.transcript))
|
||||||
print()
|
print()
|
||||||
print(format_report(report, spec))
|
|
||||||
|
print(full_report)
|
||||||
|
|
||||||
|
if args.output:
|
||||||
|
args.output.write_text(full_report, encoding="utf-8")
|
||||||
|
|
||||||
return 0
|
return 0
|
||||||
|
|||||||
@@ -0,0 +1,297 @@
|
|||||||
|
"""LLM report writer (Phase 3) — aggregator + French narrative generator.
|
||||||
|
|
||||||
|
This module provides the data structures and pure functions to build a
|
||||||
|
report context from Monte Carlo outcomes and LLM observability counters,
|
||||||
|
then format the final French report combining the factual balance report
|
||||||
|
with an LLM-generated narrative section.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from pf1e_simulator.client import BudgetExceededError, CostBudget
|
||||||
|
from pf1e_simulator.objectives import danger_metrics
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
from pf1e_simulator.client import ClientStats, LLMClient
|
||||||
|
from pf1e_simulator.nl_policy import NLPolicyStats
|
||||||
|
from pf1e_simulator.objectives import DangerMetrics
|
||||||
|
from pf1e_simulator.prompt_builder import PromptBuilder
|
||||||
|
from pf1e_simulator.robustness import (
|
||||||
|
DangerCell,
|
||||||
|
MatchupBenchmark,
|
||||||
|
SensitivityEntry,
|
||||||
|
)
|
||||||
|
from pf1e_simulator.runner import EncounterReport, RunOutcome
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ReportContext:
|
||||||
|
"""Complete context for the LLM report writer.
|
||||||
|
|
||||||
|
Fields marked as optional are filled when the corresponding analysis
|
||||||
|
was executed (e.g. `collect_runs=True` for DangerMetrics, robustness
|
||||||
|
calls for MatchupBenchmark/SensitivityEntry/DangerCell).
|
||||||
|
"""
|
||||||
|
|
||||||
|
encounter: EncounterReport
|
||||||
|
spec_name: str
|
||||||
|
|
||||||
|
danger: DangerMetrics | None = None
|
||||||
|
|
||||||
|
nl_stats: NLPolicyStats | None = None
|
||||||
|
client_stats: ClientStats | None = None
|
||||||
|
|
||||||
|
matchup: MatchupBenchmark | None = None
|
||||||
|
danger_map: tuple[DangerCell, ...] | None = None
|
||||||
|
frontier_value: float | None = None
|
||||||
|
sensitivity: tuple[SensitivityEntry, ...] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def build_report_context( # noqa: PLR0913 - intentional builder with many optional params
|
||||||
|
*,
|
||||||
|
encounter: EncounterReport,
|
||||||
|
spec_name: str,
|
||||||
|
outcomes: Sequence[RunOutcome] | None = None,
|
||||||
|
party: str = "players",
|
||||||
|
nl_stats: NLPolicyStats | None = None,
|
||||||
|
client_stats: ClientStats | None = None,
|
||||||
|
matchup: MatchupBenchmark | None = None,
|
||||||
|
danger_map: tuple[DangerCell, ...] | None = None,
|
||||||
|
frontier_value: float | None = None,
|
||||||
|
sensitivity: tuple[SensitivityEntry, ...] | None = None,
|
||||||
|
) -> ReportContext:
|
||||||
|
"""Build a `ReportContext` from raw Monte Carlo outputs."""
|
||||||
|
danger: DangerMetrics | None = None
|
||||||
|
if outcomes is not None:
|
||||||
|
danger = danger_metrics(outcomes, party)
|
||||||
|
|
||||||
|
return ReportContext(
|
||||||
|
encounter=encounter,
|
||||||
|
spec_name=spec_name,
|
||||||
|
danger=danger,
|
||||||
|
nl_stats=nl_stats,
|
||||||
|
client_stats=client_stats,
|
||||||
|
matchup=matchup,
|
||||||
|
danger_map=danger_map,
|
||||||
|
frontier_value=frontier_value,
|
||||||
|
sensitivity=sensitivity,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _render_encounter(ctx: ReportContext) -> str:
|
||||||
|
"""Render the EncounterReport in the same format as cli.format_report."""
|
||||||
|
r = ctx.encounter
|
||||||
|
lines = ["=== Rapport d'équilibrage ===", f"Carte : {ctx.spec_name}"]
|
||||||
|
lines.extend(f"{side_name} : {r.wins.get(side_name, 0)} combats" for side_name in r.wins)
|
||||||
|
lines.append(f"{r.runs} combats simulés (seed {r.seed}, plafond ? rounds)")
|
||||||
|
lines.append("")
|
||||||
|
for side_name in r.wins:
|
||||||
|
wins = r.wins.get(side_name, 0)
|
||||||
|
rate = r.win_rate(side_name)
|
||||||
|
low, high = r.win_rate_band(side_name)
|
||||||
|
lines.append(
|
||||||
|
f"Victoires {side_name} : {rate:.1%} ({wins}) — "
|
||||||
|
f"bande 3σ : [{low:.1%}, {high:.1%}]"
|
||||||
|
)
|
||||||
|
lines.append(f"Nuls : {r.draws / r.runs:.1%} ({r.draws})")
|
||||||
|
lines.append(f"Rounds moyens : {r.avg_rounds:.1f}")
|
||||||
|
lines.append("")
|
||||||
|
lines.append("Attrition moyenne par combatant :")
|
||||||
|
for cid, attr in r.attrition.items():
|
||||||
|
lines.append(
|
||||||
|
f" {cid} : {attr.hits:.2f} touches · {attr.crits:.2f} critiques · "
|
||||||
|
f"{attr.damage_dealt:.2f} dégâts infligés · {attr.damage_taken:.2f} subis"
|
||||||
|
f" · {attr.healing_done:.2f} soins prodigués"
|
||||||
|
)
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def _render_danger(danger: DangerMetrics) -> str:
|
||||||
|
"""Render DangerMetrics section."""
|
||||||
|
lines = ["=== Métriques de danger ==="]
|
||||||
|
lines.append(f"runs: {danger.runs}")
|
||||||
|
lines.append(f"p_survives: {danger.p_survives:.2f}")
|
||||||
|
lines.append(f"p_tpk: {danger.p_tpk:.2f}")
|
||||||
|
lines.append(f"p_any_death: {danger.p_any_death:.2f}")
|
||||||
|
lines.append(f"expected_hp_lost: {danger.expected_hp_lost:.1f}")
|
||||||
|
lines.append(f"expected_rounds: {danger.expected_rounds:.1f}")
|
||||||
|
lines.append(f"worst_decile_party_hp: {danger.worst_decile_party_hp:.1f}")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def _render_nl_stats(stats: NLPolicyStats) -> str:
|
||||||
|
lines = ["=== Observabilité LLM ==="]
|
||||||
|
lines.append(f"calls: {stats.calls}")
|
||||||
|
lines.append(f"repairs_attempted: {stats.repairs_attempted}")
|
||||||
|
lines.append(f"repairs_succeeded: {stats.repairs_succeeded}")
|
||||||
|
lines.append(f"fallbacks: {stats.fallbacks}")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def _render_client_stats(stats: ClientStats) -> str:
|
||||||
|
lines = ["=== Coût LLM ==="]
|
||||||
|
lines.append(f"prompt_tokens: {stats.prompt_tokens}")
|
||||||
|
lines.append(f"completion_tokens: {stats.completion_tokens}")
|
||||||
|
lines.append(f"cost_usd: {stats.cost_usd:.3f}")
|
||||||
|
lines.append(f"cache_hits: {stats.cache_hits}")
|
||||||
|
lines.append(f"cache_misses: {stats.cache_misses}")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def _render_matchup(matchup: MatchupBenchmark) -> str:
|
||||||
|
lines = ["=== Robustesse (matchup) ==="]
|
||||||
|
lines.append(f"party: {matchup.party}")
|
||||||
|
lines.append(f"adversary: {matchup.adversary}")
|
||||||
|
lines.append(f"runs: {matchup.runs}")
|
||||||
|
lines.append(f"seed: {matchup.seed}")
|
||||||
|
lines.append(f"party_danger.p_survives: {matchup.party_danger.p_survives:.2f}")
|
||||||
|
lines.append(f"party_danger.p_tpk: {matchup.party_danger.p_tpk:.2f}")
|
||||||
|
lines.append(f"adversary_danger.p_survives: {matchup.adversary_danger.p_survives:.2f}")
|
||||||
|
lines.append(f"adversary_danger.p_tpk: {matchup.adversary_danger.p_tpk:.2f}")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def build_report_prompt(ctx: ReportContext, template: str) -> str:
|
||||||
|
"""Build the complete LLM prompt for the report writer."""
|
||||||
|
sections: list[str] = [template, ""]
|
||||||
|
|
||||||
|
sections.append(_render_encounter(ctx))
|
||||||
|
sections.append("")
|
||||||
|
|
||||||
|
if ctx.danger is not None:
|
||||||
|
sections.append(_render_danger(ctx.danger))
|
||||||
|
sections.append("")
|
||||||
|
|
||||||
|
if ctx.matchup is not None:
|
||||||
|
sections.append(_render_matchup(ctx.matchup))
|
||||||
|
sections.append("")
|
||||||
|
|
||||||
|
if ctx.nl_stats is not None:
|
||||||
|
sections.append(_render_nl_stats(ctx.nl_stats))
|
||||||
|
sections.append("")
|
||||||
|
|
||||||
|
if ctx.client_stats is not None:
|
||||||
|
sections.append(_render_client_stats(ctx.client_stats))
|
||||||
|
sections.append("")
|
||||||
|
|
||||||
|
if sections[-1] == "":
|
||||||
|
sections.pop()
|
||||||
|
|
||||||
|
return "\n".join(sections)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ReportBudget:
|
||||||
|
"""Budget limits for a single report generation (integrates with CostBudget).
|
||||||
|
|
||||||
|
Mirrors the ceilings from the architecture doc (section 13):
|
||||||
|
small caps for showcase/vitrine mode — max 1 call per report.
|
||||||
|
"""
|
||||||
|
|
||||||
|
max_calls: int = 1
|
||||||
|
max_total_tokens: int | None = None
|
||||||
|
max_cost_usd: float | None = None
|
||||||
|
|
||||||
|
def to_cost_budget(self) -> CostBudget:
|
||||||
|
"""Convert to the client's CostBudget type."""
|
||||||
|
return CostBudget(
|
||||||
|
max_calls=self.max_calls,
|
||||||
|
max_total_tokens=self.max_total_tokens,
|
||||||
|
max_cost_usd=self.max_cost_usd,
|
||||||
|
)
|
||||||
|
|
||||||
|
def for_retries(self, max_retries: int) -> ReportBudget:
|
||||||
|
"""Return a budget allowing for retries (max_calls = max_retries + 1)."""
|
||||||
|
if self.max_calls == 1:
|
||||||
|
return ReportBudget(
|
||||||
|
max_calls=max_retries + 1,
|
||||||
|
max_total_tokens=self.max_total_tokens,
|
||||||
|
max_cost_usd=self.max_cost_usd,
|
||||||
|
)
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ReportStats:
|
||||||
|
"""Mutable counters for the LLM report writer."""
|
||||||
|
|
||||||
|
calls: int = 0
|
||||||
|
retries: int = 0
|
||||||
|
fallbacks: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class LLMReportPolicy:
|
||||||
|
"""Generate a French narrative report via LLM completion.
|
||||||
|
|
||||||
|
Distinct from NLPolicy: produces free-text narrative (not JSON actions),
|
||||||
|
uses simple retry loop (no repair trail), and tracks separate stats.
|
||||||
|
"""
|
||||||
|
|
||||||
|
client: LLMClient
|
||||||
|
builder: PromptBuilder
|
||||||
|
budget: ReportBudget = field(default_factory=ReportBudget)
|
||||||
|
prompt_name: str = "report"
|
||||||
|
max_retries: int = 1
|
||||||
|
stats: ReportStats = field(default_factory=ReportStats)
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if self.max_retries < 0:
|
||||||
|
msg = f"max_retries must be >= 0, got {self.max_retries}"
|
||||||
|
raise ValueError(msg)
|
||||||
|
expected = self.builder.prompt_version(self.prompt_name)
|
||||||
|
if self.client.prompt_version != expected:
|
||||||
|
msg = (
|
||||||
|
f"client prompt_version {self.client.prompt_version!r} does not "
|
||||||
|
f"match pack {expected!r} for {self.prompt_name!r}"
|
||||||
|
)
|
||||||
|
raise ValueError(msg)
|
||||||
|
# Apply the report budget (adjusted for retries) to the client
|
||||||
|
if self.client._budget is None: # noqa: SLF001 - private access required for budget injection # pyright: ignore[reportPrivateUsage]
|
||||||
|
adjusted_budget = self.budget.for_retries(self.max_retries)
|
||||||
|
self.client._budget = adjusted_budget.to_cost_budget() # noqa: SLF001 - private access required for budget injection # pyright: ignore[reportPrivateUsage]
|
||||||
|
|
||||||
|
def __call__(self, ctx: ReportContext) -> str:
|
||||||
|
prompt = build_report_prompt(ctx, self.builder.pack.get(self.prompt_name))
|
||||||
|
retries = 0
|
||||||
|
while True:
|
||||||
|
self.stats.calls += 1
|
||||||
|
try:
|
||||||
|
return self.client.complete(prompt)
|
||||||
|
except BudgetExceededError:
|
||||||
|
# Budget exceeded during retry - treat as fallback
|
||||||
|
self.stats.fallbacks += 1
|
||||||
|
return ""
|
||||||
|
except Exception: # noqa: BLE001 - client may raise various exceptions
|
||||||
|
if retries >= self.max_retries:
|
||||||
|
self.stats.fallbacks += 1
|
||||||
|
return ""
|
||||||
|
retries += 1
|
||||||
|
self.stats.retries += 1
|
||||||
|
|
||||||
|
|
||||||
|
def format_llm_report(ctx: ReportContext, llm_text: str) -> str:
|
||||||
|
"""Combine the factual balance report with the LLM narrative section.
|
||||||
|
|
||||||
|
The result is the complete French report printed to stdout and/or written
|
||||||
|
to a file via `--output`.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
ctx: The populated `ReportContext` with all available data.
|
||||||
|
llm_text: The narrative text returned by `LLMReportPolicy` (may be empty).
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
Complete French report as a single string.
|
||||||
|
"""
|
||||||
|
factual = _render_encounter(ctx)
|
||||||
|
if not llm_text.strip():
|
||||||
|
return factual
|
||||||
|
|
||||||
|
return f"{factual}\n\n=== Récit d'équilibrage (LLM) ===\n{llm_text.strip()}"
|
||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
Vous êtes le narrateur pour un simulateur de combat Pathfinder 1e.
|
||||||
|
|
||||||
|
Vous recevez un rapport de bataille structuré contenant :
|
||||||
|
- Le rapport d'équilibrage factuel (taux de victoire, bandes de confiance 3σ, attrition)
|
||||||
|
- Les métriques de danger (probabilité de survie, risque TPK, perte de PV attendue, queue pire décile)
|
||||||
|
- La cartographie de robustesse optionnelle (matchups, carte de danger, frontière, sensibilité)
|
||||||
|
- L'observabilité LLM (appels, réparations, fallbacks, usage tokens, coût)
|
||||||
|
|
||||||
|
Rédigez un résumé narratif concis en français pour le maître de jeu. Ton : analytique, évocateur, ancré dans les chiffres. N'inventez PAS de chiffres — citez les statistiques fournies textuellement. PAS de formatage markdown. Texte brut, français uniquement.
|
||||||
|
|
||||||
|
Structurez votre réponse ainsi :
|
||||||
|
1. **Bilan d'équilibrage** — 2-3 phrases synthétisant les taux de victoire et leur signification.
|
||||||
|
2. **Danger et attrition** — 2-3 phrases sur survie/TPK/perte PV/pire décile.
|
||||||
|
3. **Robustesse** (si données présentes) — 1-2 phrases sur matchup/carte de danger/frontière/sensibilité.
|
||||||
|
4. **Observabilité LLM** (si données présentes) — 1 phrase sur appels/réparations/coût.
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
"""CLI integration test for --llm-report flag (P5.3 T5)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from pf1e_simulator.cli import main
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
MAP = ROOT / "data" / "maps" / "sample_arena.yaml"
|
||||||
|
|
||||||
|
|
||||||
|
def _argv_common() -> list[str]:
|
||||||
|
return [
|
||||||
|
"--map", str(MAP),
|
||||||
|
"--side", "players", str(ROOT / "data" / "monsters" / "goblin.json"),
|
||||||
|
"--side", "monsters", str(ROOT / "data" / "monsters" / "orc.json"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_cli_llm_report_mutually_exclusive_with_transcript() -> None:
|
||||||
|
"""--llm-report and --transcript are mutually exclusive."""
|
||||||
|
argv = [*_argv_common(), "--llm-report", "--transcript"]
|
||||||
|
|
||||||
|
with pytest.raises(SystemExit) as exc_info:
|
||||||
|
main(argv)
|
||||||
|
assert exc_info.value.code == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_cli_output_requires_llm_report() -> None:
|
||||||
|
"""--output requires --llm-report."""
|
||||||
|
argv = [*_argv_common(), "--output", "report.md"]
|
||||||
|
|
||||||
|
with pytest.raises(SystemExit) as exc_info:
|
||||||
|
main(argv)
|
||||||
|
assert exc_info.value.code == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_cli_llm_report_requires_api_key_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""--llm-report fails gracefully when PF1E_LLM_API_KEY not set."""
|
||||||
|
monkeypatch.delenv("PF1E_LLM_API_KEY", raising=False)
|
||||||
|
monkeypatch.delenv("PF1E_PROMPT_PACK_DIR", raising=False)
|
||||||
|
|
||||||
|
argv = [*_argv_common(), "--llm-report"]
|
||||||
|
|
||||||
|
with pytest.raises(SystemExit) as exc_info:
|
||||||
|
main(argv)
|
||||||
|
assert exc_info.value.code == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_cli_llm_report_requires_prompt_pack_dir(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""--llm-report fails gracefully when PF1E_PROMPT_PACK_DIR not set."""
|
||||||
|
monkeypatch.setenv("PF1E_LLM_API_KEY", "test-key")
|
||||||
|
monkeypatch.delenv("PF1E_PROMPT_PACK_DIR", raising=False)
|
||||||
|
|
||||||
|
argv = [*_argv_common(), "--llm-report"]
|
||||||
|
|
||||||
|
with pytest.raises(SystemExit) as exc_info:
|
||||||
|
main(argv)
|
||||||
|
assert exc_info.value.code == 2
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
"""Tests for format_llm_report (P5.3 T4)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pf1e_simulator.report import build_report_context, format_llm_report
|
||||||
|
from pf1e_simulator.runner import CombatantAttrition, EncounterReport
|
||||||
|
|
||||||
|
|
||||||
|
def _make_encounter() -> EncounterReport:
|
||||||
|
return EncounterReport(
|
||||||
|
runs=100,
|
||||||
|
seed=1,
|
||||||
|
wins={"players": 65, "monsters": 33},
|
||||||
|
draws=2,
|
||||||
|
avg_rounds=7.2,
|
||||||
|
attrition={
|
||||||
|
"goblin": CombatantAttrition(2.45, 0.12, 18.30, 4.20, 0.00),
|
||||||
|
"orc": CombatantAttrition(3.20, 0.25, 28.40, 15.60, 0.00),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_llm_report_empty_narrative() -> None:
|
||||||
|
"""Empty LLM text returns only the factual report."""
|
||||||
|
ctx = build_report_context(encounter=_make_encounter(), spec_name="test")
|
||||||
|
result = format_llm_report(ctx, "")
|
||||||
|
|
||||||
|
assert "=== Rapport d'équilibrage ===" in result
|
||||||
|
assert "Victoires players : 65.0%" in result
|
||||||
|
assert "Récit d'équilibrage" not in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_llm_report_whitespace_only_narrative() -> None:
|
||||||
|
"""Whitespace-only LLM text returns only the factual report."""
|
||||||
|
ctx = build_report_context(encounter=_make_encounter(), spec_name="test")
|
||||||
|
result = format_llm_report(ctx, " \n \t ")
|
||||||
|
|
||||||
|
assert "=== Rapport d'équilibrage ===" in result
|
||||||
|
assert "Récit d'équilibrage" not in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_llm_report_with_narrative() -> None:
|
||||||
|
"""Non-empty LLM text appends narrative section after factual report."""
|
||||||
|
ctx = build_report_context(encounter=_make_encounter(), spec_name="test")
|
||||||
|
narrative = "Le gobelin a surpris l'orc par sa vitesse."
|
||||||
|
result = format_llm_report(ctx, narrative)
|
||||||
|
|
||||||
|
assert "=== Rapport d'équilibrage ===" in result
|
||||||
|
assert "Victoires players : 65.0%" in result
|
||||||
|
assert "=== Récit d'équilibrage (LLM) ===" in result
|
||||||
|
assert narrative in result
|
||||||
|
# factual comes before narrative
|
||||||
|
factual_end = result.index("=== Récit d'équilibrage (LLM) ===")
|
||||||
|
assert "Victoires players" in result[:factual_end]
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_llm_report_strips_narrative_whitespace() -> None:
|
||||||
|
"""Leading/trailing whitespace in narrative is stripped."""
|
||||||
|
ctx = build_report_context(encounter=_make_encounter(), spec_name="test")
|
||||||
|
result = format_llm_report(ctx, " \n Texte narratif. \n ")
|
||||||
|
|
||||||
|
assert result.endswith("Texte narratif.")
|
||||||
|
assert "=== Récit d'équilibrage (LLM) ===\nTexte narratif." in result
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
"""Tests for LLMReportPolicy (P5.3 T3)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from pf1e_simulator.client import FakeLLMClient
|
||||||
|
from pf1e_simulator.prompt import PromptPack
|
||||||
|
from pf1e_simulator.prompt_builder import PromptBuilder
|
||||||
|
from pf1e_simulator.report import LLMReportPolicy, build_report_context
|
||||||
|
from pf1e_simulator.runner import CombatantAttrition, EncounterReport
|
||||||
|
|
||||||
|
FIXTURE_DIR = Path(__file__).resolve().parent / "fixtures" / "prompt_pack_fs"
|
||||||
|
|
||||||
|
|
||||||
|
def _make_encounter() -> EncounterReport:
|
||||||
|
return EncounterReport(
|
||||||
|
runs=100,
|
||||||
|
seed=1,
|
||||||
|
wins={"players": 65, "monsters": 33},
|
||||||
|
draws=2,
|
||||||
|
avg_rounds=7.2,
|
||||||
|
attrition={
|
||||||
|
"goblin": CombatantAttrition(2.45, 0.12, 18.30, 4.20, 0.00),
|
||||||
|
"orc": CombatantAttrition(3.20, 0.25, 28.40, 15.60, 0.00),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_policy(
|
||||||
|
script: list[str],
|
||||||
|
*,
|
||||||
|
max_retries: int = 1,
|
||||||
|
) -> LLMReportPolicy:
|
||||||
|
pack = PromptPack.load(FIXTURE_DIR)
|
||||||
|
builder = PromptBuilder(pack)
|
||||||
|
expected_version = pack.prompt_version("report")
|
||||||
|
client = FakeLLMClient(script=script, prompt_version=expected_version) # type: ignore[arg-type]
|
||||||
|
return LLMReportPolicy(
|
||||||
|
client=client, builder=builder, prompt_name="report", max_retries=max_retries
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_llm_report_policy_returns_text() -> None:
|
||||||
|
"""Policy returns the scripted text from FakeLLMClient."""
|
||||||
|
policy = _make_policy(script=["Rapport narratif de test."])
|
||||||
|
ctx = build_report_context(encounter=_make_encounter(), spec_name="test")
|
||||||
|
|
||||||
|
result = policy(ctx)
|
||||||
|
|
||||||
|
assert result == "Rapport narratif de test."
|
||||||
|
assert policy.stats.calls == 1
|
||||||
|
assert policy.stats.retries == 0
|
||||||
|
assert policy.stats.fallbacks == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_llm_report_policy_retry_on_exception() -> None:
|
||||||
|
"""Policy retries on exception and increments retries counter."""
|
||||||
|
policy = _make_policy(
|
||||||
|
script=[Exception("boom"), "Rapport après retry."], # type: ignore[list-item]
|
||||||
|
max_retries=1,
|
||||||
|
)
|
||||||
|
ctx = build_report_context(encounter=_make_encounter(), spec_name="test")
|
||||||
|
|
||||||
|
result = policy(ctx)
|
||||||
|
|
||||||
|
assert result == "Rapport après retry."
|
||||||
|
assert policy.stats.calls == 2
|
||||||
|
assert policy.stats.retries == 1
|
||||||
|
assert policy.stats.fallbacks == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_llm_report_policy_fallback_after_max_retries() -> None:
|
||||||
|
"""Policy returns empty string and increments fallbacks after max retries."""
|
||||||
|
policy = _make_policy(
|
||||||
|
script=[Exception("boom"), Exception("boom2")], # type: ignore[list-item]
|
||||||
|
max_retries=1,
|
||||||
|
)
|
||||||
|
ctx = build_report_context(encounter=_make_encounter(), spec_name="test")
|
||||||
|
|
||||||
|
result = policy(ctx)
|
||||||
|
|
||||||
|
assert result == ""
|
||||||
|
assert policy.stats.calls == 2
|
||||||
|
assert policy.stats.retries == 1
|
||||||
|
assert policy.stats.fallbacks == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_llm_report_policy_prompt_version_mismatch_raises() -> None:
|
||||||
|
"""Policy rejects client with mismatched prompt_version."""
|
||||||
|
pack = PromptPack.load(FIXTURE_DIR)
|
||||||
|
builder = PromptBuilder(pack)
|
||||||
|
expected_version = pack.prompt_version("report")
|
||||||
|
client = FakeLLMClient(script=["x"], prompt_version=expected_version)
|
||||||
|
client.prompt_version = "wrong-version"
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="prompt_version"):
|
||||||
|
LLMReportPolicy(client=client, builder=builder, prompt_name="report")
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
"""Tests for ReportBudget and budget exceeded handling (P5.3 T6)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from pf1e_simulator.client import CostBudget, FakeLLMClient
|
||||||
|
from pf1e_simulator.prompt import PromptPack
|
||||||
|
from pf1e_simulator.prompt_builder import PromptBuilder
|
||||||
|
from pf1e_simulator.report import (
|
||||||
|
LLMReportPolicy,
|
||||||
|
ReportBudget,
|
||||||
|
build_report_context,
|
||||||
|
)
|
||||||
|
from pf1e_simulator.runner import EncounterReport
|
||||||
|
|
||||||
|
FIXTURE_DIR = Path(__file__).resolve().parent / "fixtures" / "prompt_pack_fs"
|
||||||
|
|
||||||
|
|
||||||
|
def _make_policy(
|
||||||
|
script: list[str],
|
||||||
|
*,
|
||||||
|
max_retries: int = 1,
|
||||||
|
budget: ReportBudget | None = None,
|
||||||
|
) -> LLMReportPolicy:
|
||||||
|
pack = PromptPack.load(FIXTURE_DIR)
|
||||||
|
builder = PromptBuilder(pack)
|
||||||
|
client = FakeLLMClient(script=script, prompt_version=pack.prompt_version("report")) # type: ignore[arg-type]
|
||||||
|
return LLMReportPolicy(
|
||||||
|
client=client,
|
||||||
|
builder=builder,
|
||||||
|
prompt_name="report",
|
||||||
|
max_retries=max_retries,
|
||||||
|
budget=budget or ReportBudget(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_report_budget_default_allows_one_call() -> None:
|
||||||
|
"""Default budget allows exactly 1 successful call."""
|
||||||
|
policy = _make_policy(script=["Rapport de test."])
|
||||||
|
ctx = build_report_context(
|
||||||
|
encounter=EncounterReport(
|
||||||
|
runs=10, seed=1, wins={"players": 5, "monsters": 5}, draws=0, avg_rounds=5.0,
|
||||||
|
attrition={}
|
||||||
|
),
|
||||||
|
spec_name="test",
|
||||||
|
)
|
||||||
|
|
||||||
|
result = policy(ctx)
|
||||||
|
|
||||||
|
assert result == "Rapport de test."
|
||||||
|
assert policy.stats.calls == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_report_budget_exceeded_on_second_call() -> None:
|
||||||
|
"""Budget exceeded when attempting a second call."""
|
||||||
|
policy = _make_policy(
|
||||||
|
script=["First call.", "Second call (should not happen)."],
|
||||||
|
max_retries=0, # No retries
|
||||||
|
)
|
||||||
|
ctx = build_report_context(
|
||||||
|
encounter=EncounterReport(
|
||||||
|
runs=10, seed=1, wins={"players": 5, "monsters": 5}, draws=0, avg_rounds=5.0,
|
||||||
|
attrition={}
|
||||||
|
),
|
||||||
|
spec_name="test",
|
||||||
|
)
|
||||||
|
|
||||||
|
# First call succeeds
|
||||||
|
result1 = policy(ctx)
|
||||||
|
assert result1 == "First call."
|
||||||
|
assert policy.stats.calls == 1
|
||||||
|
|
||||||
|
# Second call should exceed budget and return empty string (fallback)
|
||||||
|
result2 = policy(ctx)
|
||||||
|
assert result2 == ""
|
||||||
|
assert policy.stats.calls == 2
|
||||||
|
assert policy.stats.fallbacks == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_report_budget_for_retries_allows_retry() -> None:
|
||||||
|
"""Budget adjusted for retries allows the retry to succeed."""
|
||||||
|
policy = _make_policy(
|
||||||
|
script=[Exception("boom"), "Rapport après retry."], # type: ignore[list-item]
|
||||||
|
max_retries=1,
|
||||||
|
)
|
||||||
|
ctx = build_report_context(
|
||||||
|
encounter=EncounterReport(
|
||||||
|
runs=10, seed=1, wins={"players": 5, "monsters": 5}, draws=0, avg_rounds=5.0,
|
||||||
|
attrition={}
|
||||||
|
),
|
||||||
|
spec_name="test",
|
||||||
|
)
|
||||||
|
|
||||||
|
result = policy(ctx)
|
||||||
|
|
||||||
|
assert result == "Rapport après retry."
|
||||||
|
assert policy.stats.calls == 2
|
||||||
|
assert policy.stats.retries == 1
|
||||||
|
assert policy.stats.fallbacks == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_report_budget_for_retries_exceeded() -> None:
|
||||||
|
"""Budget still exceeded after max retries exhausted."""
|
||||||
|
policy = _make_policy(
|
||||||
|
script=[Exception("boom1"), Exception("boom2"), "Should not reach here."], # type: ignore[list-item]
|
||||||
|
max_retries=1,
|
||||||
|
)
|
||||||
|
ctx = build_report_context(
|
||||||
|
encounter=EncounterReport(
|
||||||
|
runs=10, seed=1, wins={"players": 5, "monsters": 5}, draws=0, avg_rounds=5.0,
|
||||||
|
attrition={}
|
||||||
|
),
|
||||||
|
spec_name="test",
|
||||||
|
)
|
||||||
|
|
||||||
|
result = policy(ctx)
|
||||||
|
|
||||||
|
assert result == ""
|
||||||
|
assert policy.stats.calls == 2 # initial + 1 retry (budget allows max_retries + 1 = 2)
|
||||||
|
assert policy.stats.retries == 1
|
||||||
|
assert policy.stats.fallbacks == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_report_budget_to_cost_budget() -> None:
|
||||||
|
"""ReportBudget converts to CostBudget correctly."""
|
||||||
|
budget = ReportBudget(max_calls=2, max_total_tokens=1000, max_cost_usd=0.01)
|
||||||
|
cost_budget = budget.to_cost_budget()
|
||||||
|
|
||||||
|
assert isinstance(cost_budget, CostBudget)
|
||||||
|
assert cost_budget.max_calls == 2
|
||||||
|
assert cost_budget.max_total_tokens == 1000
|
||||||
|
assert cost_budget.max_cost_usd == 0.01
|
||||||
|
|
||||||
|
|
||||||
|
def test_report_budget_for_retries_adjusts_max_calls() -> None:
|
||||||
|
"""for_retries adjusts max_calls to max_retries + 1."""
|
||||||
|
budget = ReportBudget(max_calls=1)
|
||||||
|
adjusted = budget.for_retries(2)
|
||||||
|
|
||||||
|
assert adjusted.max_calls == 3 # 2 retries + 1 initial
|
||||||
|
assert adjusted.max_total_tokens is None
|
||||||
|
assert adjusted.max_cost_usd is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_report_budget_for_retries_preserves_other_limits() -> None:
|
||||||
|
"""for_retries preserves token and cost limits."""
|
||||||
|
budget = ReportBudget(max_calls=1, max_total_tokens=500, max_cost_usd=0.005)
|
||||||
|
adjusted = budget.for_retries(3)
|
||||||
|
|
||||||
|
assert adjusted.max_calls == 4
|
||||||
|
assert adjusted.max_total_tokens == 500
|
||||||
|
assert adjusted.max_cost_usd == 0.005
|
||||||
|
|
||||||
|
|
||||||
|
def test_report_budget_for_retries_preserves_non_default_max_calls() -> None:
|
||||||
|
"""for_retries preserves non-default max_calls."""
|
||||||
|
budget = ReportBudget(max_calls=5)
|
||||||
|
adjusted = budget.for_retries(2)
|
||||||
|
|
||||||
|
assert adjusted.max_calls == 5 # unchanged because it wasn't the default 1
|
||||||
|
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
"""Tests for ReportContext and build_report_context (P5.3 T1)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import FrozenInstanceError
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from pf1e_simulator.client import ClientStats
|
||||||
|
from pf1e_simulator.loaders import load_combatant
|
||||||
|
from pf1e_simulator.map import load_map
|
||||||
|
from pf1e_simulator.nl_policy import NLPolicyStats
|
||||||
|
from pf1e_simulator.objectives import DangerMetrics
|
||||||
|
from pf1e_simulator.report import ReportContext, build_report_context
|
||||||
|
from pf1e_simulator.runner import EncounterSpec, Side, run_encounter
|
||||||
|
from pf1e_simulator.spells import SPELLS_DIR, load_spell_registry
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
MAP = ROOT / "data" / "maps" / "sample_arena.yaml"
|
||||||
|
|
||||||
|
|
||||||
|
def _c(path: str):
|
||||||
|
return load_combatant(ROOT / "data" / path)
|
||||||
|
|
||||||
|
|
||||||
|
def _spec() -> EncounterSpec:
|
||||||
|
"""Fast 1v1 (goblin vs orc) on the sample arena."""
|
||||||
|
return EncounterSpec(
|
||||||
|
map=load_map(MAP),
|
||||||
|
round_cap=20,
|
||||||
|
sides=(
|
||||||
|
Side(name="players", combatants=(_c("monsters/goblin.json"),), zone="A"),
|
||||||
|
Side(name="monsters", combatants=(_c("monsters/orc.json"),), zone="B"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_report_context_minimal() -> None:
|
||||||
|
"""Minimal context: only EncounterReport, no RunOutcome."""
|
||||||
|
registry = load_spell_registry(SPELLS_DIR)
|
||||||
|
spec = _spec()
|
||||||
|
report = run_encounter(spec, runs=10, seed=1, spell_registry=registry)
|
||||||
|
|
||||||
|
ctx = build_report_context(
|
||||||
|
encounter=report,
|
||||||
|
spec_name="goblin-vs-orc",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert isinstance(ctx, ReportContext)
|
||||||
|
assert ctx.encounter is report
|
||||||
|
assert ctx.spec_name == "goblin-vs-orc"
|
||||||
|
assert ctx.danger is None
|
||||||
|
assert ctx.nl_stats is None
|
||||||
|
assert ctx.client_stats is None
|
||||||
|
assert ctx.matchup is None
|
||||||
|
assert ctx.danger_map is None
|
||||||
|
assert ctx.frontier_value is None
|
||||||
|
assert ctx.sensitivity is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_report_context_with_outcomes() -> None:
|
||||||
|
"""Context with RunOutcome -> DangerMetrics computed for the party."""
|
||||||
|
registry = load_spell_registry(SPELLS_DIR)
|
||||||
|
spec = _spec()
|
||||||
|
report, outcomes = run_encounter(
|
||||||
|
spec, runs=100, seed=1, spell_registry=registry, collect_runs=True
|
||||||
|
)
|
||||||
|
|
||||||
|
ctx = build_report_context(
|
||||||
|
encounter=report,
|
||||||
|
spec_name="goblin-vs-orc",
|
||||||
|
outcomes=outcomes,
|
||||||
|
party="players",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert ctx.danger is not None
|
||||||
|
assert isinstance(ctx.danger, DangerMetrics)
|
||||||
|
assert ctx.danger.runs == 100
|
||||||
|
# p_survives, p_tpk, p_any_death are probabilities in [0, 1]
|
||||||
|
assert 0.0 <= ctx.danger.p_survives <= 1.0
|
||||||
|
assert 0.0 <= ctx.danger.p_tpk <= 1.0
|
||||||
|
assert 0.0 <= ctx.danger.p_any_death <= 1.0
|
||||||
|
assert ctx.danger.expected_hp_lost >= 0.0
|
||||||
|
assert ctx.danger.expected_rounds > 0.0
|
||||||
|
assert ctx.danger.worst_decile_party_hp >= 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_report_context_with_all_optional_fields() -> None:
|
||||||
|
"""Context with all optional fields populated."""
|
||||||
|
registry = load_spell_registry(SPELLS_DIR)
|
||||||
|
spec = _spec()
|
||||||
|
report, outcomes = run_encounter(
|
||||||
|
spec, runs=100, seed=1, spell_registry=registry, collect_runs=True
|
||||||
|
)
|
||||||
|
|
||||||
|
nl_stats = NLPolicyStats(calls=5, repairs_attempted=1, repairs_succeeded=1, fallbacks=0)
|
||||||
|
client_stats = ClientStats(
|
||||||
|
calls=5,
|
||||||
|
prompt_tokens=1000,
|
||||||
|
completion_tokens=200,
|
||||||
|
cost_usd=0.001,
|
||||||
|
cache_hits=0,
|
||||||
|
cache_misses=5,
|
||||||
|
)
|
||||||
|
|
||||||
|
ctx = build_report_context(
|
||||||
|
encounter=report,
|
||||||
|
spec_name="goblin-vs-orc",
|
||||||
|
outcomes=outcomes,
|
||||||
|
party="players",
|
||||||
|
nl_stats=nl_stats,
|
||||||
|
client_stats=client_stats,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert ctx.nl_stats is nl_stats
|
||||||
|
assert ctx.client_stats is client_stats
|
||||||
|
assert ctx.nl_stats.calls == 5 # type: ignore[reportOptionalMemberAccess]
|
||||||
|
assert ctx.nl_stats.repairs_attempted == 1 # type: ignore[reportOptionalMemberAccess]
|
||||||
|
assert ctx.nl_stats.repairs_succeeded == 1 # type: ignore[reportOptionalMemberAccess]
|
||||||
|
assert ctx.nl_stats.fallbacks == 0 # type: ignore[reportOptionalMemberAccess]
|
||||||
|
assert ctx.client_stats.prompt_tokens == 1000 # type: ignore[reportOptionalMemberAccess]
|
||||||
|
assert ctx.client_stats.cost_usd == 0.001 # type: ignore[reportOptionalMemberAccess]
|
||||||
|
|
||||||
|
|
||||||
|
def test_report_context_immutable() -> None:
|
||||||
|
"""ReportContext is a frozen dataclass (immutable)."""
|
||||||
|
registry = load_spell_registry(SPELLS_DIR)
|
||||||
|
spec = _spec()
|
||||||
|
report = run_encounter(spec, runs=5, seed=1, spell_registry=registry)
|
||||||
|
|
||||||
|
ctx = build_report_context(
|
||||||
|
encounter=report,
|
||||||
|
spec_name="test",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Frozen dataclass -> assignment raises FrozenInstanceError
|
||||||
|
with pytest.raises(FrozenInstanceError):
|
||||||
|
ctx.encounter = None # type: ignore[misc]
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
"""Tests for report prompt pack (P5.3 T2)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from pf1e_simulator.prompt import PromptPack
|
||||||
|
from pf1e_simulator.report import build_report_context, build_report_prompt
|
||||||
|
from pf1e_simulator.runner import CombatantAttrition, EncounterReport
|
||||||
|
|
||||||
|
|
||||||
|
def test_report_prompt_pack_version_stable() -> None:
|
||||||
|
"""Prompt pack version hash is stable for the fixture."""
|
||||||
|
pack_dir = Path(__file__).resolve().parent / "fixtures" / "prompt_pack_fs"
|
||||||
|
pack = PromptPack.load(pack_dir)
|
||||||
|
|
||||||
|
report_version = pack.prompt_version("report")
|
||||||
|
assert report_version.startswith("0.1.0-test:report:")
|
||||||
|
assert len(report_version.split(":")[2]) == 12 # hash12
|
||||||
|
|
||||||
|
|
||||||
|
def test_report_prompt_renders() -> None:
|
||||||
|
"""build_report_prompt renders the context correctly."""
|
||||||
|
pack_dir = Path(__file__).resolve().parent / "fixtures" / "prompt_pack_fs"
|
||||||
|
pack = PromptPack.load(pack_dir)
|
||||||
|
template = pack.get("report")
|
||||||
|
|
||||||
|
encounter = EncounterReport(
|
||||||
|
runs=100,
|
||||||
|
seed=1,
|
||||||
|
wins={"players": 65, "monsters": 33},
|
||||||
|
draws=2,
|
||||||
|
avg_rounds=7.2,
|
||||||
|
attrition={
|
||||||
|
"goblin": CombatantAttrition(2.45, 0.12, 18.30, 4.20, 0.00),
|
||||||
|
"orc": CombatantAttrition(3.20, 0.25, 28.40, 15.60, 0.00),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
ctx = build_report_context(
|
||||||
|
encounter=encounter,
|
||||||
|
spec_name="test",
|
||||||
|
)
|
||||||
|
|
||||||
|
prompt = build_report_prompt(ctx, template)
|
||||||
|
|
||||||
|
# Should contain the system prompt text
|
||||||
|
assert "Vous êtes le narrateur" in prompt
|
||||||
|
assert "Bilan d'équilibrage" in prompt
|
||||||
|
assert "Danger et attrition" in prompt
|
||||||
|
# Should contain the rendered encounter data
|
||||||
|
assert "Victoires players : 65.0%" in prompt or "Victoires players : 65" in prompt
|
||||||
|
assert "goblin" in prompt
|
||||||
|
assert "orc" in prompt
|
||||||
|
# Should contain attrition
|
||||||
|
assert "Attrition moyenne par combatant" in prompt
|
||||||
|
assert "touches" in prompt
|
||||||
|
|
||||||
|
|
||||||
|
def test_report_prompt_version_different_from_nl_default() -> None:
|
||||||
|
"""report prompt_version differs from nl_default."""
|
||||||
|
pack_dir = Path(__file__).resolve().parent / "fixtures" / "prompt_pack_fs"
|
||||||
|
pack = PromptPack.load(pack_dir)
|
||||||
|
|
||||||
|
report_version = pack.prompt_version("report")
|
||||||
|
nl_version = pack.prompt_version("nl_default")
|
||||||
|
assert report_version != nl_version
|
||||||
Reference in New Issue
Block a user