79ac190728
Movement budget is now max(land, fly) speed. Updates the harvie golden: Harvie (land 10 / fly 80) is a flyer and now flies at 80 ft, so he wins vs the goblin instead of being pinned crawling at 10 ft.
201 lines
9.1 KiB
Python
201 lines
9.1 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."""
|
|
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 16 -> 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, 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"])
|