feat(combat): grant soft cover (+4 AC) to ranged attacks through creatures

This commit is contained in:
2026-08-17 22:49:50 +02:00
parent f67cd017e4
commit bb53d049ee
5 changed files with 129 additions and 19 deletions
+55
View File
@@ -664,3 +664,58 @@ def test_ranged_weapon_unusable_beyond_maximum_range() -> None:
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)"
def test_ranged_soft_cover_from_creature_between() -> None:
"""A creature between shooter and target grants soft cover (+4 AC)."""
archer = make_combatant("archer", attack_bonus=4, kind="ranged", range_increment_ft=60)
blocker = make_combatant("blocker", speed=0)
target = make_combatant("target", ac=13)
states = [
make_state(archer, "players", (1, 1)),
make_state(blocker, "players", (1, 2)),
make_state(target, "monsters", (1, 4)),
]
engine = CombatEngine(ScriptedRng([12, 3]), make_grid(), states)
result = engine.resolve_attack(states[0], states[2], archer.attacks[0])
assert result.ac == 17 # 13 + 4 soft cover
assert result.hit is False # 12 + 4 = 16 < 17
def test_melee_soft_cover_ignored_in_resolution() -> None:
"""Melee attacks ignore creatures for soft cover (CRB: ranged only)."""
attacker = make_combatant("attacker", attack_bonus=2)
blocker = make_combatant("blocker", speed=0)
target = make_combatant("target", ac=13)
states = [
make_state(attacker, "players", (1, 1)),
make_state(blocker, "players", (1, 2)),
make_state(target, "monsters", (1, 4)),
]
engine = CombatEngine(ScriptedRng([12, 3]), make_grid(), states)
result = engine.resolve_attack(states[0], states[2], attacker.attacks[0])
assert result.ac == 13 # melee ignores soft cover
assert result.hit is True # 12 + 2 = 14 >= 13
def test_ranged_soft_cover_shown_in_transcript() -> None:
"""Soft cover raises the AC displayed in the attack log line."""
archer = make_combatant("archer", attack_bonus=4, kind="ranged", range_increment_ft=60)
blocker = make_combatant("blocker", speed=0)
target = make_combatant("target", speed=0)
states = [
make_state(archer, "players", (0, 0)),
make_state(blocker, "players", (0, 2)),
make_state(target, "monsters", (0, 4)),
]
engine = CombatEngine(ScriptedRng([10, 9, 8, 12]), make_grid(), states, round_cap=1)
result = engine.run()
assert result.transcript == (
"initiative: archer d20=10+0=10",
"initiative: blocker d20=9+0=9",
"initiative: target d20=8+0=8",
"round 1 archer: short bow vs target d20=12+4=16 AC 17 -> MISS",
"round 1 blocker: wait",
"round 1 target: wait",
"battle over: draw after 1 rounds",
)
+28
View File
@@ -88,3 +88,31 @@ def test_cover_melee_uses_same_corner_rule_in_phase0() -> None:
def test_cover_same_square_is_false() -> None:
grid = make_grid(["."])
assert has_cover(grid, (0, 0), (0, 0), ranged=True) is False
def test_soft_cover_creature_between_grants_cover() -> None:
"""A creature between shooter and target grants cover on ranged attacks."""
grid = make_grid([".....", ".....", "....."])
occupied = frozenset({(1, 2)})
assert has_cover(grid, (1, 0), (1, 4), ranged=True, occupied=occupied) is True
def test_soft_cover_melee_ignores_creatures() -> None:
"""Melee attacks ignore creatures for soft cover (CRB: ranged only)."""
grid = make_grid([".....", ".....", "....."])
occupied = frozenset({(1, 2)})
assert has_cover(grid, (1, 0), (1, 4), ranged=False, occupied=occupied) is False
def test_soft_cover_creature_behind_target_ignored() -> None:
"""A creature behind the target does not grant cover."""
grid = make_grid(["......", "......"])
occupied = frozenset({(1, 5)})
assert has_cover(grid, (1, 0), (1, 4), ranged=True, occupied=occupied) is False
def test_soft_cover_creature_off_axis_ignored() -> None:
"""A creature off the attack line does not grant cover."""
grid = make_grid([".....", "....."])
occupied = frozenset({(0, 2)})
assert has_cover(grid, (1, 0), (1, 4), ranged=True, occupied=occupied) is False