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 monsters : 2 combatants
1000 combats simulés (seed 1, plafond 100 rounds) 1000 combats simulés (seed 1, plafond 100 rounds)
Victoires players : 100.0% (1000) — bande 3σ : [100.0%, 100.0%] Victoires players : 98.1% (981) — bande 3σ : [96.8%, 99.4%]
Victoires monsters : 0.0% (0) — bande 3σ : [0.0%, 0.0%] Victoires monsters : 1.9% (19) — bande 3σ : [0.6%, 3.2%]
Nuls : 0.0% (0) Nuls : 0.0% (0)
Rounds moyens : 5.8 Rounds moyens : 6.6
``` ```
## Utilisation de la CLI ## Utilisation de la CLI
@@ -130,9 +130,11 @@ confiance à 3σ calculée en forme fermée (modèle binomial) :
immobiles, camps inaccessibles…). immobiles, camps inaccessibles…).
Exemple vérifié — 1 gobelin (players) contre 2 orcs (monsters), 1000 runs, 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 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 ## 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 Champ `range_increment_ft` (optionnel, uniquement pour `kind: ranged`) :
maximale de tir — au-delà, l'arme n'est pas utilisable. `damage_bonus` et `dr` incrément de portée en pieds. La pénalité de portée vaut 2 par incrément
sont optionnels. Deux monstres d'exemple sont fournis : gobelin et orc. 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) ### 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 - 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 confirmation de critique ; le couvert octroyé par les créatures n'est pas
modélisé. modélisé.
- Distance : l'arme doit être à portée (range_increment_ft) ; aucune pénalité - Distance : pénalité cumulative de 2 par incrément de portée complet au-delà
de portée au-delà du premier incrément. 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 Non modélisé en Phase 0 (couches `elevation`/`markers` présentes mais non
appliquées dans la résolution) : appliquées dans la résolution) :
- Sorts, jets de sauvegarde, conditions et états. - Sorts, jets de sauvegarde, conditions et états.
- Attaques d'opportunité, flanquement, manœuvres de combat. - 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. - Effets mécaniques de hauteur/élévation.
- Tailles Large+ (2×2), allonge > 5 ft, itératifs de BAB, économie d'action - Tailles Large+ (2×2), allonge > 5 ft, itératifs de BAB, économie d'action
complète (charge, pas de placement…). 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 - `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`. d'effet dans `weapon_for`, bonus de couvert +4 CA dans `resolve_attack`.
- `combat.py``CombatEngine` déterministe : initiative, actions, résolution - `combat.py``CombatEngine` déterministe : initiative, actions, résolution
des attaques (couvert, ligne d'effet), états de vie, transcripts, politique des attaques (couvert, ligne d'effet, pénalités de portée), états de vie,
par défaut. transcripts, politique par défaut.
- `metrics.py` — statistiques en forme fermée : `win_rate`, `win_rate_sigma`, - `metrics.py` — statistiques en forme fermée : `win_rate`, `win_rate_sigma`,
`win_rate_band` (bande 3σ bornée à [0, 1]). `win_rate_band` (bande 3σ bornée à [0, 1]).
- `runner.py``EncounterSpec`/`Side`, `build_states` (placement en zone + - `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) : La gate de validation complète (tests + lint + types) :
```bash ```bash
uv run pytest -q # 182 tests uv run pytest -q # 189 tests
uv run ruff check src tests uv run ruff check src tests
uv run basedpyright src # mode strict 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. 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 - Cover (corner rule) grants +4 AC on hit and crit-confirm rolls; melee and
ranged reuse the same corner rule. ranged reuse the same corner rule.
- Ranged: only the first range increment is enforced in Phase 0 (no distance - Ranged: cumulative -2 per full range increment beyond the first, up to 10
penalty, no soft cover from creatures). 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 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_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 _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, ...] = ( _STEP_DELTAS: tuple[Pos, ...] = (
(-1, -1), (-1, -1),
@@ -96,6 +100,7 @@ class AttackResult:
roll: int roll: int
total: int total: int
ac: int ac: int
penalty: int = 0
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -193,17 +198,28 @@ class CombatEngine:
if ( if (
weapon.kind == "ranged" weapon.kind == "ranged"
and weapon.range_increment_ft is not None 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 weapon
return None 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( def resolve_attack(
self, attacker: CombatantState, 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 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 ac = target.combatant.ac.total
if has_cover(self._grid, attacker.pos, target.pos, ranged=weapon.kind == "ranged"): if has_cover(self._grid, attacker.pos, target.pos, ranged=weapon.kind == "ranged"):
ac += _COVER_AC_BONUS ac += _COVER_AC_BONUS
@@ -213,13 +229,15 @@ class CombatEngine:
if hit: if hit:
if roll != _NATURAL_ONE and roll >= weapon.crit_range: if roll != _NATURAL_ONE and roll >= weapon.crit_range:
confirm = self._rng.d20() 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 damage = sum(c.formula.roll(self._rng) for c in weapon.damage) + weapon.damage_bonus
if crit: if crit:
damage *= weapon.crit_mult damage *= weapon.crit_mult
damage = self._apply_dr(damage, weapon, target) damage = self._apply_dr(damage, weapon, target)
target.hp -= damage 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: def _apply_dr(self, damage: int, weapon: AttackSpec, target: CombatantState) -> int:
dr: DamageReduction | None = target.combatant.dr dr: DamageReduction | None = target.combatant.dr
@@ -291,9 +309,14 @@ class CombatEngine:
target_live.damage_taken += result.damage target_live.damage_taken += result.damage
outcome = "CRIT" if result.crit else "HIT" if result.hit else "MISS" outcome = "CRIT" if result.crit else "HIT" if result.hit else "MISS"
label = weapon.name if weapon.count == 1 else f"{weapon.name} #{swing}" 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 = ( line = (
f"round {round_no} {attacker.combatant.id}: {label} vs {target.combatant.id} " 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}" f"AC {result.ac} -> {outcome}"
) )
if result.hit: 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) engine = CombatEngine(ScriptedRng([10, 9]), grid, states, round_cap=1)
result = engine.run() result = engine.run()
assert result.transcript[2] == "round 1 archer: wait" 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)"