Files
pf1e-simulator/tests/test_pc_combat_golden.py
T

231 lines
10 KiB
Python

"""Golden tests for the 8 PCs - deterministic combats with pinned transcripts."""
from pathlib import Path
import pytest
from pf1e_simulator.combat import CombatantState, CombatEngine
from pf1e_simulator.grid import Grid
from pf1e_simulator.loaders.combatant import load_combatant
from pf1e_simulator.map import MapSpec, TerrainType
from pf1e_simulator.rng import ScriptedRng
from pf1e_simulator.spells import load_spell_registry
PCS_DIR = Path(__file__).resolve().parent.parent / "data" / "pcs"
SPELLS_DIR = Path(__file__).resolve().parent.parent / "data" / "spells"
DATA_DIR = Path(__file__).resolve().parent.parent / "data"
def make_grid():
"""Standard 20x20 open arena."""
legend = {".": TerrainType(type="floor", move_cost=1)}
spec = MapSpec(name="test", terrain=tuple(["." * 20] * 20), legend=legend)
return Grid.from_spec(spec)
def _build_queue():
"""Return the fixed ScriptedRng queue used by every golden test."""
init_rolls = [15, 10]
combat_rolls = []
for _ in range(100):
combat_rolls.extend([10, 15])
return init_rolls + combat_rolls
def run_golden(
pc_name: str, monster_name: str = "goblin", seed: int = 42, rounds: int = 10
) -> tuple:
"""Run a deterministic combat and return (result, transcript).
The queue is designed so PC wins initiative (first roll higher than monster's
by at least init_mod difference).
"""
pc = load_combatant(Path(f"{PCS_DIR}/{pc_name}.json"))
monster = load_combatant(Path(f"{DATA_DIR}/monsters/{monster_name}.json"))
spell_reg = load_spell_registry(SPELLS_DIR)
pc_state = CombatantState(combatant=pc, side="players", pos=(0, 0), hp=pc.hp_max)
monster_state = CombatantState(
combatant=monster, side="monsters", pos=(10, 10), hp=monster.hp_max
)
rng = ScriptedRng(_build_queue())
engine = CombatEngine(
rng, make_grid(), [pc_state, monster_state], round_cap=10, spell_registry=spell_reg
)
result = engine.run()
return result, engine._transcript
class TestGoldenCombats:
"""Golden tests for each PC vs goblin - deterministic combats with pinned transcripts."""
def test_tammara_vs_goblin(self):
"""Tammara (Gunslinger/Warpriest) vs Goblin — full pinned transcript.
Golden updated by the Weapon Focus conformance fix: the feat filter
"Pistol" now matches her pistol names ("... (pistolet ?)"), so every
pistol attack gains the +1 feat bonus.
Updated again by the SRD shield-of-faith fix (data/spells/
shield_of_faith.json now carries its real +2 deflection AC buff):
_find_buff_spell returns the FIRST buff spell in her known-spell
order, and Shield of Faith (list index 30) precedes Bull's Strength
(index 46) — so the policy's buff pick is now Shield of Faith.
Line-by-line:
- rounds 1-4 tammara: "cast Shield of Faith ... DC14 SAVED -> buff 1
mods (10r)" — DC 14 = 10 + spell level 1 + her +3 Wis; the deflection
buff does not stack with itself, so the re-casts just refresh it. She
has four level-1 slots, so the buff phase now runs rounds 1-4 (the
old Bull's Strength phase ran rounds 1-3 on three level-2 slots).
- rounds 1-10 goblin: "AC 22" (was 20) — the +2 deflection raises her
AC; the goblin's d20=15+4-2=17 shot still misses.
- rounds 5-10 tammara: "d20=10+9-6=14 AC 16 -> MISS" — out of level-1
slots she shoots; without Bull's Strength's +2 enhancement attack
bonus the scripted d20=10 shot totals 14, below the goblin's AC 16.
- "battle over: draw after 10 rounds": the goblin is never dropped
within the round cap. This is a default_policy first-match heuristic
artifact (Shield of Faith is a legitimate SRD buff, just a
defensively-picked one), not an SRD regression.
"""
result, _ = run_golden("tammara")
assert result.winner is None
assert result.rounds == 10
assert result.transcript == (
"initiative: tammara d20=15+7=22",
"initiative: goblin d20=10+6=16",
("round 1 tammara: cast Shield of Faith vs tammara DC14 SAVED "
"-> buff 1 mods (10r) (36->36)"),
"round 1 goblin: short bow vs tammara d20=15+4-2=17 AC 22 -> MISS",
("round 2 tammara: cast Shield of Faith vs tammara DC14 SAVED "
"-> buff 1 mods (10r) (36->36)"),
"round 2 goblin: short bow vs tammara d20=15+4-2=17 AC 22 -> MISS",
("round 3 tammara: cast Shield of Faith vs tammara DC14 SAVED "
"-> buff 1 mods (10r) (36->36)"),
"round 3 goblin: short bow vs tammara d20=15+4-2=17 AC 22 -> MISS",
("round 4 tammara: cast Shield of Faith vs tammara DC14 SAVED "
"-> buff 1 mods (10r) (36->36)"),
"round 4 goblin: short bow vs tammara d20=15+4-2=17 AC 22 -> MISS",
("round 5 tammara: Braises d'Automne (pistolet ?) vs goblin "
"d20=10+9-6=14 AC 16 -> MISS"),
"round 5 goblin: short bow vs tammara d20=15+4-2=17 AC 22 -> MISS",
("round 6 tammara: Braises d'Automne (pistolet ?) vs goblin "
"d20=10+9-6=14 AC 16 -> MISS"),
"round 6 goblin: short bow vs tammara d20=15+4-2=17 AC 22 -> MISS",
("round 7 tammara: Braises d'Automne (pistolet ?) vs goblin "
"d20=10+9-6=14 AC 16 -> MISS"),
"round 7 goblin: short bow vs tammara d20=15+4-2=17 AC 22 -> MISS",
("round 8 tammara: Braises d'Automne (pistolet ?) vs goblin "
"d20=10+9-6=14 AC 16 -> MISS"),
"round 8 goblin: short bow vs tammara d20=15+4-2=17 AC 22 -> MISS",
("round 9 tammara: Braises d'Automne (pistolet ?) vs goblin "
"d20=10+9-6=14 AC 16 -> MISS"),
"round 9 goblin: short bow vs tammara d20=15+4-2=17 AC 22 -> MISS",
("round 10 tammara: Braises d'Automne (pistolet ?) vs goblin "
"d20=10+9-6=14 AC 16 -> MISS"),
"round 10 goblin: short bow vs tammara d20=15+4-2=17 AC 22 -> MISS",
"battle over: draw after 10 rounds",
)
def test_tammara_vs_lich(self):
"""Tammara vs Lich (boss fight) — full pinned transcript.
Updated (S2b): the lich is caster level 11, so Magic Missile now fires
its SRD full five missiles (1d4+1 each) instead of a single one. Three
missiles (16 + 11 + 16 = 43) drop Tammara below 0 hp on round 1; the
remaining two are lost (the engine's target-drops-mid-sequence
convention), so the lich wins in 1 round.
Updated by the SRD shield-of-faith fix: her round-1 buff pick is now
Shield of Faith (first buff spell in her known-spell order, index 30
before Bull's Strength's 46) — "cast Shield of Faith ... DC14 SAVED ->
buff 1 mods (10r)" (DC 14 = 10 + level 1 + her +3 Wis). Magic Missile
auto-hits and ignores the +2 deflection it would otherwise grant, so
the lich's line and the outcome are unchanged.
"""
result, _ = run_golden("tammara", "lich")
assert result.winner == "monsters"
assert result.rounds == 1
assert result.transcript == (
"initiative: tammara d20=15+7=22",
"initiative: lich d20=10+6=16",
("round 1 tammara: cast Shield of Faith vs tammara DC14 SAVED "
"-> buff 1 mods (10r) (36->36)"),
"round 1 lich: cast Magic Missile vs tammara DC16 NO SAVE -> 43 damage (36->-7)",
"battle over: monsters win in 1 rounds",
)
def _assert_deterministic_goblin(self, pc_name: str):
"""Run the same PC vs goblin twice and assert identical outcomes."""
r1, t1 = run_golden(pc_name)
r2, t2 = run_golden(pc_name)
assert r1.winner == r2.winner
assert t1 == t2
def test_esha_vs_goblin(self):
"""Esha (Luring Cavalier/Phantom Thief) vs Goblin."""
result, transcript = run_golden("esha")
assert result.winner == "players"
assert result.rounds >= 1
self._assert_deterministic_goblin("esha")
def test_nairda_vs_goblin(self):
"""Nairda (Witch/Sorcerer) vs Goblin - draw under the fixed queue."""
result, transcript = run_golden("nairda")
assert result.winner is None
assert result.rounds == 10
self._assert_deterministic_goblin("nairda")
def test_misty_vs_goblin(self):
"""Misty (Magus/Bard) vs Goblin."""
result, transcript = run_golden("misty")
assert result.winner == "players"
assert result.rounds >= 1
self._assert_deterministic_goblin("misty")
def test_oni_vs_goblin(self):
"""Oni (Cleric/Shifter) vs Goblin."""
result, transcript = run_golden("oni")
assert result.winner == "players"
assert result.rounds >= 1
self._assert_deterministic_goblin("oni")
def test_harvie_vs_goblin(self):
"""Harvie (Animal Companion, flying) vs Goblin - Harvie wins.
Updated (V4): Harvie has speed_land_ft 10 / speed_fly_ft 80; movement now
uses the best of land/fly speed, so Harvie flies at 80 ft and wins (the
previous expectation pinned him crawling at 10 ft).
"""
result, transcript = run_golden("harvie")
assert result.winner == "players"
assert result.rounds >= 1
self._assert_deterministic_goblin("harvie")
def test_ierlieth_vs_goblin(self):
"""Ierlieth (Court Drake) vs Goblin."""
result, transcript = run_golden("ierlieth")
assert result.winner == "players"
assert result.rounds >= 1
self._assert_deterministic_goblin("ierlieth")
def test_jeanne_vs_goblin(self):
"""Jeanne (Animal Companion Bodyguard) vs Goblin."""
result, transcript = run_golden("jeanne")
assert result.winner == "players"
assert result.rounds >= 1
self._assert_deterministic_goblin("jeanne")
class TestAllPCsLoad:
"""Verify all 8 PCs load without errors."""
@pytest.mark.parametrize("pc_name", [
"esha", "tammara", "nairda", "misty", "oni", "harvie", "ierlieth", "jeanne"
])
def test_pc_loads(self, pc_name):
pc = load_combatant(Path(f"data/pcs/{pc_name}.json"))
assert pc.name
assert pc.level == 4 or pc.name == "Harvie" # Harvie is level 2
assert pc.hp_max > 0
assert pc.ac.total > 0
assert pc.bab >= 0
assert len(pc.attacks) > 0
if __name__ == "__main__":
pytest.main([__file__, "-v"])