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:
2026-08-17 22:49:50 +02:00
parent a21e234b41
commit e17c571d7e
3 changed files with 436 additions and 18 deletions
+323
View File
@@ -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(1p)/n)), nuls, rounds moyens, attrition moyenne par combattant
(touches, critiques, dégâts infligés/subis).
- Interface en ligne de commande `pf1e-sim`.
## Prérequis et installation
Prérequis :
- Python ≥ 3.12
- [uv](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(1p)/n) ; la bande est [p 3σ, p + 3σ], bornée à [0, 1].
- Un affrontement symétrique (combattants identiques sur des zones en miroir)
doit rester proche de 50 % ; un écart hors de la bande 3σ signale un
déséquilibre (avantage d'initiative, géométrie, portées…).
- Une bande large indique un échantillon trop petit pour conclure — augmentez
`--runs`.
- L'attrition moyenne par combattant indique qui participe réellement : un
combattant à distance qui n'est jamais rejoint affiche des dégâts subis
proches de zéro, un combattant qui meurt systématiquement affiche des dégâts
subis supérieurs à ses PV maximum (dégâts du coup fatal inclus).
- Les nuls proviennent des combats atteignant le plafond de rounds (combattants
immobiles, camps inaccessibles…).
Exemple vérifié — 1 gobelin (players) contre 2 orcs (monsters), 1000 runs,
seed 1 : players 55,7 %, bande 3σ [51,0 % ; 60,4 %], 0 nuls, 13,1 rounds moyens.
Sans ligne de visée depuis la zone de départ, l'archer gobelin doit contourner
le mur central avant de tirer — c'est ce qui coûte des rounds.
## Formats de données
### Carte (YAML)
Chaque couche est un bloc ASCII de mêmes dimensions ; un caractère = une case
de `square_size_ft` pieds. Seule `terrain` est obligatoire.
```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.
+16 -4
View File
@@ -9,7 +9,12 @@ Phase 0 documented deviations from PF1e (conventions):
- Initiative ties: higher initiative_mod first, then list order (no re-roll).
- 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.
- 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
@@ -26,10 +31,13 @@ if TYPE_CHECKING:
from pf1e_simulator.models import AttackSpec, Combatant, DamageReduction
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
_DEATH_FLOOR = -10 # PF1e: dead when hp < -10 or -CON, whichever is lower
_NATURAL_ONE = 1 # PF1e: natural 1 always misses
_NATURAL_TWENTY = 20 # PF1e: natural 20 always hits and threatens
_COVER_AC_BONUS = 4 # PF1e: partial cover grants +4 AC
_STEP_DELTAS: tuple[Pos, ...] = (
(-1, -1),
@@ -172,7 +180,9 @@ class CombatEngine:
)
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
for weapon in attacker.combatant.attacks:
if weapon.kind in ("melee", "touch") and dist_ft <= weapon.reach_ft:
@@ -186,12 +196,14 @@ class CombatEngine:
return None
def resolve_attack(
self, target: CombatantState, weapon: AttackSpec
self, attacker: CombatantState, target: CombatantState, weapon: AttackSpec
) -> AttackResult:
"""Roll one attack (with crit confirm and damage) and apply it."""
roll = self._rng.d20()
total = roll + weapon.attack_bonus
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)
crit = False
damage = 0
@@ -266,7 +278,7 @@ class CombatEngine:
self, attacker: CombatantState, target: CombatantState, weapon: AttackSpec, round_no: int
) -> None:
hp_before = target.hp
result = self.resolve_attack(target, weapon)
result = self.resolve_attack(attacker, target, weapon)
live = self._stats[attacker.combatant.id]
live.hits += int(result.hit)
live.crits += int(result.crit)
+97 -14
View File
@@ -6,6 +6,8 @@ end to end: dice order, log lines, winner, and per-combatant stats.
from __future__ import annotations
from typing import Literal
from pf1e_simulator.combat import (
CombatantState,
CombatantStats,
@@ -40,18 +42,21 @@ def make_combatant(
initiative_mod: int = 0,
con: int = 12,
speed: int = 30,
weapon_name: str = "short sword",
weapon_name: str | None = None,
dr: DamageReduction | None = None,
kind: Literal["melee", "ranged", "touch"] = "melee",
range_increment_ft: int | None = None,
) -> Combatant:
attack = AttackSpec(
id=f"{cid}-w",
name=weapon_name,
kind="melee",
name=weapon_name or ("short bow" if kind == "ranged" else "short sword"),
kind=kind,
attack_bonus=attack_bonus,
damage=[DamageComponent(formula=parse_dice(damage), types=["slashing"])],
damage_bonus=damage_bonus,
crit_range=crit_range,
crit_mult=crit_mult,
range_increment_ft=range_increment_ft,
)
return Combatant(
id=cid,
@@ -99,7 +104,7 @@ def test_attack_hit_deals_damage() -> None:
a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (1, 2))
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.crit is False
assert result.damage == 3
@@ -112,7 +117,7 @@ def test_attack_miss_leaves_target_untouched() -> None:
a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (1, 2))
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.damage == 0
assert b.hp == 6
@@ -124,7 +129,7 @@ def test_natural_1_always_misses() -> None:
a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (1, 2))
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 b.hp == 6
@@ -135,7 +140,7 @@ def test_natural_20_threatens_and_crits() -> None:
a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (1, 2))
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.damage == 8
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))
b = make_state(b_spec, "monsters", (1, 2))
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.damage == 4
assert b.hp == 2
@@ -159,7 +164,7 @@ def test_confirm_fail_is_normal_hit() -> None:
a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (1, 2))
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.crit is False
assert result.damage == 2
@@ -173,7 +178,7 @@ def test_no_threat_outside_crit_range() -> None:
b = make_state(b_spec, "monsters", (1, 2))
# Only 2 rolls consumed: no confirm roll happens outside the threat range.
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.damage == 3
assert b.hp == 3
@@ -185,7 +190,7 @@ def test_dr_reduces_damage() -> None:
a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (1, 2))
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 b.hp == 4
@@ -196,7 +201,7 @@ def test_dr_floors_damage_at_zero() -> None:
a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (1, 2))
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 b.hp == 6
@@ -207,7 +212,7 @@ def test_dr_bypass_ignores_reduction() -> None:
a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (1, 2))
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 b.hp == -1
@@ -218,7 +223,7 @@ def test_dr_applies_after_crit_multiplier() -> None:
a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (1, 2))
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.damage == 7
assert b.hp == -1
@@ -415,3 +420,81 @@ def test_melee_routes_around_wall() -> None:
result = engine.run()
assert result.winner == "players"
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"