fix(rules): set the slumber hex duration to the witch level

PF1e (APG Slumber): the target "remains asleep for a number of rounds equal to the witch's level" (Will negates). _hex_slumber hardcoded 1 round; it now uses the caster's level. The RAW clause that the hex ends when the target takes damage is not modeled (no wake-on-damage hook in the engine) — documented in README. Two behavioral tests pin the failed-save duration (= level) and the saved case.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
2026-09-12 12:52:52 +02:00
parent f9c369380d
commit 68699ad6c7
3 changed files with 49 additions and 3 deletions
+4
View File
@@ -1098,6 +1098,10 @@ Règles modélisées :
**une seule option** — l'option choisie est modélisée par les jets d'attaque — **une seule option** — l'option choisie est modélisée par les jets d'attaque —
pendant **3 rounds** ; une sauvegarde de Volonté réussie ne l'annule pas mais pendant **3 rounds** ; une sauvegarde de Volonté réussie ne l'annule pas mais
**réduit la durée à 1 round** (« A Will save reduces this to just 1 round ») ; **réduit la durée à 1 round** (« A Will save reduces this to just 1 round ») ;
`slumber` (APG, conformance D4) endort la cible pour un **nombre de rounds
égal au niveau de la sorcière** (Volonté annule ; simplification documentée :
la fin RAW du sortilège quand la cible subit des dégâts n'est pas modélisée,
le moteur n'a pas de crochet de réveil) ;
`cackle` prolonge d'un round les sortilèges déjà `cackle` prolonge d'un round les sortilèges déjà
actifs, sur une liste instantanée pour ne pas s'étendre à l'infini. Les sorts à actifs, sur une liste instantanée pour ne pas s'étendre à l'infini. Les sorts à
zone (AoE) utilisent le DD d'incantation du jeteur et chaque créature de la zone (AoE) utilisent le DD d'incantation du jeteur et chaque créature de la
+7 -3
View File
@@ -4783,7 +4783,10 @@ class CombatEngine:
def _hex_slumber( def _hex_slumber(
self, state: CombatantState, target: CombatantState, hex_dc: int self, state: CombatantState, target: CombatantState, hex_dc: int
) -> None: ) -> None:
"""Slumber: asleep/disabled if Will fails.""" """Slumber (APG): the target remains asleep for a number of rounds
equal to the witch's level; a successful Will save negates.
Simplification documented in README: the RAW ending of the hex when
the target takes damage is not modeled (no wake-on-damage hook)."""
save_result = self.resolve_save(target, "will", hex_dc) save_result = self.resolve_save(target, "will", hex_dc)
if save_result.success: if save_result.success:
self._log( self._log(
@@ -4791,16 +4794,17 @@ class CombatEngine:
f"{target.combatant.id} -> SAVED" f"{target.combatant.id} -> SAVED"
) )
return return
rounds = state.combatant.level
target.active_conditions.append( target.active_conditions.append(
ActiveCondition( ActiveCondition(
condition=CONDITIONS_BY_NAME["asleep"], condition=CONDITIONS_BY_NAME["asleep"],
rounds_left=1, rounds_left=rounds,
source=f"hex: slumber by {state.combatant.id}", source=f"hex: slumber by {state.combatant.id}",
) )
) )
self._log( self._log(
f"round {self._current_round} {state.combatant.id}: slumber on " f"round {self._current_round} {state.combatant.id}: slumber on "
f"{target.combatant.id} -> asleep (1r)" f"{target.combatant.id} -> asleep ({rounds}r)"
) )
def _hex_cackle(self, state: CombatantState, target: CombatantState) -> None: def _hex_cackle(self, state: CombatantState, target: CombatantState) -> None:
+38
View File
@@ -294,6 +294,44 @@ def test_evil_eye_saved_deals_minus_two_attack_one_round() -> None:
assert hex_buffs[0].modifiers == (StatModifier(target="attack", value=-2),) assert hex_buffs[0].modifiers == (StatModifier(target="attack", value=-2),)
def test_slumber_failed_save_puts_target_asleep_for_witch_level() -> None:
"""APG Slumber: the target "remains asleep for a number of rounds equal to
the witch's level". Given: a level-5 witch and a target failing the Will
save (d20 2 +4 < DC 14)
When: the hex is cast
Then: the asleep condition carries rounds_left = 5."""
nairda = _combatant("nairda", resources={"hexes": 5})
goblin = _combatant("goblin", hp_max=6)
engine, me, enemy = _engine(nairda, [goblin], rng_queue=[2])
engine._execute(
me, Action(kind="hex", target_id=enemy[0].combatant.id, hex_name="slumber")
)
asleep = [
ac for ac in enemy[0].active_conditions if ac.condition.name == "asleep"
]
assert len(asleep) == 1
assert asleep[0].rounds_left == 5
def test_slumber_saved_leaves_target_awake() -> None:
"""APG Slumber: Will negates. Given: a target saving (natural 20)
When: the hex is cast
Then: no asleep condition is applied."""
nairda = _combatant("nairda", resources={"hexes": 5})
goblin = _combatant("goblin", hp_max=6)
engine, me, enemy = _engine(nairda, [goblin], rng_queue=[20])
engine._execute(
me, Action(kind="hex", target_id=enemy[0].combatant.id, hex_name="slumber")
)
assert not any(
ac.condition.name == "asleep" for ac in enemy[0].active_conditions
)
# --- tammara: deeds --- # --- tammara: deeds ---