feat(combat): wire corner-rule LoS/cover into attack resolution
- weapon_for: gate attacks on has_line_of_effect (no LoE -> policy moves) - resolve_attack: +4 AC cover bonus on hit and crit-confirm (ranged flag) - tests: cover bonus (ranged pillar, melee wall corner), no-LoE move/attack, no-LoE unreachable wait; 11 resolve_attack call sites updated - README: quick-start and verified example re-measured (55.7% 1x2), rules and architecture updated (los.py wired)
This commit is contained in:
@@ -0,0 +1,323 @@
|
|||||||
|
# pf1e-simulator
|
||||||
|
|
||||||
|
Simulateur de combat Monte Carlo déterministe pour Pathfinder 1re édition.
|
||||||
|
|
||||||
|
Le moteur joue une rencontre définie (carte + camps) un grand nombre de fois —
|
||||||
|
chaque combat est reproductible grâce à un générateur aléatoire seedé — puis
|
||||||
|
produit un rapport d'équilibrage en français : taux de victoire par camp avec
|
||||||
|
bande de confiance 3σ, nuls, rounds moyens et attrition par combattant.
|
||||||
|
|
||||||
|
Phase 0 : le moteur de combat couvre le socle des règles (initiative, attaques,
|
||||||
|
critiques, RD, états de vie, déplacement, mêlée/distance). La couche LLM
|
||||||
|
(tactiques et rédaction du rapport) n'est pas encore implémentée.
|
||||||
|
|
||||||
|
## Fonctionnalités
|
||||||
|
|
||||||
|
- Carte en grille de cases de 5 ft (1,5 m) au format YAML : couches ASCII
|
||||||
|
`terrain`, `elevation`, `zones`, points nommés `markers`, déploiement par camp.
|
||||||
|
- Comptage des diagonales selon la règle 5-10-5, coûts de déplacement selon le
|
||||||
|
terrain, règle des coins pour les diagonales le long des murs.
|
||||||
|
- Monstres définis en JSON (bestiaire) ; combattants déployés dans les zones de
|
||||||
|
la carte ; ids dupliqués automatiquement désambiguïsés (`goblin`, `goblin-2`…).
|
||||||
|
- Combats déterministes : chaque run utilise un flux `SeededRng(seed + i)` ;
|
||||||
|
même seed ⇒ mêmes combats.
|
||||||
|
- Rapport d'équilibrage en français : taux de victoire + bande 3σ
|
||||||
|
(σ = √(p(1−p)/n)), nuls, rounds moyens, attrition moyenne par combattant
|
||||||
|
(touches, critiques, dégâts infligés/subis).
|
||||||
|
- Interface en ligne de commande `pf1e-sim`.
|
||||||
|
|
||||||
|
## Prérequis et installation
|
||||||
|
|
||||||
|
Prérequis :
|
||||||
|
|
||||||
|
- Python ≥ 3.12
|
||||||
|
- [uv](https://docs.astral.sh/uv/) (gestionnaire de paquets et d'environnements)
|
||||||
|
|
||||||
|
Installation :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone <url-du-dépôt> pf1e-simulator
|
||||||
|
cd pf1e-simulator
|
||||||
|
uv sync --dev # inclut pytest, ruff et basedpyright
|
||||||
|
```
|
||||||
|
|
||||||
|
`uv sync` seul suffit pour n'utiliser que la CLI (pas d'outillage de dev).
|
||||||
|
Vérification :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run pf1e-sim --help
|
||||||
|
```
|
||||||
|
|
||||||
|
## Démarrage rapide
|
||||||
|
|
||||||
|
La carte d'exemple `data/maps/sample_arena.yaml` et les monstres
|
||||||
|
`data/monsters/goblin.json` / `data/monsters/orc.json` permettent de lancer une
|
||||||
|
première simulation immédiatement. 3 gobelins (camp `players`) contre 2 orcs
|
||||||
|
(camp `monsters`), 1000 combats, seed 1 :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run pf1e-sim \
|
||||||
|
--map data/maps/sample_arena.yaml \
|
||||||
|
--side players data/monsters/goblin.json data/monsters/goblin.json data/monsters/goblin.json \
|
||||||
|
--side monsters data/monsters/orc.json data/monsters/orc.json \
|
||||||
|
--runs 1000 --seed 1
|
||||||
|
```
|
||||||
|
|
||||||
|
Sortie (extrait) :
|
||||||
|
|
||||||
|
```
|
||||||
|
=== Rapport d'équilibrage ===
|
||||||
|
Carte : Arène d'essai
|
||||||
|
players : 3 combatants
|
||||||
|
monsters : 2 combatants
|
||||||
|
1000 combats simulés (seed 1, plafond 100 rounds)
|
||||||
|
|
||||||
|
Victoires players : 100.0% (1000) — bande 3σ : [100.0%, 100.0%]
|
||||||
|
Victoires monsters : 0.0% (0) — bande 3σ : [0.0%, 0.0%]
|
||||||
|
Nuls : 0.0% (0)
|
||||||
|
Rounds moyens : 5.8
|
||||||
|
```
|
||||||
|
|
||||||
|
## Utilisation de la CLI
|
||||||
|
|
||||||
|
```
|
||||||
|
usage: pf1e-sim [-h] --map MAP [--side NAME [FILE ...]] [--runs RUNS]
|
||||||
|
[--seed SEED] [--round-cap ROUND_CAP]
|
||||||
|
```
|
||||||
|
|
||||||
|
| Option | Rôle | Défaut |
|
||||||
|
|---|---|---|
|
||||||
|
| `--map MAP` | Fichier YAML de la carte (obligatoire) | — |
|
||||||
|
| `--side NAME [FILE ...]` | Camp `NAME` avec un ou plusieurs JSON de monstres ; répétable pour chaque camp | — |
|
||||||
|
| `--runs RUNS` | Nombre de combats simulés | `1000` |
|
||||||
|
| `--seed SEED` | Graine du générateur aléatoire | `1` |
|
||||||
|
| `--round-cap ROUND_CAP` | Plafond de rounds par combat (au-delà, combat nul) | `100` |
|
||||||
|
|
||||||
|
Codes de sortie :
|
||||||
|
|
||||||
|
- `0` — simulation terminée, rapport imprimé.
|
||||||
|
- `2` — erreur d'utilisation ou de données (message sur stderr) : côté
|
||||||
|
inconnu, côté de déploiement manquant, zone trop petite, fichier invalide…
|
||||||
|
|
||||||
|
Règles de construction d'une rencontre :
|
||||||
|
|
||||||
|
- Les noms de camps (`--side NAME`) doivent correspondre exactement à la
|
||||||
|
section `deployment` de la carte, et **tous** les camps du déploiement
|
||||||
|
doivent être fournis.
|
||||||
|
- Le même fichier monstre peut être répété pour créer plusieurs combattants
|
||||||
|
identiques ; les ids sont désambiguïsés globalement dans l'ordre de
|
||||||
|
déploiement (`goblin`, `goblin-2`, `goblin-3`).
|
||||||
|
- Les combattants d'un camp remplissent les cases de leur zone dans l'ordre
|
||||||
|
(ligne, colonne). Une zone trop petite pour le nombre de combattants est une
|
||||||
|
erreur.
|
||||||
|
|
||||||
|
## Interpréter le rapport
|
||||||
|
|
||||||
|
Pour chaque camp, le rapport donne le taux de victoire observé et une bande de
|
||||||
|
confiance à 3σ calculée en forme fermée (modèle binomial) :
|
||||||
|
|
||||||
|
- σ = √(p(1−p)/n) ; la bande est [p − 3σ, p + 3σ], bornée à [0, 1].
|
||||||
|
- Un affrontement symétrique (combattants identiques sur des zones en miroir)
|
||||||
|
doit rester proche de 50 % ; un écart hors de la bande 3σ signale un
|
||||||
|
déséquilibre (avantage d'initiative, géométrie, portées…).
|
||||||
|
- Une bande large indique un échantillon trop petit pour conclure — augmentez
|
||||||
|
`--runs`.
|
||||||
|
- L'attrition moyenne par combattant indique qui participe réellement : un
|
||||||
|
combattant à distance qui n'est jamais rejoint affiche des dégâts subis
|
||||||
|
proches de zéro, un combattant qui meurt systématiquement affiche des dégâts
|
||||||
|
subis supérieurs à ses PV maximum (dégâts du coup fatal inclus).
|
||||||
|
- Les nuls proviennent des combats atteignant le plafond de rounds (combattants
|
||||||
|
immobiles, camps inaccessibles…).
|
||||||
|
|
||||||
|
Exemple vérifié — 1 gobelin (players) contre 2 orcs (monsters), 1000 runs,
|
||||||
|
seed 1 : players 55,7 %, bande 3σ [51,0 % ; 60,4 %], 0 nuls, 13,1 rounds moyens.
|
||||||
|
Sans ligne de visée depuis la zone de départ, l'archer gobelin doit contourner
|
||||||
|
le mur central avant de tirer — c'est ce qui coûte des rounds.
|
||||||
|
|
||||||
|
## Formats de données
|
||||||
|
|
||||||
|
### Carte (YAML)
|
||||||
|
|
||||||
|
Chaque couche est un bloc ASCII de mêmes dimensions ; un caractère = une case
|
||||||
|
de `square_size_ft` pieds. Seule `terrain` est obligatoire.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
name: "Arène d'essai"
|
||||||
|
square_size_ft: 5
|
||||||
|
|
||||||
|
terrain: | # couche obligatoire
|
||||||
|
####################
|
||||||
|
#......######......#
|
||||||
|
#..C...######...C..#
|
||||||
|
#......~..~........#
|
||||||
|
#..TT..~..~...TT...#
|
||||||
|
#......~..~........#
|
||||||
|
#..C............C..#
|
||||||
|
####################
|
||||||
|
|
||||||
|
legend: # un caractère = un type de terrain
|
||||||
|
"#": { type: wall, move_cost: null, blocks_los: true } # bloque déplacement + LoS
|
||||||
|
".": { type: floor, move_cost: 1 }
|
||||||
|
"T": { type: rubble, move_cost: 2 } # terrain difficile
|
||||||
|
"~": { type: water, move_cost: 2 }
|
||||||
|
"C": { type: pillar, move_cost: null, cover: true } # bloque le pas, couvert
|
||||||
|
|
||||||
|
elevation: | # optionnel : chiffres = hauteur (validé, pas encore appliqué)
|
||||||
|
....................
|
||||||
|
..1111..............
|
||||||
|
..1111..............
|
||||||
|
....................
|
||||||
|
|
||||||
|
zones: | # optionnel : lettres de zone de déploiement
|
||||||
|
....................
|
||||||
|
.AAAA..........BBBB.
|
||||||
|
.AAAA..........BBBB.
|
||||||
|
....................
|
||||||
|
|
||||||
|
markers: # optionnel : points nommés (validés, pas encore utilisés)
|
||||||
|
autel: [1, 16]
|
||||||
|
|
||||||
|
deployment: # obligatoire si zones : nom de camp -> lettre de zone
|
||||||
|
players: A
|
||||||
|
monsters: B
|
||||||
|
```
|
||||||
|
|
||||||
|
Valeur du `move_cost` : entier > 0 pour un terrain praticable, `null` pour un
|
||||||
|
terrain infranchissable (mur, pilier). `blocks_los` et `cover` sont appliqués
|
||||||
|
par la résolution des attaques (module `los.py`, règle des coins) : pas de
|
||||||
|
ligne d'effet ⇒ l'attaque est impossible ; couvert ⇒ bonus de +4 CA.
|
||||||
|
|
||||||
|
### Monstre (JSON)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "Goblin",
|
||||||
|
"level": 1,
|
||||||
|
"size": "Small",
|
||||||
|
"cr": "1/3",
|
||||||
|
"xp": 135,
|
||||||
|
"source": "Bestiary > Goblin",
|
||||||
|
"abilities": {
|
||||||
|
"str_score": 11, "dex_score": 15, "con_score": 12,
|
||||||
|
"int_score": 10, "wis_score": 9, "cha_score": 6
|
||||||
|
},
|
||||||
|
"hp_max": 6,
|
||||||
|
"ac": { "total": 16, "touch": 13, "flat_footed": 14 },
|
||||||
|
"bab": 1,
|
||||||
|
"initiative_mod": 6,
|
||||||
|
"speed_land_ft": 30,
|
||||||
|
"saves": { "fort": 3, "ref": 2, "will": -1 },
|
||||||
|
"attacks": [
|
||||||
|
{
|
||||||
|
"name": "short sword",
|
||||||
|
"kind": "melee",
|
||||||
|
"attack_bonus": 2,
|
||||||
|
"damage": [{ "formula": "1d4", "types": ["slashing"] }],
|
||||||
|
"crit_range": 19,
|
||||||
|
"crit_mult": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "short bow",
|
||||||
|
"kind": "ranged",
|
||||||
|
"attack_bonus": 4,
|
||||||
|
"damage": [{ "formula": "1d4", "types": ["piercing"] }],
|
||||||
|
"crit_mult": 3,
|
||||||
|
"range_increment_ft": 60
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Champ `range_increment_ft` (optionnel, uniquement pour `kind: ranged`) : portée
|
||||||
|
maximale de tir — au-delà, l'arme n'est pas utilisable. `damage_bonus` et `dr`
|
||||||
|
sont optionnels. Deux monstres d'exemple sont fournis : gobelin et orc.
|
||||||
|
|
||||||
|
### Fiches de personnages
|
||||||
|
|
||||||
|
`fiches_personnages/` contient 8 fiches de PJ au format Foundry VTT
|
||||||
|
(`pf1-sheet/v1`) ; le chargeur `loaders/foundry.py` sait les lire. Les
|
||||||
|
rencontres CLI se construisent pour l'instant avec des monstres JSON — les
|
||||||
|
fiches Foundry alimenteront la couche PJ dans une phase ultérieure.
|
||||||
|
|
||||||
|
## Règles implémentées et limites de la Phase 0
|
||||||
|
|
||||||
|
Règles modélisées :
|
||||||
|
|
||||||
|
- 1 naturel = échec automatique ; 20 naturel = touche + menace de critique.
|
||||||
|
- Critique : (dés + bonus) × multiplicateur ; la RD s'applique après
|
||||||
|
multiplication, les types perforants annulent la RD, les dégâts plancher à 0.
|
||||||
|
- Mort quand `hp < min(-10, -CON)` ; `hp ≤ 0` = inactif.
|
||||||
|
- Initiative : triée sur (total, modificateur, ordre de liste), sans re-jet.
|
||||||
|
- Une action par tour : se déplacer OU attaquer.
|
||||||
|
- Mêlée : allonge d'une case ; mouvement : un pas par action, le long du plus
|
||||||
|
court chemin réel (champ de coût Dijkstra depuis la cible — les combattants
|
||||||
|
contournent les murs au lieu d'osciller contre eux).
|
||||||
|
- Ligne d'effet : une cible entièrement derrière un terrain `blocks_los` ne
|
||||||
|
peut pas être attaquée — la politique se déplace jusqu'à gagner une ligne de
|
||||||
|
visée.
|
||||||
|
- Couvert (règle des coins) : la cible gagne +4 CA sur la touche et la
|
||||||
|
confirmation de critique ; le couvert octroyé par les créatures n'est pas
|
||||||
|
modélisé.
|
||||||
|
- Distance : l'arme doit être à portée (range_increment_ft) ; aucune pénalité
|
||||||
|
de portée au-delà du premier incrément.
|
||||||
|
|
||||||
|
Non modélisé en Phase 0 (couches `elevation`/`markers` présentes mais non
|
||||||
|
appliquées dans la résolution) :
|
||||||
|
|
||||||
|
- Sorts, jets de sauvegarde, conditions et états.
|
||||||
|
- Attaques d'opportunité, flanquement, manœuvres de combat.
|
||||||
|
- Pénalités de portée (distance) et couvert mou des créatures.
|
||||||
|
- Effets mécaniques de hauteur/élévation.
|
||||||
|
- Tailles Large+ (2×2), allonge > 5 ft, attaques itératives (une attaque par
|
||||||
|
tour), économie d'action complète (charge, pas de placement…).
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
- `dice.py` — parseur de notation de dés (`2d4+1`) et lancer.
|
||||||
|
- `rng.py` — `SeededRng` (flux reproductible par graine) et `ScriptedRng`
|
||||||
|
(séquence de dés scriptée pour les tests de transcripts).
|
||||||
|
- `models.py` — schémas Pydantic v2 : `Combatant`, `AbilityScores`,
|
||||||
|
`ACProfile`, `AttackSpec`, `DamageComponent`, `Saves`, `DamageReduction`.
|
||||||
|
- `map.py` — `MapSpec`/`TerrainType`, chargement YAML et validation
|
||||||
|
(dimensions, caractères connus, zones, déploiement, markers).
|
||||||
|
- `grid.py` — grille 5-10-5 : `distance`, `step_cost`, règle des coins
|
||||||
|
(`diagonal_allowed`), Dijkstra `reachable` avec ou sans budget.
|
||||||
|
- `los.py` — ligne d'effet et couvert par la règle des coins : gate de ligne
|
||||||
|
d'effet dans `weapon_for`, bonus de couvert +4 CA dans `resolve_attack`.
|
||||||
|
- `combat.py` — `CombatEngine` déterministe : initiative, actions, résolution
|
||||||
|
des attaques (couvert, ligne d'effet), états de vie, transcripts, politique
|
||||||
|
par défaut.
|
||||||
|
- `metrics.py` — statistiques en forme fermée : `win_rate`, `win_rate_sigma`,
|
||||||
|
`win_rate_band` (bande 3σ bornée à [0, 1]).
|
||||||
|
- `runner.py` — `EncounterSpec`/`Side`, `build_states` (placement en zone +
|
||||||
|
désambiguïsation des ids), `run_encounter` (un `SeededRng` par run) et
|
||||||
|
agrégation en `EncounterReport` avec attrition par combattant.
|
||||||
|
- `cli.py` — front-end argparse `pf1e-sim`, rapport français, code de sortie 2
|
||||||
|
en cas d'erreur.
|
||||||
|
- `loaders/` — `foundry.py` (fiches Foundry `pf1-sheet/v1`) et `monster.py`
|
||||||
|
(JSON de monstre).
|
||||||
|
|
||||||
|
## Développement
|
||||||
|
|
||||||
|
La gate de validation complète (tests + lint + types) :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run pytest -q # 170 tests
|
||||||
|
uv run ruff check src tests
|
||||||
|
uv run basedpyright src # mode strict
|
||||||
|
```
|
||||||
|
|
||||||
|
- Ruff est configuré avec `select = ["ALL"]`, longueur de ligne 100.
|
||||||
|
- Basedpyright tourne en `typeCheckingMode = "strict"` sur `src` et `tests`.
|
||||||
|
- Le moteur est testé par des transcripts épinglés (combats 1v1 et 2v2
|
||||||
|
scriptés) et par des assertions statistiques en forme fermée (matchup
|
||||||
|
symétrique dans la bande 3σ).
|
||||||
|
|
||||||
|
## Feuille de route
|
||||||
|
|
||||||
|
- **Phase 1** — magie et états : jets de sauvegarde, sorts modélisés comme
|
||||||
|
effets paramétrés, conditions, flanquement, attaques d'opportunité.
|
||||||
|
- **Phase 2** — couche tactique LLM : stratégies en langage naturel traduites
|
||||||
|
en politiques, balayage de matrices de positionnement.
|
||||||
|
- **Phase 3** — rapporteur LLM local : agrégation des statistiques et
|
||||||
|
rédaction du rapport d'équilibrage en français.
|
||||||
@@ -9,7 +9,12 @@ Phase 0 documented deviations from PF1e (conventions):
|
|||||||
- Initiative ties: higher initiative_mod first, then list order (no re-roll).
|
- Initiative ties: higher initiative_mod first, then list order (no re-roll).
|
||||||
- Movement: one step per move action toward the nearest enemy, following the
|
- Movement: one step per move action toward the nearest enemy, following the
|
||||||
true shortest path (Dijkstra cost field from the target); ties keep delta order.
|
true shortest path (Dijkstra cost field from the target); ties keep delta order.
|
||||||
- Ranged attacks ignore cover and range penalties in Phase 0.
|
- Line of effect gates all attacks: a target fully behind blocking terrain
|
||||||
|
cannot be attacked, and the policy moves to gain sight instead.
|
||||||
|
- Cover (corner rule) grants +4 AC on hit and crit-confirm rolls; melee and
|
||||||
|
ranged reuse the same corner rule.
|
||||||
|
- Ranged: only the first range increment is enforced in Phase 0 (no distance
|
||||||
|
penalty, no soft cover from creatures).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -26,10 +31,13 @@ if TYPE_CHECKING:
|
|||||||
from pf1e_simulator.models import AttackSpec, Combatant, DamageReduction
|
from pf1e_simulator.models import AttackSpec, Combatant, DamageReduction
|
||||||
from pf1e_simulator.rng import Rng
|
from pf1e_simulator.rng import Rng
|
||||||
|
|
||||||
|
from pf1e_simulator.los import has_cover, has_line_of_effect
|
||||||
|
|
||||||
_SQUARE_FT = 5 # Phase 0 maps use 5-ft squares
|
_SQUARE_FT = 5 # Phase 0 maps use 5-ft squares
|
||||||
_DEATH_FLOOR = -10 # PF1e: dead when hp < -10 or -CON, whichever is lower
|
_DEATH_FLOOR = -10 # PF1e: dead when hp < -10 or -CON, whichever is lower
|
||||||
_NATURAL_ONE = 1 # PF1e: natural 1 always misses
|
_NATURAL_ONE = 1 # PF1e: natural 1 always misses
|
||||||
_NATURAL_TWENTY = 20 # PF1e: natural 20 always hits and threatens
|
_NATURAL_TWENTY = 20 # PF1e: natural 20 always hits and threatens
|
||||||
|
_COVER_AC_BONUS = 4 # PF1e: partial cover grants +4 AC
|
||||||
|
|
||||||
_STEP_DELTAS: tuple[Pos, ...] = (
|
_STEP_DELTAS: tuple[Pos, ...] = (
|
||||||
(-1, -1),
|
(-1, -1),
|
||||||
@@ -172,7 +180,9 @@ class CombatEngine:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def weapon_for(self, attacker: CombatantState, target: CombatantState) -> AttackSpec | None:
|
def weapon_for(self, attacker: CombatantState, target: CombatantState) -> AttackSpec | None:
|
||||||
"""First weapon of the attacker usable against the target at this range."""
|
"""First weapon usable against the target at this range and with clear LoE."""
|
||||||
|
if not has_line_of_effect(self._grid, attacker.pos, target.pos):
|
||||||
|
return None
|
||||||
dist_ft = self._grid.distance(attacker.pos, target.pos) * _SQUARE_FT
|
dist_ft = self._grid.distance(attacker.pos, target.pos) * _SQUARE_FT
|
||||||
for weapon in attacker.combatant.attacks:
|
for weapon in attacker.combatant.attacks:
|
||||||
if weapon.kind in ("melee", "touch") and dist_ft <= weapon.reach_ft:
|
if weapon.kind in ("melee", "touch") and dist_ft <= weapon.reach_ft:
|
||||||
@@ -186,12 +196,14 @@ class CombatEngine:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
def resolve_attack(
|
def resolve_attack(
|
||||||
self, target: CombatantState, weapon: AttackSpec
|
self, attacker: CombatantState, target: CombatantState, weapon: AttackSpec
|
||||||
) -> AttackResult:
|
) -> AttackResult:
|
||||||
"""Roll one attack (with crit confirm and damage) and apply it."""
|
"""Roll one attack (with crit confirm and damage) and apply it."""
|
||||||
roll = self._rng.d20()
|
roll = self._rng.d20()
|
||||||
total = roll + weapon.attack_bonus
|
total = roll + weapon.attack_bonus
|
||||||
ac = target.combatant.ac.total
|
ac = target.combatant.ac.total
|
||||||
|
if has_cover(self._grid, attacker.pos, target.pos, ranged=weapon.kind == "ranged"):
|
||||||
|
ac += _COVER_AC_BONUS
|
||||||
hit = roll == _NATURAL_TWENTY or (roll != _NATURAL_ONE and total >= ac)
|
hit = roll == _NATURAL_TWENTY or (roll != _NATURAL_ONE and total >= ac)
|
||||||
crit = False
|
crit = False
|
||||||
damage = 0
|
damage = 0
|
||||||
@@ -266,7 +278,7 @@ class CombatEngine:
|
|||||||
self, attacker: CombatantState, target: CombatantState, weapon: AttackSpec, round_no: int
|
self, attacker: CombatantState, target: CombatantState, weapon: AttackSpec, round_no: int
|
||||||
) -> None:
|
) -> None:
|
||||||
hp_before = target.hp
|
hp_before = target.hp
|
||||||
result = self.resolve_attack(target, weapon)
|
result = self.resolve_attack(attacker, target, weapon)
|
||||||
live = self._stats[attacker.combatant.id]
|
live = self._stats[attacker.combatant.id]
|
||||||
live.hits += int(result.hit)
|
live.hits += int(result.hit)
|
||||||
live.crits += int(result.crit)
|
live.crits += int(result.crit)
|
||||||
|
|||||||
+97
-14
@@ -6,6 +6,8 @@ end to end: dice order, log lines, winner, and per-combatant stats.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
from pf1e_simulator.combat import (
|
from pf1e_simulator.combat import (
|
||||||
CombatantState,
|
CombatantState,
|
||||||
CombatantStats,
|
CombatantStats,
|
||||||
@@ -40,18 +42,21 @@ def make_combatant(
|
|||||||
initiative_mod: int = 0,
|
initiative_mod: int = 0,
|
||||||
con: int = 12,
|
con: int = 12,
|
||||||
speed: int = 30,
|
speed: int = 30,
|
||||||
weapon_name: str = "short sword",
|
weapon_name: str | None = None,
|
||||||
dr: DamageReduction | None = None,
|
dr: DamageReduction | None = None,
|
||||||
|
kind: Literal["melee", "ranged", "touch"] = "melee",
|
||||||
|
range_increment_ft: int | None = None,
|
||||||
) -> Combatant:
|
) -> Combatant:
|
||||||
attack = AttackSpec(
|
attack = AttackSpec(
|
||||||
id=f"{cid}-w",
|
id=f"{cid}-w",
|
||||||
name=weapon_name,
|
name=weapon_name or ("short bow" if kind == "ranged" else "short sword"),
|
||||||
kind="melee",
|
kind=kind,
|
||||||
attack_bonus=attack_bonus,
|
attack_bonus=attack_bonus,
|
||||||
damage=[DamageComponent(formula=parse_dice(damage), types=["slashing"])],
|
damage=[DamageComponent(formula=parse_dice(damage), types=["slashing"])],
|
||||||
damage_bonus=damage_bonus,
|
damage_bonus=damage_bonus,
|
||||||
crit_range=crit_range,
|
crit_range=crit_range,
|
||||||
crit_mult=crit_mult,
|
crit_mult=crit_mult,
|
||||||
|
range_increment_ft=range_increment_ft,
|
||||||
)
|
)
|
||||||
return Combatant(
|
return Combatant(
|
||||||
id=cid,
|
id=cid,
|
||||||
@@ -99,7 +104,7 @@ def test_attack_hit_deals_damage() -> None:
|
|||||||
a = make_state(a_spec, "players", (1, 1))
|
a = make_state(a_spec, "players", (1, 1))
|
||||||
b = make_state(b_spec, "monsters", (1, 2))
|
b = make_state(b_spec, "monsters", (1, 2))
|
||||||
engine = make_engine([12, 3], [a, b])
|
engine = make_engine([12, 3], [a, b])
|
||||||
result = engine.resolve_attack(b, a_spec.attacks[0])
|
result = engine.resolve_attack(a, b, a_spec.attacks[0])
|
||||||
assert result.hit is True
|
assert result.hit is True
|
||||||
assert result.crit is False
|
assert result.crit is False
|
||||||
assert result.damage == 3
|
assert result.damage == 3
|
||||||
@@ -112,7 +117,7 @@ def test_attack_miss_leaves_target_untouched() -> None:
|
|||||||
a = make_state(a_spec, "players", (1, 1))
|
a = make_state(a_spec, "players", (1, 1))
|
||||||
b = make_state(b_spec, "monsters", (1, 2))
|
b = make_state(b_spec, "monsters", (1, 2))
|
||||||
engine = make_engine([9], [a, b])
|
engine = make_engine([9], [a, b])
|
||||||
result = engine.resolve_attack(b, a_spec.attacks[0])
|
result = engine.resolve_attack(a, b, a_spec.attacks[0])
|
||||||
assert result.hit is False
|
assert result.hit is False
|
||||||
assert result.damage == 0
|
assert result.damage == 0
|
||||||
assert b.hp == 6
|
assert b.hp == 6
|
||||||
@@ -124,7 +129,7 @@ def test_natural_1_always_misses() -> None:
|
|||||||
a = make_state(a_spec, "players", (1, 1))
|
a = make_state(a_spec, "players", (1, 1))
|
||||||
b = make_state(b_spec, "monsters", (1, 2))
|
b = make_state(b_spec, "monsters", (1, 2))
|
||||||
engine = make_engine([1], [a, b])
|
engine = make_engine([1], [a, b])
|
||||||
result = engine.resolve_attack(b, a_spec.attacks[0])
|
result = engine.resolve_attack(a, b, a_spec.attacks[0])
|
||||||
assert result.hit is False
|
assert result.hit is False
|
||||||
assert b.hp == 6
|
assert b.hp == 6
|
||||||
|
|
||||||
@@ -135,7 +140,7 @@ def test_natural_20_threatens_and_crits() -> None:
|
|||||||
a = make_state(a_spec, "players", (1, 1))
|
a = make_state(a_spec, "players", (1, 1))
|
||||||
b = make_state(b_spec, "monsters", (1, 2))
|
b = make_state(b_spec, "monsters", (1, 2))
|
||||||
engine = make_engine([20, 12, 4], [a, b])
|
engine = make_engine([20, 12, 4], [a, b])
|
||||||
result = engine.resolve_attack(b, a_spec.attacks[0])
|
result = engine.resolve_attack(a, b, a_spec.attacks[0])
|
||||||
assert result.crit is True
|
assert result.crit is True
|
||||||
assert result.damage == 8
|
assert result.damage == 8
|
||||||
assert b.hp == -2
|
assert b.hp == -2
|
||||||
@@ -147,7 +152,7 @@ def test_crit_range_19_threatens_on_19() -> None:
|
|||||||
a = make_state(a_spec, "players", (1, 1))
|
a = make_state(a_spec, "players", (1, 1))
|
||||||
b = make_state(b_spec, "monsters", (1, 2))
|
b = make_state(b_spec, "monsters", (1, 2))
|
||||||
engine = make_engine([19, 11, 2], [a, b])
|
engine = make_engine([19, 11, 2], [a, b])
|
||||||
result = engine.resolve_attack(b, a_spec.attacks[0])
|
result = engine.resolve_attack(a, b, a_spec.attacks[0])
|
||||||
assert result.crit is True
|
assert result.crit is True
|
||||||
assert result.damage == 4
|
assert result.damage == 4
|
||||||
assert b.hp == 2
|
assert b.hp == 2
|
||||||
@@ -159,7 +164,7 @@ def test_confirm_fail_is_normal_hit() -> None:
|
|||||||
a = make_state(a_spec, "players", (1, 1))
|
a = make_state(a_spec, "players", (1, 1))
|
||||||
b = make_state(b_spec, "monsters", (1, 2))
|
b = make_state(b_spec, "monsters", (1, 2))
|
||||||
engine = make_engine([19, 8, 2], [a, b])
|
engine = make_engine([19, 8, 2], [a, b])
|
||||||
result = engine.resolve_attack(b, a_spec.attacks[0])
|
result = engine.resolve_attack(a, b, a_spec.attacks[0])
|
||||||
assert result.hit is True
|
assert result.hit is True
|
||||||
assert result.crit is False
|
assert result.crit is False
|
||||||
assert result.damage == 2
|
assert result.damage == 2
|
||||||
@@ -173,7 +178,7 @@ def test_no_threat_outside_crit_range() -> None:
|
|||||||
b = make_state(b_spec, "monsters", (1, 2))
|
b = make_state(b_spec, "monsters", (1, 2))
|
||||||
# Only 2 rolls consumed: no confirm roll happens outside the threat range.
|
# Only 2 rolls consumed: no confirm roll happens outside the threat range.
|
||||||
engine = make_engine([18, 3], [a, b])
|
engine = make_engine([18, 3], [a, b])
|
||||||
result = engine.resolve_attack(b, a_spec.attacks[0])
|
result = engine.resolve_attack(a, b, a_spec.attacks[0])
|
||||||
assert result.crit is False
|
assert result.crit is False
|
||||||
assert result.damage == 3
|
assert result.damage == 3
|
||||||
assert b.hp == 3
|
assert b.hp == 3
|
||||||
@@ -185,7 +190,7 @@ def test_dr_reduces_damage() -> None:
|
|||||||
a = make_state(a_spec, "players", (1, 1))
|
a = make_state(a_spec, "players", (1, 1))
|
||||||
b = make_state(b_spec, "monsters", (1, 2))
|
b = make_state(b_spec, "monsters", (1, 2))
|
||||||
engine = make_engine([12, 7], [a, b])
|
engine = make_engine([12, 7], [a, b])
|
||||||
result = engine.resolve_attack(b, a_spec.attacks[0])
|
result = engine.resolve_attack(a, b, a_spec.attacks[0])
|
||||||
assert result.damage == 2
|
assert result.damage == 2
|
||||||
assert b.hp == 4
|
assert b.hp == 4
|
||||||
|
|
||||||
@@ -196,7 +201,7 @@ def test_dr_floors_damage_at_zero() -> None:
|
|||||||
a = make_state(a_spec, "players", (1, 1))
|
a = make_state(a_spec, "players", (1, 1))
|
||||||
b = make_state(b_spec, "monsters", (1, 2))
|
b = make_state(b_spec, "monsters", (1, 2))
|
||||||
engine = make_engine([12, 3], [a, b])
|
engine = make_engine([12, 3], [a, b])
|
||||||
result = engine.resolve_attack(b, a_spec.attacks[0])
|
result = engine.resolve_attack(a, b, a_spec.attacks[0])
|
||||||
assert result.damage == 0
|
assert result.damage == 0
|
||||||
assert b.hp == 6
|
assert b.hp == 6
|
||||||
|
|
||||||
@@ -207,7 +212,7 @@ def test_dr_bypass_ignores_reduction() -> None:
|
|||||||
a = make_state(a_spec, "players", (1, 1))
|
a = make_state(a_spec, "players", (1, 1))
|
||||||
b = make_state(b_spec, "monsters", (1, 2))
|
b = make_state(b_spec, "monsters", (1, 2))
|
||||||
engine = make_engine([12, 7], [a, b])
|
engine = make_engine([12, 7], [a, b])
|
||||||
result = engine.resolve_attack(b, a_spec.attacks[0])
|
result = engine.resolve_attack(a, b, a_spec.attacks[0])
|
||||||
assert result.damage == 7
|
assert result.damage == 7
|
||||||
assert b.hp == -1
|
assert b.hp == -1
|
||||||
|
|
||||||
@@ -218,7 +223,7 @@ def test_dr_applies_after_crit_multiplier() -> None:
|
|||||||
a = make_state(a_spec, "players", (1, 1))
|
a = make_state(a_spec, "players", (1, 1))
|
||||||
b = make_state(b_spec, "monsters", (1, 2))
|
b = make_state(b_spec, "monsters", (1, 2))
|
||||||
engine = make_engine([20, 11, 6], [a, b])
|
engine = make_engine([20, 11, 6], [a, b])
|
||||||
result = engine.resolve_attack(b, a_spec.attacks[0])
|
result = engine.resolve_attack(a, b, a_spec.attacks[0])
|
||||||
assert result.crit is True
|
assert result.crit is True
|
||||||
assert result.damage == 7
|
assert result.damage == 7
|
||||||
assert b.hp == -1
|
assert b.hp == -1
|
||||||
@@ -415,3 +420,81 @@ def test_melee_routes_around_wall() -> None:
|
|||||||
result = engine.run()
|
result = engine.run()
|
||||||
assert result.winner == "players"
|
assert result.winner == "players"
|
||||||
assert result.stats["mover"].hits > 0
|
assert result.stats["mover"].hits > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_ranged_attack_gets_cover_bonus() -> None:
|
||||||
|
"""A pillar between shooter and target grants partial cover (+4 AC)."""
|
||||||
|
legend = {
|
||||||
|
".": TerrainType(type="floor", move_cost=1),
|
||||||
|
"C": TerrainType(type="pillar", move_cost=None, cover=True),
|
||||||
|
}
|
||||||
|
spec = MapSpec(name="cover-test", terrain=(".C...",), legend=legend)
|
||||||
|
grid = Grid.from_spec(spec)
|
||||||
|
archer = make_combatant("archer", attack_bonus=4, kind="ranged", range_increment_ft=60)
|
||||||
|
target = make_combatant("target", ac=13)
|
||||||
|
states = [make_state(archer, "players", (0, 0)), make_state(target, "monsters", (0, 3))]
|
||||||
|
engine = CombatEngine(ScriptedRng([12, 3]), grid, states)
|
||||||
|
result = engine.resolve_attack(states[0], states[1], archer.attacks[0])
|
||||||
|
assert result.ac == 17 # 13 + 4 cover bonus
|
||||||
|
assert result.hit is False # 12 + 4 = 16 < 17
|
||||||
|
|
||||||
|
|
||||||
|
def test_melee_attack_gets_cover_bonus_across_wall_corner() -> None:
|
||||||
|
"""Diagonal melee across a wall corner grants cover (+4 AC)."""
|
||||||
|
legend = {
|
||||||
|
".": TerrainType(type="floor", move_cost=1),
|
||||||
|
"#": TerrainType(type="wall", move_cost=None, blocks_los=True),
|
||||||
|
}
|
||||||
|
spec = MapSpec(name="corner-test", terrain=(".#.", "#.."), legend=legend)
|
||||||
|
grid = Grid.from_spec(spec)
|
||||||
|
attacker = make_state(make_combatant("a", attack_bonus=2), "players", (0, 0))
|
||||||
|
target = make_state(make_combatant("b", ac=13), "monsters", (1, 1))
|
||||||
|
engine = CombatEngine(ScriptedRng([12, 3]), grid, [attacker, target])
|
||||||
|
result = engine.resolve_attack(attacker, target, attacker.combatant.attacks[0])
|
||||||
|
assert result.ac == 17
|
||||||
|
assert result.hit is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_ranged_no_line_of_effect_moves_around_wall() -> None:
|
||||||
|
"""An archer without line of effect cannot shoot; the policy moves until it gains sight."""
|
||||||
|
legend = {
|
||||||
|
".": TerrainType(type="floor", move_cost=1),
|
||||||
|
"#": TerrainType(type="wall", move_cost=None, blocks_los=True),
|
||||||
|
}
|
||||||
|
spec = MapSpec(name="wall-block", terrain=("..#..", "....."), legend=legend)
|
||||||
|
grid = Grid.from_spec(spec)
|
||||||
|
archer = make_combatant("archer", attack_bonus=4, kind="ranged", range_increment_ft=60)
|
||||||
|
target = make_combatant("target", speed=0)
|
||||||
|
states = [make_state(archer, "players", (0, 1)), make_state(target, "monsters", (0, 4))]
|
||||||
|
engine = CombatEngine(ScriptedRng([10, 9, 11, 3, 5, 14, 2, 8]), grid, states, round_cap=4)
|
||||||
|
result = engine.run()
|
||||||
|
assert result.winner is None
|
||||||
|
assert result.transcript == (
|
||||||
|
"initiative: archer d20=10+0=10",
|
||||||
|
"initiative: target d20=9+0=9",
|
||||||
|
"round 1 archer: move (0,1)->(1,2)",
|
||||||
|
"round 1 target: wait",
|
||||||
|
"round 2 archer: short bow vs target d20=11+4=15 AC 13 -> HIT 3 damage (6->3)",
|
||||||
|
"round 2 target: wait",
|
||||||
|
"round 3 archer: short bow vs target d20=5+4=9 AC 13 -> MISS",
|
||||||
|
"round 3 target: wait",
|
||||||
|
"round 4 archer: short bow vs target d20=14+4=18 AC 13 -> HIT 2 damage (3->1)",
|
||||||
|
"round 4 target: wait",
|
||||||
|
"battle over: draw after 4 rounds",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_ranged_no_line_of_effect_unreachable_target_waits() -> None:
|
||||||
|
"""A target on an island (no LoE, no path) leaves the archer waiting."""
|
||||||
|
legend = {
|
||||||
|
".": TerrainType(type="floor", move_cost=1),
|
||||||
|
"#": TerrainType(type="wall", move_cost=None, blocks_los=True),
|
||||||
|
}
|
||||||
|
spec = MapSpec(name="walled-off", terrain=("..#..", "..#.."), legend=legend)
|
||||||
|
grid = Grid.from_spec(spec)
|
||||||
|
archer = make_combatant("archer", attack_bonus=4, kind="ranged", range_increment_ft=60)
|
||||||
|
target = make_combatant("target", speed=0)
|
||||||
|
states = [make_state(archer, "players", (0, 1)), make_state(target, "monsters", (0, 4))]
|
||||||
|
engine = CombatEngine(ScriptedRng([10, 9]), grid, states, round_cap=1)
|
||||||
|
result = engine.run()
|
||||||
|
assert result.transcript[2] == "round 1 archer: wait"
|
||||||
|
|||||||
Reference in New Issue
Block a user