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:
2026-08-17 22:49:50 +02:00
parent a21e234b41
commit e17c571d7e
3 changed files with 436 additions and 18 deletions
+97 -14
View File
@@ -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"