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
+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)"