fix(rules): summon duration scales with caster level (SRD summon-monster: 1 round/level (D)) - SummonEffect gains duration_rounds_per_level resolved at cast time, summon_monster_i/ii JSON updated
This commit is contained in:
@@ -1235,7 +1235,14 @@ Règles modélisées :
|
|||||||
coin/case selon le CRB ; les créatures 1×1 sont inchangées.
|
coin/case selon le CRB ; les créatures 1×1 sont inchangées.
|
||||||
- Invocation : les créatures invoquées agissent (calées sur la case d'initiative de
|
- Invocation : les créatures invoquées agissent (calées sur la case d'initiative de
|
||||||
leur conjurateur via `_build_round_order`) puis expirent après leur `summon_duration`
|
leur conjurateur via `_build_round_order`) puis expirent après leur `summon_duration`
|
||||||
(`_expire_summons` décrémente puis retire).
|
(`_expire_summons` décrémente puis retire). La durée des sorts `summon monster` est
|
||||||
|
`1 round/level` (SRD spells/summon-monster-1.md : « Duration 1 round/level (D) ») :
|
||||||
|
un `SummonEffect` déclare `duration_rounds_per_level` (défaut 0 = durée fixe,
|
||||||
|
comportement pré-conformance) et la durée est résolue au moment de l'incantation
|
||||||
|
comme `max(1, niveau du lanceur × duration_rounds_per_level)`, selon la même
|
||||||
|
convention de scalage par niveau que `ConditionEffect` (voir « Formes de zone et
|
||||||
|
scalage »). Les deux sorts importés (`summon_monster_i/ii.json`) portent
|
||||||
|
`duration_rounds_per_level: 1`.
|
||||||
- Conditions effectives : 28 conditions prédéfinies (`conditions.py`). Leurs drapeaux
|
- Conditions effectives : 28 conditions prédéfinies (`conditions.py`). Leurs drapeaux
|
||||||
comportementaux sont appliqués par le moteur : `cant_act` (étourdi, paralysé, dazed,
|
comportementaux sont appliqués par le moteur : `cant_act` (étourdi, paralysé, dazed,
|
||||||
endormi, inerme, fasciné) annule le tour ; `cant_attack` (nauséeux, épinglé) n'autorise qu'une unique action
|
endormi, inerme, fasciné) annule le tour ; `cant_attack` (nauséeux, épinglé) n'autorise qu'une unique action
|
||||||
|
|||||||
@@ -11,8 +11,9 @@
|
|||||||
{
|
{
|
||||||
"type": "summon",
|
"type": "summon",
|
||||||
"creature": "data/monsters/celestial_wolf.json",
|
"creature": "data/monsters/celestial_wolf.json",
|
||||||
"duration_rounds": 4
|
"duration_rounds": 1,
|
||||||
|
"duration_rounds_per_level": 1
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"description": "Casting time 1 round: summons one extraplanar creature from the 1st-level list (e.g. dire rat, dog, eagle) to fight for you for 1 round/level. Simplified: the engine bestiary holds no 1st-level summon, so the celestial wolf model is reused and the duration is set to 4 rounds (1 round/level at caster level 4)."
|
"description": "Casting time 1 round: summons one extraplanar creature from the 1st-level list (e.g. dire rat, dog, eagle) to fight for you. SRD duration 1 round/level (D), resolved at cast time from the caster level. Simplified: the engine bestiary holds no 1st-level summon, so the celestial wolf model is reused."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,8 +11,9 @@
|
|||||||
{
|
{
|
||||||
"type": "summon",
|
"type": "summon",
|
||||||
"creature": "data/monsters/celestial_wolf.json",
|
"creature": "data/monsters/celestial_wolf.json",
|
||||||
"duration_rounds": 5
|
"duration_rounds": 1,
|
||||||
|
"duration_rounds_per_level": 1
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"description": "Summons a celestial wolf to fight for you."
|
"description": "Summons a celestial wolf to fight for you. SRD duration 1 round/level (D), resolved at cast time from the caster level."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7220,6 +7220,15 @@ class CombatEngine:
|
|||||||
_spell_name: str,
|
_spell_name: str,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Apply a summoning effect - create a summoned creature."""
|
"""Apply a summoning effect - create a summoned creature."""
|
||||||
|
# PF1e (SRD spells/summon-monster-1.md): "Duration 1 round/level (D)"
|
||||||
|
# - a per-level duration resolves against the caster's level at cast
|
||||||
|
# time, mirroring the ConditionEffect convention; a fixed duration is
|
||||||
|
# the pre-conformance fallback.
|
||||||
|
duration = effect.duration_rounds
|
||||||
|
if effect.duration_rounds_per_level > 0:
|
||||||
|
duration = max(
|
||||||
|
1, state.combatant.level * effect.duration_rounds_per_level
|
||||||
|
)
|
||||||
# Load the summoned creature
|
# Load the summoned creature
|
||||||
try:
|
try:
|
||||||
summoned_combatant = load_combatant(Path(effect.creature))
|
summoned_combatant = load_combatant(Path(effect.creature))
|
||||||
@@ -7256,7 +7265,7 @@ class CombatEngine:
|
|||||||
self._states.insert(caster_idx + 1, summoned_state)
|
self._states.insert(caster_idx + 1, summoned_state)
|
||||||
|
|
||||||
# Set expiration
|
# Set expiration
|
||||||
summoned_state.summon_duration = effect.duration_rounds
|
summoned_state.summon_duration = duration
|
||||||
summoned_state.summon_caster_id = state.combatant.id
|
summoned_state.summon_caster_id = state.combatant.id
|
||||||
self._apply_simple_monster_death(summoned_state)
|
self._apply_simple_monster_death(summoned_state)
|
||||||
|
|
||||||
@@ -7265,7 +7274,7 @@ class CombatEngine:
|
|||||||
|
|
||||||
self._log(
|
self._log(
|
||||||
f"round {self._current_round} {state.combatant.id}: "
|
f"round {self._current_round} {state.combatant.id}: "
|
||||||
f"summon {summoned_combatant.id} at {spawn_pos} ({effect.duration_rounds}r)"
|
f"summon {summoned_combatant.id} at {spawn_pos} ({duration}r)"
|
||||||
)
|
)
|
||||||
return f"summoned {summoned_combatant.id}"
|
return f"summoned {summoned_combatant.id}"
|
||||||
|
|
||||||
|
|||||||
@@ -296,10 +296,17 @@ class SummonEffect:
|
|||||||
"""Summoning spell effect.
|
"""Summoning spell effect.
|
||||||
|
|
||||||
- creature: path to the creature JSON file (e.g., "data/monsters/celestial_wolf.json")
|
- creature: path to the creature JSON file (e.g., "data/monsters/celestial_wolf.json")
|
||||||
- duration_rounds: how long the summoned creature stays
|
- duration_rounds: fixed rounds the summoned creature stays, used when
|
||||||
|
``duration_rounds_per_level`` is 0
|
||||||
|
- duration_rounds_per_level: PF1e "1 round/level" summoning duration
|
||||||
|
(SRD spells/summon-monster-1.md: "Duration 1 round/level (D)") - when
|
||||||
|
> 0 the rounds resolve at cast time as
|
||||||
|
``max(1, caster_level * duration_rounds_per_level)``, mirroring
|
||||||
|
``ConditionEffect``'s field of the same name.
|
||||||
"""
|
"""
|
||||||
creature: str
|
creature: str
|
||||||
duration_rounds: int
|
duration_rounds: int
|
||||||
|
duration_rounds_per_level: int = 0
|
||||||
|
|
||||||
|
|
||||||
SpellEffect = (
|
SpellEffect = (
|
||||||
@@ -690,6 +697,9 @@ def _parse_summon_effect(effect: dict[str, Json]) -> SummonEffect:
|
|||||||
return SummonEffect(
|
return SummonEffect(
|
||||||
creature=_as_str(effect["creature"]),
|
creature=_as_str(effect["creature"]),
|
||||||
duration_rounds=_as_int(effect["duration_rounds"]),
|
duration_rounds=_as_int(effect["duration_rounds"]),
|
||||||
|
duration_rounds_per_level=_as_int(
|
||||||
|
effect.get("duration_rounds_per_level", 0)
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+76
-1
@@ -20,7 +20,7 @@ from pf1e_simulator.models import (
|
|||||||
Saves,
|
Saves,
|
||||||
)
|
)
|
||||||
from pf1e_simulator.rng import SeededRng
|
from pf1e_simulator.rng import SeededRng
|
||||||
from pf1e_simulator.spells import SpellSpec, SummonEffect
|
from pf1e_simulator.spells import SPELLS_DIR, SpellSpec, SummonEffect, load_spell
|
||||||
|
|
||||||
|
|
||||||
def _make_caster() -> Combatant:
|
def _make_caster() -> Combatant:
|
||||||
@@ -260,3 +260,78 @@ class TestSummonedCreatureExpires:
|
|||||||
assert not wolf_states[0].active, (
|
assert not wolf_states[0].active, (
|
||||||
"Wolf should be inactive after expiration"
|
"Wolf should be inactive after expiration"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestSummonDurationScalesWithCasterLevel:
|
||||||
|
"""SRD spells/summon-monster-1.md: "Duration 1 round/level (D)" - the
|
||||||
|
summon's rounds resolve at cast time from the caster's level, mirroring
|
||||||
|
the ConditionEffect per-level duration convention."""
|
||||||
|
|
||||||
|
def _cast(self, caster_level: int, *, per_level: int) -> CombatEngine:
|
||||||
|
caster = _make_caster()
|
||||||
|
caster = caster.model_copy(update={"level": caster_level})
|
||||||
|
enemy = _make_enemy()
|
||||||
|
c = CombatantState(combatant=caster, side="players", pos=(0, 0), hp=caster.hp_max)
|
||||||
|
e = CombatantState(combatant=enemy, side="monsters", pos=(5, 0), hp=enemy.hp_max)
|
||||||
|
spell = SpellSpec(
|
||||||
|
name="Summon Monster II",
|
||||||
|
level=2,
|
||||||
|
school="conjuration",
|
||||||
|
range="close",
|
||||||
|
effects=(
|
||||||
|
SummonEffect(
|
||||||
|
creature="data/monsters/celestial_wolf.json",
|
||||||
|
duration_rounds=1,
|
||||||
|
duration_rounds_per_level=per_level,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
legend = {".": TerrainType(type="floor", move_cost=1)}
|
||||||
|
spec = MapSpec(name="test", terrain=tuple(["." * 12] * 12), legend=legend)
|
||||||
|
engine = CombatEngine(
|
||||||
|
SeededRng(1), Grid.from_spec(spec), [c, e],
|
||||||
|
spell_registry={"Summon Monster II": spell},
|
||||||
|
)
|
||||||
|
engine._cast_spell(c, e, "Summon Monster II")
|
||||||
|
return engine
|
||||||
|
|
||||||
|
def test_duration_equals_caster_level_rounds(self) -> None:
|
||||||
|
# Given/When: a level 5 caster summons with 1 round/level
|
||||||
|
# Then: the wolf lives for 5 rounds.
|
||||||
|
engine = self._cast(5, per_level=1)
|
||||||
|
wolf = next(s for s in engine._states if s.combatant.id == "celestial_wolf")
|
||||||
|
assert wolf.summon_duration == 5
|
||||||
|
|
||||||
|
def test_duration_scales_for_a_higher_caster(self) -> None:
|
||||||
|
# Given: a level 9 caster
|
||||||
|
# Then: 9 rounds - the duration is not the fixed JSON value.
|
||||||
|
engine = self._cast(9, per_level=1)
|
||||||
|
wolf = next(s for s in engine._states if s.combatant.id == "celestial_wolf")
|
||||||
|
assert wolf.summon_duration == 9
|
||||||
|
|
||||||
|
def test_fixed_duration_still_used_without_per_level_field(self) -> None:
|
||||||
|
# Given: a summon declaring only a fixed duration (per_level = 0)
|
||||||
|
# Then: the fixed value applies unchanged (pre-conformance fallback).
|
||||||
|
caster = _make_caster()
|
||||||
|
enemy = _make_enemy()
|
||||||
|
c = CombatantState(combatant=caster, side="players", pos=(0, 0), hp=caster.hp_max)
|
||||||
|
e = CombatantState(combatant=enemy, side="monsters", pos=(5, 0), hp=enemy.hp_max)
|
||||||
|
legend = {".": TerrainType(type="floor", move_cost=1)}
|
||||||
|
spec = MapSpec(name="test", terrain=tuple(["." * 12] * 12), legend=legend)
|
||||||
|
reg = {"Summon Monster II": _make_summon_spell(duration_rounds=3)}
|
||||||
|
engine = CombatEngine(
|
||||||
|
SeededRng(1), Grid.from_spec(spec), [c, e], spell_registry=reg
|
||||||
|
)
|
||||||
|
engine._cast_spell(c, e, "Summon Monster II")
|
||||||
|
wolf = next(s for s in engine._states if s.combatant.id == "celestial_wolf")
|
||||||
|
assert wolf.summon_duration == 3
|
||||||
|
|
||||||
|
def test_shipped_summon_spells_declare_per_level_duration(self) -> None:
|
||||||
|
# Given: the shipped spell data
|
||||||
|
# When: Summon Monster I/II are loaded
|
||||||
|
# Then: they carry the SRD 1 round/level duration (no fixed value).
|
||||||
|
for name in ("summon_monster_i.json", "summon_monster_ii.json"):
|
||||||
|
spell = load_spell(SPELLS_DIR / name)
|
||||||
|
effect = spell.effects[0]
|
||||||
|
assert isinstance(effect, SummonEffect)
|
||||||
|
assert effect.duration_rounds_per_level == 1
|
||||||
|
|||||||
Reference in New Issue
Block a user