219 lines
9.5 KiB
Python
219 lines
9.5 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 spell-effect fixes (shield of faith and
|
|
divine favor now carry their real buffs): _find_buff_spell returns the
|
|
FIRST buff spell in her known-spell order, and Divine Favor (list
|
|
index 20) precedes Shield of Faith (30) and Bull's Strength (46) — so
|
|
the policy's buff pick is now Divine Favor.
|
|
Line-by-line:
|
|
- rounds 1-4 tammara: "cast Divine Favor vs tammara DC14 NO SAVE ->
|
|
buff 2 mods (10r)" — save "none" (SRD: personal, no save), so no d20
|
|
is consumed by the cast; the +1 luck attack / +1 luck weapon-damage
|
|
buff scales +1 per three caster levels (level 4 -> +2/+2), luck not
|
|
stacking with itself so the re-casts just refresh it. Four level-1
|
|
slots cover rounds 1-4 (the old Bull's Strength phase ran rounds 1-3
|
|
on three level-2 slots).
|
|
- rounds 1-5 goblin: the un-consumed save rolls shift the scripted
|
|
dice queue — the goblin's shots alternate d20=10/15 (+4-2 range),
|
|
12/17 vs her unchanged AC 20: all MISS.
|
|
- round 5 tammara: out of level-1 slots she shoots; the Divine Favor
|
|
+2 luck on attack and weapon damage reproduces the old Bull's
|
|
Strength totals: d20=10+9-6=16 meets AC 16 -> HIT 17 damage, goblin
|
|
6 -> -11 (below its -CON death threshold).
|
|
- "goblin down" + "players win in 5 rounds": the goblin dies one round
|
|
later than before (four level-1 buff casts instead of three
|
|
level-2 casts).
|
|
"""
|
|
result, _ = run_golden("tammara")
|
|
assert result.winner == "players"
|
|
assert result.rounds == 5
|
|
assert result.transcript == (
|
|
"initiative: tammara d20=15+7=22",
|
|
"initiative: goblin d20=10+6=16",
|
|
("round 1 tammara: cast Divine Favor vs tammara DC14 NO SAVE "
|
|
"-> buff 2 mods (10r) (36->36)"),
|
|
"round 1 goblin: short bow vs tammara d20=10+4-2=12 AC 20 -> MISS",
|
|
("round 2 tammara: cast Divine Favor vs tammara DC14 NO SAVE "
|
|
"-> buff 2 mods (10r) (36->36)"),
|
|
"round 2 goblin: short bow vs tammara d20=15+4-2=17 AC 20 -> MISS",
|
|
("round 3 tammara: cast Divine Favor vs tammara DC14 NO SAVE "
|
|
"-> buff 2 mods (10r) (36->36)"),
|
|
"round 3 goblin: short bow vs tammara d20=10+4-2=12 AC 20 -> MISS",
|
|
("round 4 tammara: cast Divine Favor vs tammara DC14 NO SAVE "
|
|
"-> buff 2 mods (10r) (36->36)"),
|
|
"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=16 AC 16 -> HIT 17 damage (6->-11)"),
|
|
"round 5 tammara: goblin down",
|
|
"battle over: players win in 5 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).
|
|
|
|
Updated by the SRD spell-effect fixes: her round-1 buff pick is now
|
|
Divine Favor (first buff spell in her known-spell order, index 20) —
|
|
"cast Divine Favor vs tammara DC14 NO SAVE -> buff 2 mods (10r)"
|
|
(DC 14 = 10 + level 1 + her +3 Wis; save "none" consumes no d20, so
|
|
the scripted queue feeding the lich's five missile damage rolls
|
|
shifts: 38 total instead of 43). Three missiles still drop Tammara
|
|
below 0 hp on round 1 (36 -> -2) and the remaining two are lost (the
|
|
engine's target-drops-mid-sequence convention), so the lich wins in 1
|
|
round — outcome 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 Divine Favor vs tammara DC14 NO SAVE "
|
|
"-> buff 2 mods (10r) (36->36)"),
|
|
"round 1 lich: cast Magic Missile vs tammara DC16 NO SAVE -> 38 damage (36->-2)",
|
|
"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"])
|