3c31b88b13
- default_policy returns (move, attack) when target out of reach: full-speed move along the Dijkstra path (up to speed cells, 5-10-5 diagonals, stops adjacent to nearest enemy), then attack if a weapon is usable; returns (attack,) when already in range. - _move logs the full path in one line (start)->(step1)->...->(end); silent on empty path. _execute is silent on invalid attacks/moves (no spurious wait log); run() adds a wait line only when a combatant's turn produced no transcript line. - Extract _take_turn helper from run() to keep cyclomatic complexity <= 10. - Module docstring updated for activated economy. - 4 pinned transcripts regenerated (move-then-attack shifts combat pacing): test_default_policy_moves_toward_enemy, test_scripted_2v2_transcript (monsters win in R2 instead of R3 — orc-2 moves + attacks same turn), test_ranged_no_line_of_effect_moves_around_wall (archer rounds wall and shoots in 1 turn), test_ranged_weapon_unusable_beyond_maximum_range (archer moves 6 cells then attacks at -14 penalty). - README: demo numbers refreshed (1v2: 49.6%->22.0%, 11.4->5.2 rounds; 3v2: 97.8%->91.4%, 6.9->6.0 rounds), rules and non-modeled sections updated for the activated economy.
728 lines
30 KiB
Python
728 lines
30 KiB
Python
"""Tests for the combat engine: attacks, crits, DR, hp, initiative, rounds.
|
|
|
|
The scripted transcripts pin the exact deterministic behavior of the engine
|
|
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,
|
|
CombatEngine,
|
|
CombatResult,
|
|
)
|
|
from pf1e_simulator.dice import parse_dice
|
|
from pf1e_simulator.grid import Grid
|
|
from pf1e_simulator.map import MapSpec, TerrainType
|
|
from pf1e_simulator.models import (
|
|
AbilityScores,
|
|
ACProfile,
|
|
AttackSpec,
|
|
Combatant,
|
|
DamageComponent,
|
|
DamageReduction,
|
|
Saves,
|
|
)
|
|
from pf1e_simulator.rng import ScriptedRng, SeededRng
|
|
|
|
|
|
def make_combatant(
|
|
cid: str,
|
|
*,
|
|
hp: int = 6,
|
|
ac: int = 13,
|
|
attack_bonus: int = 2,
|
|
damage: str = "1d4",
|
|
damage_bonus: int = 0,
|
|
crit_range: int = 20,
|
|
crit_mult: int = 2,
|
|
initiative_mod: int = 0,
|
|
con: int = 12,
|
|
speed: int = 30,
|
|
weapon_name: str | None = None,
|
|
dr: DamageReduction | None = None,
|
|
kind: Literal["melee", "ranged", "touch"] = "melee",
|
|
range_increment_ft: int | None = None,
|
|
count: int = 1,
|
|
) -> Combatant:
|
|
attack = AttackSpec(
|
|
id=f"{cid}-w",
|
|
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,
|
|
count=count,
|
|
)
|
|
return Combatant(
|
|
id=cid,
|
|
name=cid,
|
|
level=1,
|
|
size="Medium",
|
|
abilities=AbilityScores(
|
|
str_score=10,
|
|
dex_score=10,
|
|
con_score=con,
|
|
int_score=10,
|
|
wis_score=10,
|
|
cha_score=10,
|
|
),
|
|
hp_max=hp,
|
|
ac=ACProfile(total=ac, touch=ac, flat_footed=ac),
|
|
bab=attack_bonus,
|
|
initiative_mod=initiative_mod,
|
|
speed_land_ft=speed,
|
|
saves=Saves(fort=0, ref=0, will=0),
|
|
attacks=[attack],
|
|
dr=dr,
|
|
)
|
|
|
|
|
|
def make_state(combatant: Combatant, side: str, pos: tuple[int, int]) -> CombatantState:
|
|
return CombatantState(combatant=combatant, side=side, pos=pos, hp=combatant.hp_max)
|
|
|
|
|
|
def make_grid() -> Grid:
|
|
legend = {".": TerrainType(type="floor", move_cost=1)}
|
|
spec = MapSpec(name="test", terrain=tuple(["." * 8] * 8), legend=legend)
|
|
return Grid.from_spec(spec)
|
|
|
|
|
|
def make_engine(
|
|
queue: list[int], states: list[CombatantState], *, round_cap: int = 100
|
|
) -> CombatEngine:
|
|
return CombatEngine(ScriptedRng(queue), make_grid(), states, round_cap=round_cap)
|
|
|
|
|
|
def test_attack_hit_deals_damage() -> None:
|
|
a_spec = make_combatant("a", attack_bonus=2)
|
|
b_spec = make_combatant("b")
|
|
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(a, b, a_spec.attacks[0])
|
|
assert result.hit is True
|
|
assert result.crit is False
|
|
assert result.damage == 3
|
|
assert b.hp == 3
|
|
|
|
|
|
def test_attack_miss_leaves_target_untouched() -> None:
|
|
a_spec = make_combatant("a")
|
|
b_spec = make_combatant("b")
|
|
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(a, b, a_spec.attacks[0])
|
|
assert result.hit is False
|
|
assert result.damage == 0
|
|
assert b.hp == 6
|
|
|
|
|
|
def test_natural_1_always_misses() -> None:
|
|
a_spec = make_combatant("a", attack_bonus=20)
|
|
b_spec = make_combatant("b")
|
|
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(a, b, a_spec.attacks[0])
|
|
assert result.hit is False
|
|
assert b.hp == 6
|
|
|
|
|
|
def test_natural_20_threatens_and_crits() -> None:
|
|
a_spec = make_combatant("a", attack_bonus=2)
|
|
b_spec = make_combatant("b")
|
|
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(a, b, a_spec.attacks[0])
|
|
assert result.crit is True
|
|
assert result.damage == 8
|
|
assert b.hp == -2
|
|
|
|
|
|
def test_crit_range_19_threatens_on_19() -> None:
|
|
a_spec = make_combatant("a", attack_bonus=2, crit_range=19)
|
|
b_spec = make_combatant("b")
|
|
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(a, b, a_spec.attacks[0])
|
|
assert result.crit is True
|
|
assert result.damage == 4
|
|
assert b.hp == 2
|
|
|
|
|
|
def test_confirm_fail_is_normal_hit() -> None:
|
|
a_spec = make_combatant("a", attack_bonus=2, crit_range=19)
|
|
b_spec = make_combatant("b")
|
|
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(a, b, a_spec.attacks[0])
|
|
assert result.hit is True
|
|
assert result.crit is False
|
|
assert result.damage == 2
|
|
assert b.hp == 4
|
|
|
|
|
|
def test_no_threat_outside_crit_range() -> None:
|
|
a_spec = make_combatant("a", attack_bonus=2, crit_range=19)
|
|
b_spec = make_combatant("b")
|
|
a = make_state(a_spec, "players", (1, 1))
|
|
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(a, b, a_spec.attacks[0])
|
|
assert result.crit is False
|
|
assert result.damage == 3
|
|
assert b.hp == 3
|
|
|
|
|
|
def test_dr_reduces_damage() -> None:
|
|
a_spec = make_combatant("a", attack_bonus=2)
|
|
b_spec = make_combatant("b", dr=DamageReduction(amount=5))
|
|
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(a, b, a_spec.attacks[0])
|
|
assert result.damage == 2
|
|
assert b.hp == 4
|
|
|
|
|
|
def test_dr_floors_damage_at_zero() -> None:
|
|
a_spec = make_combatant("a", attack_bonus=2)
|
|
b_spec = make_combatant("b", dr=DamageReduction(amount=5))
|
|
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(a, b, a_spec.attacks[0])
|
|
assert result.damage == 0
|
|
assert b.hp == 6
|
|
|
|
|
|
def test_dr_bypass_ignores_reduction() -> None:
|
|
a_spec = make_combatant("a", attack_bonus=2)
|
|
b_spec = make_combatant("b", dr=DamageReduction(amount=5, bypass=frozenset({"slashing"})))
|
|
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(a, b, a_spec.attacks[0])
|
|
assert result.damage == 7
|
|
assert b.hp == -1
|
|
|
|
|
|
def test_dr_applies_after_crit_multiplier() -> None:
|
|
a_spec = make_combatant("a", attack_bonus=2)
|
|
b_spec = make_combatant("b", dr=DamageReduction(amount=5))
|
|
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(a, b, a_spec.attacks[0])
|
|
assert result.crit is True
|
|
assert result.damage == 7
|
|
assert b.hp == -1
|
|
|
|
|
|
def test_count_two_resolves_two_swings() -> None:
|
|
a_spec = make_combatant("a", attack_bonus=2, count=2)
|
|
b_spec = make_combatant("b", hp=12)
|
|
a = make_state(a_spec, "players", (1, 1))
|
|
b = make_state(b_spec, "monsters", (1, 2))
|
|
engine = make_engine(
|
|
[10, 9, 12, 3, 10, 7, 13, 4, 11, 2, 8, 12, 3], [a, b]
|
|
)
|
|
result = engine.run()
|
|
assert result.winner == "players"
|
|
assert result.rounds == 3
|
|
assert result.transcript == (
|
|
"initiative: a d20=10+0=10",
|
|
"initiative: b d20=9+0=9",
|
|
"round 1 a: short sword #1 vs b d20=12+2=14 AC 13 -> HIT 3 damage (12->9)",
|
|
"round 1 a: short sword #2 vs b d20=10+2=12 AC 13 -> MISS",
|
|
"round 1 b: short sword vs a d20=7+2=9 AC 13 -> MISS",
|
|
"round 2 a: short sword #1 vs b d20=13+2=15 AC 13 -> HIT 4 damage (9->5)",
|
|
"round 2 a: short sword #2 vs b d20=11+2=13 AC 13 -> HIT 2 damage (5->3)",
|
|
"round 2 b: short sword vs a d20=8+2=10 AC 13 -> MISS",
|
|
"round 3 a: short sword #1 vs b d20=12+2=14 AC 13 -> HIT 3 damage (3->0)",
|
|
"round 3 a: b down",
|
|
"battle over: players win in 3 rounds",
|
|
)
|
|
assert result.stats["a"] == CombatantStats(hits=4, crits=0, damage_dealt=12, damage_taken=0)
|
|
assert result.stats["b"] == CombatantStats(hits=0, crits=0, damage_dealt=0, damage_taken=12)
|
|
|
|
|
|
def test_count_swings_stop_when_target_drops() -> None:
|
|
a_spec = make_combatant("a", attack_bonus=2, damage="1d6", count=2)
|
|
b_spec = make_combatant("b", hp=4)
|
|
a = make_state(a_spec, "players", (1, 1))
|
|
b = make_state(b_spec, "monsters", (1, 2))
|
|
# First swing downs b; the second swing must never consume a roll.
|
|
engine = make_engine([10, 9, 12, 6], [a, b])
|
|
result = engine.run()
|
|
assert result.winner == "players"
|
|
assert result.rounds == 1
|
|
assert result.transcript == (
|
|
"initiative: a d20=10+0=10",
|
|
"initiative: b d20=9+0=9",
|
|
"round 1 a: short sword #1 vs b d20=12+2=14 AC 13 -> HIT 6 damage (4->-2)",
|
|
"round 1 a: b down",
|
|
"battle over: players win in 1 rounds",
|
|
)
|
|
assert result.stats["a"] == CombatantStats(hits=1, crits=0, damage_dealt=6, damage_taken=0)
|
|
assert result.stats["b"] == CombatantStats(hits=0, crits=0, damage_dealt=0, damage_taken=6)
|
|
|
|
|
|
def test_count_swings_resolve_independently() -> None:
|
|
a_spec = make_combatant("a", attack_bonus=2, count=2)
|
|
b_spec = make_combatant("b", hp=12)
|
|
a = make_state(a_spec, "players", (1, 1))
|
|
b = make_state(b_spec, "monsters", (1, 2))
|
|
engine = make_engine([10, 9, 20, 11, 3, 5, 8], [a, b], round_cap=1)
|
|
result = engine.run()
|
|
assert result.winner is None
|
|
assert result.transcript == (
|
|
"initiative: a d20=10+0=10",
|
|
"initiative: b d20=9+0=9",
|
|
"round 1 a: short sword #1 vs b d20=20+2=22 AC 13 -> CRIT 6 damage (12->6)",
|
|
"round 1 a: short sword #2 vs b d20=5+2=7 AC 13 -> MISS",
|
|
"round 1 b: short sword vs a d20=8+2=10 AC 13 -> MISS",
|
|
"battle over: draw after 1 rounds",
|
|
)
|
|
assert result.stats["a"] == CombatantStats(hits=1, crits=1, damage_dealt=6, damage_taken=0)
|
|
assert result.stats["b"] == CombatantStats(hits=0, crits=0, damage_dealt=0, damage_taken=6)
|
|
|
|
|
|
def test_hp_states_active_down_dead() -> None:
|
|
c = make_combatant("c", con=12)
|
|
st = make_state(c, "players", (0, 0))
|
|
st.hp = 1
|
|
assert st.active is True
|
|
assert st.dead is False
|
|
st.hp = 0
|
|
assert st.active is False
|
|
assert st.dead is False
|
|
st.hp = -11
|
|
assert st.active is False
|
|
assert st.dead is False
|
|
st.hp = -12
|
|
assert st.active is False
|
|
assert st.dead is False # dead requires hp < -12, not <=
|
|
st.hp = -13
|
|
assert st.active is False
|
|
assert st.dead is True
|
|
|
|
|
|
def test_scripted_2v2_transcript() -> None:
|
|
gob_1 = make_combatant(
|
|
"gob-1", hp=6, ac=16, attack_bonus=2, damage="1d4", crit_range=19, initiative_mod=6
|
|
)
|
|
gob_2 = make_combatant(
|
|
"gob-2", hp=6, ac=16, attack_bonus=2, damage="1d4", crit_range=19, initiative_mod=6
|
|
)
|
|
orc_1 = make_combatant(
|
|
"orc-1", hp=6, ac=13, attack_bonus=5, damage="2d4", damage_bonus=4, crit_range=18,
|
|
weapon_name="falchion",
|
|
)
|
|
orc_2 = make_combatant(
|
|
"orc-2", hp=6, ac=13, attack_bonus=5, damage="2d4", damage_bonus=4, crit_range=18,
|
|
weapon_name="falchion",
|
|
)
|
|
states = [
|
|
make_state(gob_1, "players", (2, 2)),
|
|
make_state(gob_2, "players", (2, 5)),
|
|
make_state(orc_1, "monsters", (3, 3)),
|
|
make_state(orc_2, "monsters", (3, 4)),
|
|
]
|
|
queue = [
|
|
15, 5, 14, 7, # initiative: gob-1, gob-2, orc-1, orc-2
|
|
13, 3, # R1 gob-1 hits orc-1 for 3
|
|
10, # R1 orc-1 misses gob-1
|
|
14, 1, # R1 gob-2 hits orc-2 for 1
|
|
17, 2, 3, # R1 orc-2 hits gob-2 for 2+3+4=9
|
|
19, 11, 2, # R2 gob-1 crits orc-1: 19 threatens, 11 confirms, 2*2=4
|
|
15, 1, 4, # R2 orc-2 hits gob-1 for 1+4+4=9
|
|
]
|
|
engine = make_engine(queue, states)
|
|
result = engine.run()
|
|
assert result.winner == "monsters"
|
|
assert result.rounds == 2
|
|
assert result.transcript == (
|
|
"initiative: gob-1 d20=15+6=21",
|
|
"initiative: orc-1 d20=14+0=14",
|
|
"initiative: gob-2 d20=5+6=11",
|
|
"initiative: orc-2 d20=7+0=7",
|
|
"round 1 gob-1: short sword vs orc-1 d20=13+2=15 AC 13 -> HIT 3 damage (6->3)",
|
|
"round 1 orc-1: falchion vs gob-1 d20=10+5=15 AC 16 -> MISS",
|
|
"round 1 gob-2: short sword vs orc-2 d20=14+2=16 AC 13 -> HIT 1 damage (6->5)",
|
|
"round 1 orc-2: falchion vs gob-2 d20=17+5=22 AC 16 -> HIT 9 damage (6->-3)",
|
|
"round 1 orc-2: gob-2 down",
|
|
"round 2 gob-1: short sword vs orc-1 d20=19+2=21 AC 13 -> CRIT 4 damage (3->-1)",
|
|
"round 2 gob-1: orc-1 down",
|
|
"round 2 orc-2: move (3,4)->(2,3)",
|
|
"round 2 orc-2: falchion vs gob-1 d20=15+5=20 AC 16 -> HIT 9 damage (6->-3)",
|
|
"round 2 orc-2: gob-1 down",
|
|
"battle over: monsters win in 2 rounds",
|
|
)
|
|
assert result.stats["gob-1"] == CombatantStats(hits=2, crits=1, damage_dealt=7, damage_taken=9)
|
|
assert result.stats["gob-2"] == CombatantStats(hits=1, crits=0, damage_dealt=1, damage_taken=9)
|
|
assert result.stats["orc-1"] == CombatantStats(hits=0, crits=0, damage_dealt=0, damage_taken=7)
|
|
assert result.stats["orc-2"] == CombatantStats(hits=2, crits=0, damage_dealt=18, damage_taken=1)
|
|
|
|
|
|
def test_same_seed_replay_is_identical() -> None:
|
|
def run_battle() -> CombatResult:
|
|
gob = make_combatant(
|
|
"gob", hp=6, ac=16, attack_bonus=2, damage="1d4", crit_range=19, initiative_mod=6
|
|
)
|
|
orc = make_combatant(
|
|
"orc", hp=6, ac=13, attack_bonus=5, damage="2d4", damage_bonus=4, crit_range=18
|
|
)
|
|
states = [make_state(gob, "players", (1, 1)), make_state(orc, "monsters", (1, 2))]
|
|
return CombatEngine(SeededRng(42), make_grid(), states).run()
|
|
|
|
first = run_battle()
|
|
second = run_battle()
|
|
assert first.transcript == second.transcript
|
|
assert first.stats == second.stats
|
|
assert first.winner == second.winner
|
|
assert first.rounds == second.rounds
|
|
|
|
|
|
def test_default_policy_moves_toward_enemy() -> None:
|
|
gob = make_combatant("gob", speed=30)
|
|
orc = make_combatant("orc", speed=0)
|
|
states = [make_state(gob, "players", (1, 1)), make_state(orc, "monsters", (1, 5))]
|
|
engine = make_engine([10, 9, 12, 3, 11, 2, 10, 1], states, round_cap=2)
|
|
result = engine.run()
|
|
assert result.transcript == (
|
|
"initiative: gob d20=10+0=10",
|
|
"initiative: orc d20=9+0=9",
|
|
"round 1 gob: move (1,1)->(0,2)->(0,3)->(0,4)",
|
|
"round 1 gob: short sword vs orc d20=12+2=14 AC 13 -> HIT 3 damage (6->3)",
|
|
"round 1 orc: short sword vs gob d20=11+2=13 AC 13 -> HIT 2 damage (6->4)",
|
|
"round 2 gob: short sword vs orc d20=10+2=12 AC 13 -> MISS",
|
|
"round 2 orc: short sword vs gob d20=1+2=3 AC 13 -> MISS",
|
|
"battle over: draw after 2 rounds",
|
|
)
|
|
|
|
|
|
def test_wait_when_enemy_out_of_reach_and_speed_zero() -> None:
|
|
gob = make_combatant("gob", speed=0)
|
|
orc = make_combatant("orc", speed=0)
|
|
states = [make_state(gob, "players", (1, 1)), make_state(orc, "monsters", (1, 3))]
|
|
engine = make_engine([10, 9], states, round_cap=1)
|
|
result = engine.run()
|
|
assert result.transcript == (
|
|
"initiative: gob d20=10+0=10",
|
|
"initiative: orc d20=9+0=9",
|
|
"round 1 gob: wait",
|
|
"round 1 orc: wait",
|
|
"battle over: draw after 1 rounds",
|
|
)
|
|
|
|
|
|
def test_nearest_enemy_targeting_and_full_rounds() -> None:
|
|
gob = make_combatant("gob", hp=6, ac=13, attack_bonus=2, damage="1d4")
|
|
orc_1 = make_combatant("orc-1", hp=6, ac=13, attack_bonus=2, damage="1d4")
|
|
orc_2 = make_combatant("orc-2", hp=6, ac=13, attack_bonus=2, damage="1d4")
|
|
states = [
|
|
make_state(gob, "players", (1, 1)),
|
|
make_state(orc_1, "monsters", (1, 2)),
|
|
make_state(orc_2, "monsters", (2, 1)),
|
|
]
|
|
queue = [
|
|
15, 10, 5, # initiative: gob, orc-1, orc-2
|
|
12, 4, # R1 gob hits orc-1 for 4
|
|
7, # R1 orc-1 misses
|
|
8, # R1 orc-2 misses
|
|
12, 6, # R2 gob downs orc-1
|
|
9, # R2 orc-2 misses
|
|
11, 3, # R3 gob hits orc-2 for 3
|
|
12, 4, # R3 orc-2 hits gob for 4
|
|
12, 6, # R4 gob downs orc-2
|
|
]
|
|
engine = make_engine(queue, states)
|
|
result = engine.run()
|
|
assert result.winner == "players"
|
|
assert result.rounds == 4
|
|
assert result.transcript == (
|
|
"initiative: gob d20=15+0=15",
|
|
"initiative: orc-1 d20=10+0=10",
|
|
"initiative: orc-2 d20=5+0=5",
|
|
"round 1 gob: short sword vs orc-1 d20=12+2=14 AC 13 -> HIT 4 damage (6->2)",
|
|
"round 1 orc-1: short sword vs gob d20=7+2=9 AC 13 -> MISS",
|
|
"round 1 orc-2: short sword vs gob d20=8+2=10 AC 13 -> MISS",
|
|
"round 2 gob: short sword vs orc-1 d20=12+2=14 AC 13 -> HIT 6 damage (2->-4)",
|
|
"round 2 gob: orc-1 down",
|
|
"round 2 orc-2: short sword vs gob d20=9+2=11 AC 13 -> MISS",
|
|
"round 3 gob: short sword vs orc-2 d20=11+2=13 AC 13 -> HIT 3 damage (6->3)",
|
|
"round 3 orc-2: short sword vs gob d20=12+2=14 AC 13 -> HIT 4 damage (6->2)",
|
|
"round 4 gob: short sword vs orc-2 d20=12+2=14 AC 13 -> HIT 6 damage (3->-3)",
|
|
"round 4 gob: orc-2 down",
|
|
"battle over: players win in 4 rounds",
|
|
)
|
|
|
|
|
|
def test_melee_routes_around_wall() -> None:
|
|
"""Melee unit follows the true shortest path instead of oscillating at a wall."""
|
|
legend = {
|
|
".": TerrainType(type="floor", move_cost=1),
|
|
"#": TerrainType(type="wall", move_cost=None, blocks_los=True),
|
|
}
|
|
spec = MapSpec(
|
|
name="wall-test",
|
|
terrain=("....#...", "....#...", "........"),
|
|
legend=legend,
|
|
)
|
|
grid = Grid.from_spec(spec)
|
|
mover = make_combatant("mover", hp=20, speed=30)
|
|
target = make_combatant("target", speed=0)
|
|
states = [make_state(mover, "players", (1, 1)), make_state(target, "monsters", (1, 5))]
|
|
engine = CombatEngine(SeededRng(42), grid, states)
|
|
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 moves around the wall at full speed, then shoots."""
|
|
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))]
|
|
rng = ScriptedRng([10, 9, 15, 3, 10, 12, 2, 8, 14, 1])
|
|
engine = CombatEngine(rng, grid, states, round_cap=4)
|
|
result = engine.run()
|
|
assert result.winner == "players"
|
|
assert result.transcript == (
|
|
"initiative: archer d20=10+0=10",
|
|
"initiative: target d20=9+0=9",
|
|
"round 1 archer: move (0,1)->(1,2)->(0,3)",
|
|
"round 1 archer: short bow vs target d20=15+4=19 AC 13 -> HIT 3 damage (6->3)",
|
|
"round 1 target: short sword vs archer d20=10+2=12 AC 13 -> MISS",
|
|
"round 2 archer: short bow vs target d20=12+4=16 AC 13 -> HIT 2 damage (3->1)",
|
|
"round 2 target: short sword vs archer d20=8+2=10 AC 13 -> MISS",
|
|
"round 3 archer: short bow vs target d20=14+4=18 AC 13 -> HIT 1 damage (1->0)",
|
|
"round 3 archer: target down",
|
|
"battle over: players win in 3 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"
|
|
|
|
|
|
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, 8]), grid, states, round_cap=1)
|
|
assert engine.weapon_for(states[0], states[1]) is None
|
|
result = engine.run()
|
|
assert result.transcript == (
|
|
"initiative: archer d20=10+0=10",
|
|
"initiative: target d20=9+0=9",
|
|
"round 1 archer: move (0,0)->(0,1)->(0,2)->(0,3)->(0,4)->(0,5)->(0,6)",
|
|
"round 1 archer: short bow vs target d20=8+4-14=-2 AC 13 -> MISS",
|
|
"round 1 target: wait",
|
|
"battle over: draw after 1 rounds",
|
|
)
|
|
|
|
|
|
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",
|
|
)
|