Files
pf1e-simulator/tests/test_combat.py
T
ctan a21e234b41 feat(runner): Monte Carlo runner, closed-form metrics and balance CLI
Adds the S6 layer on top of the deterministic combat engine:
- metrics.py: closed-form binomial statistics (win rate, standard error,
  3-sigma confidence band) with clamped degenerate proportions.
- runner.py: EncounterSpec/Side definitions, per-run SeededRng streams,
  global duplicate-id disambiguation, attrition averages per combatant.
- cli.py: pf1e-sim entry point producing a French balance report with
  win rates, 3-sigma bands, draws, rounds, and attrition.
- Movement fix: greedy straight-line heuristic could oscillate at walls;
  _step_toward now follows the true shortest path via an unbounded
  Dijkstra cost field from the target (Grid.reachable budget=None).
- Regression tests: melee unit routes around a wall and engages; grid
  unbounded-budget coverage.
2026-08-17 22:49:50 +02:00

418 lines
15 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 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 = "short sword",
dr: DamageReduction | None = None,
) -> Combatant:
attack = AttackSpec(
id=f"{cid}-w",
name=weapon_name,
kind="melee",
attack_bonus=attack_bonus,
damage=[DamageComponent(formula=parse_dice(damage), types=["slashing"])],
damage_bonus=damage_bonus,
crit_range=crit_range,
crit_mult=crit_mult,
)
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(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(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(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(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(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(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(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(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(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(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(b, a_spec.attacks[0])
assert result.crit is True
assert result.damage == 7
assert b.hp == -1
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
7, # R3 gob-1 misses orc-2
15, 1, 4, # R3 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 == 3
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 3 gob-1: short sword vs orc-2 d20=7+2=9 AC 13 -> MISS",
"round 3 orc-2: falchion vs gob-1 d20=15+5=20 AC 16 -> HIT 9 damage (6->-3)",
"round 3 orc-2: gob-1 down",
"battle over: monsters win in 3 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], 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)",
"round 1 orc: wait",
"round 2 gob: move (0,2)->(0,3)",
"round 2 orc: wait",
"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