"""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 3 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 3 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 3 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=15 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=15 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=15 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=15 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=15 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=15 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=15 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. 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. """ 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 Bull's Strength vs tammara DC15 SAVED -> buff 3 mods (60r) " "(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"])