From 9ffae1a7a237c90104467f80a80536a259db0e88 Mon Sep 17 00:00:00 2001 From: ctan Date: Fri, 4 Sep 2026 22:56:03 +0200 Subject: [PATCH] test(combat): pin ScriptedRng transcripts, unique per scenario --- tests/test_pc_combat_golden.py | 195 +++++++++++++++++++++++++++++++++ 1 file changed, 195 insertions(+) create mode 100644 tests/test_pc_combat_golden.py diff --git a/tests/test_pc_combat_golden.py b/tests/test_pc_combat_golden.py new file mode 100644 index 0000000..4e0f2cb --- /dev/null +++ b/tests/test_pc_combat_golden.py @@ -0,0 +1,195 @@ +"""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.""" + 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 Bull's Strength vs tammara DC15 SAVED -> buff 2 mods (60r) " + "(36->36)"), + "round 1 goblin: short bow vs tammara d20=15+4-2=17 AC 20 -> MISS", + ("round 2 tammara: cast Bull's Strength vs tammara DC15 SAVED -> buff 2 mods (60r) " + "(36->36)"), + "round 2 goblin: short bow vs tammara d20=15+4-2=17 AC 20 -> MISS", + ("round 3 tammara: cast Bull's Strength vs tammara DC15 SAVED -> buff 2 mods (60r) " + "(36->36)"), + "round 3 goblin: short bow vs tammara d20=15+4-2=17 AC 20 -> MISS", + "round 4 tammara: Braises d'Automne (pistolet ?) vs goblin d20=10+9-6=14 AC 16 -> MISS", + "round 4 goblin: short bow vs tammara d20=15+4-2=17 AC 20 -> 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 20 -> 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 20 -> 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 20 -> 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 20 -> 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 20 -> 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 20 -> MISS", + "battle over: draw after 10 rounds", + ) + + def test_tammara_vs_lich(self): + """Tammara vs Lich (boss fight) — full pinned transcript.""" + result, _ = run_golden("tammara", "lich") + assert result.winner == "monsters" + assert result.rounds == 6 + assert result.transcript == ( + "initiative: tammara d20=15+7=22", + "initiative: lich d20=10+6=16", + ("round 1 tammara: cast Bull's Strength vs tammara DC15 SAVED -> buff 2 mods (60r) " + "(36->36)"), + "round 1 lich: cast Magic Missile vs tammara DC16 NO SAVE -> 16 damage (36->20)", + ("round 2 tammara: cast Bull's Strength vs tammara DC15 SAVED -> buff 2 mods (60r) " + "(20->20)"), + "round 2 lich: cast Magic Missile vs tammara DC16 NO SAVE -> 16 damage (20->4)", + "round 3 tammara: cast Cure Light Wounds vs tammara DC14 SAVED -> +16 HP (4->20)", + "round 3 lich: cast Magic Missile vs tammara DC16 NO SAVE -> 11 damage (20->9)", + "round 4 tammara: cast Cure Light Wounds vs tammara DC14 SAVED -> +11 HP (9->20)", + "round 4 lich: cast Magic Missile vs tammara DC16 NO SAVE -> 16 damage (20->4)", + "round 5 tammara: cast Cure Light Wounds vs tammara DC14 SAVED -> +16 HP (4->20)", + "round 5 lich: move (10,10)->(9,9)->(8,8)->(7,7)->(6,6)->(5,5)->(4,4)", + ("round 6 tammara: cast Bull's Strength vs tammara DC15 SAVED -> buff 2 mods (60r) " + "(20->20)"), + "round 6 lich: fear aura on tammara -> SAVED", + "round 6 lich: move (4,4)->(3,3)->(2,2)->(1,1)", + ("round 6 lich: Paralyzing Touch vs tammara d20=10+10=20 AC 20 -> HIT 25 damage " + "(20->-5) paralyzed (11r)"), + "round 6 lich: tammara down", + "battle over: monsters win in 6 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) vs Goblin - the goblin wins.""" + result, transcript = run_golden("harvie") + assert result.winner == "monsters" + 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"])