From e17c571d7eb82602cb9490a741b76592a722ec42 Mon Sep 17 00:00:00 2001 From: Thien An Date: Mon, 17 Aug 2026 22:49:50 +0200 Subject: [PATCH] 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) --- README.md | 323 +++++++++++++++++++++++++++++++++++ src/pf1e_simulator/combat.py | 20 ++- tests/test_combat.py | 111 ++++++++++-- 3 files changed, 436 insertions(+), 18 deletions(-) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..e76ce83 --- /dev/null +++ b/README.md @@ -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 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. diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index 4ab195d..b391540 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -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) diff --git a/tests/test_combat.py b/tests/test_combat.py index e68b25a..ee4cbef 100644 --- a/tests/test_combat.py +++ b/tests/test_combat.py @@ -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"