feat(combat): apply cumulative -2 range penalties per increment
This commit is contained in:
@@ -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
|
||||
```
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)"
|
||||
|
||||
Reference in New Issue
Block a user