feat(combat): apply cumulative -2 range penalties per increment

This commit is contained in:
2026-08-17 22:49:50 +02:00
parent b748980423
commit f67cd017e4
3 changed files with 145 additions and 21 deletions
+20 -14
View File
@@ -72,10 +72,10 @@ 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%]
Victoires players : 98.1% (981) — bande 3σ : [96.8%, 99.4%]
Victoires monsters : 1.9% (19) — bande 3σ : [0.6%, 3.2%]
Nuls : 0.0% (0)
Rounds moyens : 5.8
Rounds moyens : 6.6
```
## Utilisation de la CLI
@@ -130,9 +130,11 @@ confiance à 3σ calculée en forme fermée (modèle binomial) :
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.
seed 1 : players 49,6 %, bande 3σ [44,9 % ; 54,3 %], 0 nuls, 11,4 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.
le mur central avant de tirer ; une fois la ligne de visée gagnée, il tire de
loin avec la pénalité de portée au lieu de s'approcher — c'est ce qui équilibre
le face-à-face à 1 contre 2.
## Formats de données
@@ -228,9 +230,11 @@ ligne d'effet ⇒ l'attaque est impossible ; couvert ⇒ bonus de +4 CA.
}
```
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.
Champ `range_increment_ft` (optionnel, uniquement pour `kind: ranged`) :
incrément de portée en pieds. La pénalité de portée vaut 2 par incrément
complet au-delà du premier ; l'arme reste utilisable jusqu'à 10 incréments,
au-delà elle n'est pas sélectionnable. `damage_bonus` et `dr` sont optionnels.
Deux monstres d'exemple sont fournis : gobelin et orc.
### Fiches de personnages (PJ)
@@ -274,15 +278,17 @@ Règles modélisées :
- 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.
- Distance : pénalité cumulative de 2 par incrément de portée complet au-delà
du premier, appliquée au jet d'attaque et à la confirmation de critique ;
l'arme ranged reste utilisable jusqu'à 10 incréments. Les armes de jet
(5 incréments max) ne sont pas distinguées des armes à projectiles.
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.
- Couvert mou des créatures.
- Effets mécaniques de hauteur/élévation.
- Tailles Large+ (2×2), allonge > 5 ft, itératifs de BAB, économie d'action
complète (charge, pas de placement…).
@@ -301,8 +307,8 @@ appliquées dans la résolution) :
- `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.
des attaques (couvert, ligne d'effet, pénalités de portée), é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 +
@@ -318,7 +324,7 @@ appliquées dans la résolution) :
La gate de validation complète (tests + lint + types) :
```bash
uv run pytest -q # 182 tests
uv run pytest -q # 189 tests
uv run ruff check src tests
uv run basedpyright src # mode strict
```
+30 -7
View File
@@ -16,8 +16,10 @@ Phase 0 documented deviations from PF1e (conventions):
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).
- Ranged: cumulative -2 per full range increment beyond the first, up to 10
range increments; the penalty applies to attack and crit-confirm rolls.
Thrown weapons (5 increments max) are not distinguished; soft cover from
creatures is not modeled.
"""
from __future__ import annotations
@@ -41,6 +43,8 @@ _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
_RANGE_INCREMENT_PENALTY = 2 # PF1e: -2 per full range increment beyond the first
_MAX_RANGE_INCREMENTS = 10 # PF1e: projectile weapons shoot up to 10 increments
_STEP_DELTAS: tuple[Pos, ...] = (
(-1, -1),
@@ -96,6 +100,7 @@ class AttackResult:
roll: int
total: int
ac: int
penalty: int = 0
@dataclass(frozen=True)
@@ -193,17 +198,28 @@ class CombatEngine:
if (
weapon.kind == "ranged"
and weapon.range_increment_ft is not None
and dist_ft <= weapon.range_increment_ft
and dist_ft <= weapon.range_increment_ft * _MAX_RANGE_INCREMENTS
):
return weapon
return None
def range_penalty(self, weapon: AttackSpec, dist_ft: int) -> int:
"""PF1e: -2 per full range increment beyond the first, zero within it."""
if weapon.kind != "ranged" or weapon.range_increment_ft is None:
return 0
increments = dist_ft // weapon.range_increment_ft
if dist_ft % weapon.range_increment_ft != 0:
increments += 1
return -_RANGE_INCREMENT_PENALTY * max(0, increments - 1)
def resolve_attack(
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
dist_ft = self._grid.distance(attacker.pos, target.pos) * _SQUARE_FT
penalty = self.range_penalty(weapon, dist_ft)
total = roll + weapon.attack_bonus + penalty
ac = target.combatant.ac.total
if has_cover(self._grid, attacker.pos, target.pos, ranged=weapon.kind == "ranged"):
ac += _COVER_AC_BONUS
@@ -213,13 +229,15 @@ class CombatEngine:
if hit:
if roll != _NATURAL_ONE and roll >= weapon.crit_range:
confirm = self._rng.d20()
crit = confirm != _NATURAL_ONE and confirm + weapon.attack_bonus >= ac
crit = confirm != _NATURAL_ONE and confirm + weapon.attack_bonus + penalty >= ac
damage = sum(c.formula.roll(self._rng) for c in weapon.damage) + weapon.damage_bonus
if crit:
damage *= weapon.crit_mult
damage = self._apply_dr(damage, weapon, target)
target.hp -= damage
return AttackResult(hit=hit, crit=crit, damage=damage, roll=roll, total=total, ac=ac)
return AttackResult(
hit=hit, crit=crit, damage=damage, roll=roll, total=total, ac=ac, penalty=penalty
)
def _apply_dr(self, damage: int, weapon: AttackSpec, target: CombatantState) -> int:
dr: DamageReduction | None = target.combatant.dr
@@ -291,9 +309,14 @@ class CombatEngine:
target_live.damage_taken += result.damage
outcome = "CRIT" if result.crit else "HIT" if result.hit else "MISS"
label = weapon.name if weapon.count == 1 else f"{weapon.name} #{swing}"
bonus = (
f"{weapon.attack_bonus}{result.penalty:+d}"
if result.penalty
else f"{weapon.attack_bonus}"
)
line = (
f"round {round_no} {attacker.combatant.id}: {label} vs {target.combatant.id} "
f"d20={result.roll}+{weapon.attack_bonus}={result.total} "
f"d20={result.roll}+{bonus}={result.total} "
f"AC {result.ac} -> {outcome}"
)
if result.hit:
+95
View File
@@ -569,3 +569,98 @@ def test_ranged_no_line_of_effect_unreachable_target_waits() -> None:
engine = CombatEngine(ScriptedRng([10, 9]), grid, states, round_cap=1)
result = engine.run()
assert result.transcript[2] == "round 1 archer: wait"
def test_ranged_penalty_zero_within_first_increment() -> None:
"""Within the first range increment (5 ft of 10), no penalty applies."""
archer = make_combatant("archer", attack_bonus=4, kind="ranged", range_increment_ft=10)
target = make_combatant("target", ac=13)
states = [make_state(archer, "players", (1, 1)), make_state(target, "monsters", (1, 2))]
engine = CombatEngine(ScriptedRng([12, 3]), make_grid(), states)
result = engine.resolve_attack(states[0], states[1], archer.attacks[0])
assert result.penalty == 0
assert result.total == 16 # 12 + 4
assert result.hit is True
def test_ranged_penalty_beyond_first_increment() -> None:
"""At 15 ft (2nd full increment of 10 ft), the attack suffers -2."""
archer = make_combatant("archer", attack_bonus=4, kind="ranged", range_increment_ft=10)
target = make_combatant("target", ac=13)
states = [make_state(archer, "players", (1, 1)), make_state(target, "monsters", (1, 4))]
engine = CombatEngine(ScriptedRng([12, 3]), make_grid(), states)
result = engine.resolve_attack(states[0], states[1], archer.attacks[0])
assert result.penalty == -2
assert result.total == 14 # 12 + 4 - 2
assert result.hit is True
def test_ranged_penalty_scales_with_more_increments() -> None:
"""At 25 ft (3rd full increment of 10 ft), the penalty reaches -4."""
archer = make_combatant("archer", attack_bonus=4, kind="ranged", range_increment_ft=10)
target = make_combatant("target", ac=13)
states = [make_state(archer, "players", (1, 1)), make_state(target, "monsters", (1, 6))]
engine = CombatEngine(ScriptedRng([12, 3]), make_grid(), states)
result = engine.resolve_attack(states[0], states[1], archer.attacks[0])
assert result.penalty == -4
assert result.total == 12 # 12 + 4 - 4: now a MISS vs AC 13
assert result.hit is False
def test_ranged_penalty_shown_in_transcript() -> None:
"""The log line renders the penalty between bonus and total."""
archer = make_combatant("archer", attack_bonus=4, kind="ranged", range_increment_ft=10)
target = make_combatant("target", speed=0)
states = [make_state(archer, "players", (1, 1)), make_state(target, "monsters", (1, 4))]
engine = CombatEngine(ScriptedRng([10, 9, 12, 3]), make_grid(), states, round_cap=1)
result = engine.run()
assert result.transcript == (
"initiative: archer d20=10+0=10",
"initiative: target d20=9+0=9",
"round 1 archer: short bow vs target d20=12+4-2=14 AC 13 -> HIT 3 damage (6->3)",
"round 1 target: wait",
"battle over: draw after 1 rounds",
)
def test_ranged_penalty_applies_to_crit_confirm() -> None:
"""The -2 range penalty also applies to the crit confirmation roll."""
archer = make_combatant("archer", attack_bonus=4, kind="ranged", range_increment_ft=10)
target = make_combatant("target", ac=15)
states = [make_state(archer, "players", (1, 1)), make_state(target, "monsters", (1, 4))]
# Natural 20 threatens; confirm 12 would beat AC 15 without the penalty
# (12 + 4 = 16) but fails with it (12 + 4 - 2 = 14).
engine = CombatEngine(ScriptedRng([20, 12, 3]), make_grid(), states)
result = engine.resolve_attack(states[0], states[1], archer.attacks[0])
assert result.hit is True
assert result.crit is False
assert result.damage == 3 # not doubled
def test_ranged_weapon_usable_at_maximum_range() -> None:
"""At exactly 10 range increments the weapon is still usable, with -18."""
legend = {".": TerrainType(type="floor", move_cost=1)}
spec = MapSpec(name="range-max", terrain=("." * 24,), legend=legend)
grid = Grid.from_spec(spec)
archer = make_combatant("archer", attack_bonus=4, kind="ranged", range_increment_ft=10)
target = make_combatant("target", ac=13)
states = [make_state(archer, "players", (0, 0)), make_state(target, "monsters", (0, 20))]
engine = CombatEngine(ScriptedRng([20, 3, 3]), grid, states)
assert engine.weapon_for(states[0], states[1]) is not None
result = engine.resolve_attack(states[0], states[1], archer.attacks[0])
assert result.penalty == -18
assert result.hit is True # natural 20
def test_ranged_weapon_unusable_beyond_maximum_range() -> None:
"""Beyond 10 range increments the weapon is dropped and the policy moves."""
legend = {".": TerrainType(type="floor", move_cost=1)}
spec = MapSpec(name="range-beyond", terrain=("." * 24,), legend=legend)
grid = Grid.from_spec(spec)
archer = make_combatant("archer", attack_bonus=4, kind="ranged", range_increment_ft=10)
target = make_combatant("target", speed=0)
states = [make_state(archer, "players", (0, 0)), make_state(target, "monsters", (0, 21))]
engine = CombatEngine(ScriptedRng([10, 9]), grid, states, round_cap=1)
assert engine.weapon_for(states[0], states[1]) is None
result = engine.run()
assert result.transcript[2] == "round 1 archer: move (0,0)->(0,1)"