feat(combat): apply cumulative -2 range penalties per increment
This commit is contained in:
@@ -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