From f67cd017e4f92435789c37992a6f7dc0885ffd57 Mon Sep 17 00:00:00 2001 From: Thien An Date: Mon, 17 Aug 2026 22:49:50 +0200 Subject: [PATCH] feat(combat): apply cumulative -2 range penalties per increment --- README.md | 34 +++++++------ src/pf1e_simulator/combat.py | 37 +++++++++++--- tests/test_combat.py | 95 ++++++++++++++++++++++++++++++++++++ 3 files changed, 145 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 83422dd..07dc60d 100644 --- a/README.md +++ b/README.md @@ -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 ``` diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index cd2f0d4..28dcd19 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -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: diff --git a/tests/test_combat.py b/tests/test_combat.py index 80d3314..d16e75e 100644 --- a/tests/test_combat.py +++ b/tests/test_combat.py @@ -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)"