feat(combat): wire corner-rule LoS/cover into attack resolution
- weapon_for: gate attacks on has_line_of_effect (no LoE -> policy moves) - resolve_attack: +4 AC cover bonus on hit and crit-confirm (ranged flag) - tests: cover bonus (ranged pillar, melee wall corner), no-LoE move/attack, no-LoE unreachable wait; 11 resolve_attack call sites updated - README: quick-start and verified example re-measured (55.7% 1x2), rules and architecture updated (los.py wired)
This commit is contained in:
+97
-14
@@ -6,6 +6,8 @@ end to end: dice order, log lines, winner, and per-combatant stats.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from pf1e_simulator.combat import (
|
||||
CombatantState,
|
||||
CombatantStats,
|
||||
@@ -40,18 +42,21 @@ def make_combatant(
|
||||
initiative_mod: int = 0,
|
||||
con: int = 12,
|
||||
speed: int = 30,
|
||||
weapon_name: str = "short sword",
|
||||
weapon_name: str | None = None,
|
||||
dr: DamageReduction | None = None,
|
||||
kind: Literal["melee", "ranged", "touch"] = "melee",
|
||||
range_increment_ft: int | None = None,
|
||||
) -> Combatant:
|
||||
attack = AttackSpec(
|
||||
id=f"{cid}-w",
|
||||
name=weapon_name,
|
||||
kind="melee",
|
||||
name=weapon_name or ("short bow" if kind == "ranged" else "short sword"),
|
||||
kind=kind,
|
||||
attack_bonus=attack_bonus,
|
||||
damage=[DamageComponent(formula=parse_dice(damage), types=["slashing"])],
|
||||
damage_bonus=damage_bonus,
|
||||
crit_range=crit_range,
|
||||
crit_mult=crit_mult,
|
||||
range_increment_ft=range_increment_ft,
|
||||
)
|
||||
return Combatant(
|
||||
id=cid,
|
||||
@@ -99,7 +104,7 @@ def test_attack_hit_deals_damage() -> None:
|
||||
a = make_state(a_spec, "players", (1, 1))
|
||||
b = make_state(b_spec, "monsters", (1, 2))
|
||||
engine = make_engine([12, 3], [a, b])
|
||||
result = engine.resolve_attack(b, a_spec.attacks[0])
|
||||
result = engine.resolve_attack(a, b, a_spec.attacks[0])
|
||||
assert result.hit is True
|
||||
assert result.crit is False
|
||||
assert result.damage == 3
|
||||
@@ -112,7 +117,7 @@ def test_attack_miss_leaves_target_untouched() -> None:
|
||||
a = make_state(a_spec, "players", (1, 1))
|
||||
b = make_state(b_spec, "monsters", (1, 2))
|
||||
engine = make_engine([9], [a, b])
|
||||
result = engine.resolve_attack(b, a_spec.attacks[0])
|
||||
result = engine.resolve_attack(a, b, a_spec.attacks[0])
|
||||
assert result.hit is False
|
||||
assert result.damage == 0
|
||||
assert b.hp == 6
|
||||
@@ -124,7 +129,7 @@ def test_natural_1_always_misses() -> None:
|
||||
a = make_state(a_spec, "players", (1, 1))
|
||||
b = make_state(b_spec, "monsters", (1, 2))
|
||||
engine = make_engine([1], [a, b])
|
||||
result = engine.resolve_attack(b, a_spec.attacks[0])
|
||||
result = engine.resolve_attack(a, b, a_spec.attacks[0])
|
||||
assert result.hit is False
|
||||
assert b.hp == 6
|
||||
|
||||
@@ -135,7 +140,7 @@ def test_natural_20_threatens_and_crits() -> None:
|
||||
a = make_state(a_spec, "players", (1, 1))
|
||||
b = make_state(b_spec, "monsters", (1, 2))
|
||||
engine = make_engine([20, 12, 4], [a, b])
|
||||
result = engine.resolve_attack(b, a_spec.attacks[0])
|
||||
result = engine.resolve_attack(a, b, a_spec.attacks[0])
|
||||
assert result.crit is True
|
||||
assert result.damage == 8
|
||||
assert b.hp == -2
|
||||
@@ -147,7 +152,7 @@ def test_crit_range_19_threatens_on_19() -> None:
|
||||
a = make_state(a_spec, "players", (1, 1))
|
||||
b = make_state(b_spec, "monsters", (1, 2))
|
||||
engine = make_engine([19, 11, 2], [a, b])
|
||||
result = engine.resolve_attack(b, a_spec.attacks[0])
|
||||
result = engine.resolve_attack(a, b, a_spec.attacks[0])
|
||||
assert result.crit is True
|
||||
assert result.damage == 4
|
||||
assert b.hp == 2
|
||||
@@ -159,7 +164,7 @@ def test_confirm_fail_is_normal_hit() -> None:
|
||||
a = make_state(a_spec, "players", (1, 1))
|
||||
b = make_state(b_spec, "monsters", (1, 2))
|
||||
engine = make_engine([19, 8, 2], [a, b])
|
||||
result = engine.resolve_attack(b, a_spec.attacks[0])
|
||||
result = engine.resolve_attack(a, b, a_spec.attacks[0])
|
||||
assert result.hit is True
|
||||
assert result.crit is False
|
||||
assert result.damage == 2
|
||||
@@ -173,7 +178,7 @@ def test_no_threat_outside_crit_range() -> None:
|
||||
b = make_state(b_spec, "monsters", (1, 2))
|
||||
# Only 2 rolls consumed: no confirm roll happens outside the threat range.
|
||||
engine = make_engine([18, 3], [a, b])
|
||||
result = engine.resolve_attack(b, a_spec.attacks[0])
|
||||
result = engine.resolve_attack(a, b, a_spec.attacks[0])
|
||||
assert result.crit is False
|
||||
assert result.damage == 3
|
||||
assert b.hp == 3
|
||||
@@ -185,7 +190,7 @@ def test_dr_reduces_damage() -> None:
|
||||
a = make_state(a_spec, "players", (1, 1))
|
||||
b = make_state(b_spec, "monsters", (1, 2))
|
||||
engine = make_engine([12, 7], [a, b])
|
||||
result = engine.resolve_attack(b, a_spec.attacks[0])
|
||||
result = engine.resolve_attack(a, b, a_spec.attacks[0])
|
||||
assert result.damage == 2
|
||||
assert b.hp == 4
|
||||
|
||||
@@ -196,7 +201,7 @@ def test_dr_floors_damage_at_zero() -> None:
|
||||
a = make_state(a_spec, "players", (1, 1))
|
||||
b = make_state(b_spec, "monsters", (1, 2))
|
||||
engine = make_engine([12, 3], [a, b])
|
||||
result = engine.resolve_attack(b, a_spec.attacks[0])
|
||||
result = engine.resolve_attack(a, b, a_spec.attacks[0])
|
||||
assert result.damage == 0
|
||||
assert b.hp == 6
|
||||
|
||||
@@ -207,7 +212,7 @@ def test_dr_bypass_ignores_reduction() -> None:
|
||||
a = make_state(a_spec, "players", (1, 1))
|
||||
b = make_state(b_spec, "monsters", (1, 2))
|
||||
engine = make_engine([12, 7], [a, b])
|
||||
result = engine.resolve_attack(b, a_spec.attacks[0])
|
||||
result = engine.resolve_attack(a, b, a_spec.attacks[0])
|
||||
assert result.damage == 7
|
||||
assert b.hp == -1
|
||||
|
||||
@@ -218,7 +223,7 @@ def test_dr_applies_after_crit_multiplier() -> None:
|
||||
a = make_state(a_spec, "players", (1, 1))
|
||||
b = make_state(b_spec, "monsters", (1, 2))
|
||||
engine = make_engine([20, 11, 6], [a, b])
|
||||
result = engine.resolve_attack(b, a_spec.attacks[0])
|
||||
result = engine.resolve_attack(a, b, a_spec.attacks[0])
|
||||
assert result.crit is True
|
||||
assert result.damage == 7
|
||||
assert b.hp == -1
|
||||
@@ -415,3 +420,81 @@ def test_melee_routes_around_wall() -> None:
|
||||
result = engine.run()
|
||||
assert result.winner == "players"
|
||||
assert result.stats["mover"].hits > 0
|
||||
|
||||
|
||||
def test_ranged_attack_gets_cover_bonus() -> None:
|
||||
"""A pillar between shooter and target grants partial cover (+4 AC)."""
|
||||
legend = {
|
||||
".": TerrainType(type="floor", move_cost=1),
|
||||
"C": TerrainType(type="pillar", move_cost=None, cover=True),
|
||||
}
|
||||
spec = MapSpec(name="cover-test", terrain=(".C...",), legend=legend)
|
||||
grid = Grid.from_spec(spec)
|
||||
archer = make_combatant("archer", attack_bonus=4, kind="ranged", range_increment_ft=60)
|
||||
target = make_combatant("target", ac=13)
|
||||
states = [make_state(archer, "players", (0, 0)), make_state(target, "monsters", (0, 3))]
|
||||
engine = CombatEngine(ScriptedRng([12, 3]), grid, states)
|
||||
result = engine.resolve_attack(states[0], states[1], archer.attacks[0])
|
||||
assert result.ac == 17 # 13 + 4 cover bonus
|
||||
assert result.hit is False # 12 + 4 = 16 < 17
|
||||
|
||||
|
||||
def test_melee_attack_gets_cover_bonus_across_wall_corner() -> None:
|
||||
"""Diagonal melee across a wall corner grants cover (+4 AC)."""
|
||||
legend = {
|
||||
".": TerrainType(type="floor", move_cost=1),
|
||||
"#": TerrainType(type="wall", move_cost=None, blocks_los=True),
|
||||
}
|
||||
spec = MapSpec(name="corner-test", terrain=(".#.", "#.."), legend=legend)
|
||||
grid = Grid.from_spec(spec)
|
||||
attacker = make_state(make_combatant("a", attack_bonus=2), "players", (0, 0))
|
||||
target = make_state(make_combatant("b", ac=13), "monsters", (1, 1))
|
||||
engine = CombatEngine(ScriptedRng([12, 3]), grid, [attacker, target])
|
||||
result = engine.resolve_attack(attacker, target, attacker.combatant.attacks[0])
|
||||
assert result.ac == 17
|
||||
assert result.hit is False
|
||||
|
||||
|
||||
def test_ranged_no_line_of_effect_moves_around_wall() -> None:
|
||||
"""An archer without line of effect cannot shoot; the policy moves until it gains sight."""
|
||||
legend = {
|
||||
".": TerrainType(type="floor", move_cost=1),
|
||||
"#": TerrainType(type="wall", move_cost=None, blocks_los=True),
|
||||
}
|
||||
spec = MapSpec(name="wall-block", terrain=("..#..", "....."), legend=legend)
|
||||
grid = Grid.from_spec(spec)
|
||||
archer = make_combatant("archer", attack_bonus=4, kind="ranged", range_increment_ft=60)
|
||||
target = make_combatant("target", speed=0)
|
||||
states = [make_state(archer, "players", (0, 1)), make_state(target, "monsters", (0, 4))]
|
||||
engine = CombatEngine(ScriptedRng([10, 9, 11, 3, 5, 14, 2, 8]), grid, states, round_cap=4)
|
||||
result = engine.run()
|
||||
assert result.winner is None
|
||||
assert result.transcript == (
|
||||
"initiative: archer d20=10+0=10",
|
||||
"initiative: target d20=9+0=9",
|
||||
"round 1 archer: move (0,1)->(1,2)",
|
||||
"round 1 target: wait",
|
||||
"round 2 archer: short bow vs target d20=11+4=15 AC 13 -> HIT 3 damage (6->3)",
|
||||
"round 2 target: wait",
|
||||
"round 3 archer: short bow vs target d20=5+4=9 AC 13 -> MISS",
|
||||
"round 3 target: wait",
|
||||
"round 4 archer: short bow vs target d20=14+4=18 AC 13 -> HIT 2 damage (3->1)",
|
||||
"round 4 target: wait",
|
||||
"battle over: draw after 4 rounds",
|
||||
)
|
||||
|
||||
|
||||
def test_ranged_no_line_of_effect_unreachable_target_waits() -> None:
|
||||
"""A target on an island (no LoE, no path) leaves the archer waiting."""
|
||||
legend = {
|
||||
".": TerrainType(type="floor", move_cost=1),
|
||||
"#": TerrainType(type="wall", move_cost=None, blocks_los=True),
|
||||
}
|
||||
spec = MapSpec(name="walled-off", terrain=("..#..", "..#.."), legend=legend)
|
||||
grid = Grid.from_spec(spec)
|
||||
archer = make_combatant("archer", attack_bonus=4, kind="ranged", range_increment_ft=60)
|
||||
target = make_combatant("target", speed=0)
|
||||
states = [make_state(archer, "players", (0, 1)), make_state(target, "monsters", (0, 4))]
|
||||
engine = CombatEngine(ScriptedRng([10, 9]), grid, states, round_cap=1)
|
||||
result = engine.run()
|
||||
assert result.transcript[2] == "round 1 archer: wait"
|
||||
|
||||
Reference in New Issue
Block a user