diff --git a/README.md b/README.md index 1ed3c93..3b3f728 100644 --- a/README.md +++ b/README.md @@ -1235,7 +1235,14 @@ Règles modélisé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 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 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 diff --git a/data/spells/summon_monster_i.json b/data/spells/summon_monster_i.json index 2c6cda0..b338c76 100644 --- a/data/spells/summon_monster_i.json +++ b/data/spells/summon_monster_i.json @@ -11,8 +11,9 @@ { "type": "summon", "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." } diff --git a/data/spells/summon_monster_ii.json b/data/spells/summon_monster_ii.json index aef9f6c..a0d3b96 100644 --- a/data/spells/summon_monster_ii.json +++ b/data/spells/summon_monster_ii.json @@ -11,8 +11,9 @@ { "type": "summon", "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." } diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index da265a7..1adffce 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -7220,6 +7220,15 @@ class CombatEngine: _spell_name: str, ) -> str: """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 try: summoned_combatant = load_combatant(Path(effect.creature)) @@ -7256,7 +7265,7 @@ class CombatEngine: self._states.insert(caster_idx + 1, summoned_state) # Set expiration - summoned_state.summon_duration = effect.duration_rounds + summoned_state.summon_duration = duration summoned_state.summon_caster_id = state.combatant.id self._apply_simple_monster_death(summoned_state) @@ -7265,7 +7274,7 @@ class CombatEngine: self._log( 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}" diff --git a/src/pf1e_simulator/spells.py b/src/pf1e_simulator/spells.py index bbb93f3..1ed6218 100644 --- a/src/pf1e_simulator/spells.py +++ b/src/pf1e_simulator/spells.py @@ -296,10 +296,17 @@ class SummonEffect: """Summoning spell effect. - 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 duration_rounds: int + duration_rounds_per_level: int = 0 SpellEffect = ( @@ -690,6 +697,9 @@ def _parse_summon_effect(effect: dict[str, Json]) -> SummonEffect: return SummonEffect( creature=_as_str(effect["creature"]), duration_rounds=_as_int(effect["duration_rounds"]), + duration_rounds_per_level=_as_int( + effect.get("duration_rounds_per_level", 0) + ), ) diff --git a/tests/test_summoning.py b/tests/test_summoning.py index 7c15c98..c77ccac 100644 --- a/tests/test_summoning.py +++ b/tests/test_summoning.py @@ -20,7 +20,7 @@ from pf1e_simulator.models import ( Saves, ) 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: @@ -260,3 +260,78 @@ class TestSummonedCreatureExpires: assert not wolf_states[0].active, ( "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