3e19d2974e
SRD spells/mage-armor.md: 'An invisible but tangible field of force surrounds the subject of a mage armor spell, providing a +4 armor bonus to AC' - 'School conjuration (creation) [force]', level 1, 'Casting Time 1 standard action', 'Range touch', 'Duration 1 hour/level (D)', 'Spell Resistance no'. - data/spells/mage_armor.json: buff +4 armor to AC, 600 rounds (1 hour/level modeled fixed, the shield.json convention). Description documents the known simplification: the engine bakes worn armor into the AC total and cannot see it, so the armor bonus stacks additively with that baked value (two mage armors still do not stack). - effects.py: 'armor' added to BonusType; absent from _STACKING_TYPES it behaves as a keep-the-highest type (verified by resolve_modifiers tests). - Spell-count pins 107 -> 108 (test_runner_spells, batch3, test_spells) and the README references. - Golden transcript untouched (buff branch only fires when no offensive spell is selected; the lich still opens with Magic Missile).
71 lines
2.6 KiB
Python
71 lines
2.6 KiB
Python
"""Wiring tests: the Monte Carlo runner loads the real spell registry and
|
|
spellcasting PCs cast against ``data/spells/``.
|
|
|
|
The engine's ``spell_registry`` defaults to ``{}`` for retro-compat with pure
|
|
unit tests; the runner must therefore load the registry from ``SPELLS_DIR`` and
|
|
pass it into every ``CombatEngine`` so a caster PC actually produces
|
|
``cast_spell`` actions in Monte Carlo.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
|
|
from pf1e_simulator.combat import CombatEngine
|
|
from pf1e_simulator.grid import Grid
|
|
from pf1e_simulator.loaders import load_combatant
|
|
from pf1e_simulator.map import load_map
|
|
from pf1e_simulator.rng import SeededRng
|
|
from pf1e_simulator.runner import EncounterSpec, Side, build_states
|
|
from pf1e_simulator.spells import SPELLS_DIR, load_spell_registry
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
MAP = ROOT / "data" / "maps" / "sample_arena.yaml"
|
|
ONI = ROOT / "data" / "pcs" / "oni.json"
|
|
GOBLIN = ROOT / "data" / "monsters" / "goblin.json"
|
|
|
|
_NUM_SPELLS = 108
|
|
|
|
|
|
def test_runner_loads_realtime_spell_registry() -> None:
|
|
"""The runner's registry source holds all 108 real spells from data/spells/."""
|
|
registry = load_spell_registry(SPELLS_DIR)
|
|
assert len(registry) == _NUM_SPELLS
|
|
|
|
|
|
def test_monte_carlo_caster_pc_casts_spell() -> None:
|
|
"""oni (cleric) casts against data/spells/ once it drops below 50% HP.
|
|
|
|
oni's known spells overlap the registry's heal spells (Cure Light/Moderate
|
|
Wounds); with enough damage taken it heals via cast_spell. Channel energy is
|
|
stripped from the resource pool: it now heals living creatures in the burst,
|
|
so below 50% HP oni self-heals by channeling and never reaches the spell
|
|
branch — this test stays a pure spell-wiring check. The Monte Carlo
|
|
loop mirrors run_encounter: build fresh states and one CombatEngine per run
|
|
with the registry from SPELLS_DIR.
|
|
"""
|
|
oni = load_combatant(ONI).model_copy(update={"resources": {}})
|
|
spec = EncounterSpec(
|
|
map=load_map(MAP),
|
|
round_cap=100,
|
|
sides=(
|
|
Side(name="players", combatants=(oni,), zone="A"),
|
|
Side(name="monsters", combatants=(load_combatant(GOBLIN),) * 4, zone="B"),
|
|
),
|
|
)
|
|
registry = load_spell_registry(SPELLS_DIR)
|
|
grid = Grid.from_spec(spec.map)
|
|
casts = 0
|
|
for i in range(100):
|
|
states = build_states(spec)
|
|
rng = SeededRng(7 + i)
|
|
result = CombatEngine(
|
|
rng, grid, states, round_cap=spec.round_cap, spell_registry=registry
|
|
).run()
|
|
casts += sum(
|
|
1
|
|
for line in result.transcript
|
|
if " vs " in line and "cast " in line
|
|
)
|
|
assert casts > 0
|