diff --git a/README.md b/README.md index 87bd84d..1ed3c93 100644 --- a/README.md +++ b/README.md @@ -1003,9 +1003,21 @@ Règles modélisées : différent) exige une attaque de contact de mêlée — d20 + BAB + mod de Force du lanceur contre la CA de contact de la cible (amputée du modificateur de Dextérité si la cible perd son bonus de Dextérité, même chemin que - `resolve_attack`) ; en cas d'échec le sort n'a aucun effet (l'emplacement - est brûlé). Simplifications documentées : Weapon Finesse et les autres - bonus situationnels de jet de contact ne sont pas modélisés. Une cible + `resolve_attack`) ; en cas d'échec le sort **n'est pas dépensé** : le + lanceur **garde la charge** (SRD « Holding the Charge » : « If the attack + misses, you are still holding the charge [...] You can continue to make + touch attacks round after round ») — l'état `held_charge` du lanceur + (sort, cible, métamagie, niveau d'emplacement) est rejoué automatiquement + au **début de chacun de ses tours suivants** tant que la cible vit et + reste à portée de contact ; une nouvelle incantation **dissipe** la charge + (« If you cast another spell, the touch spell dissipates ») et une cible + morte la dissipe aussi (journalisé `dissipates`). L'emplacement reste + brûlé (dépensé à l'incantation). Simplifications documentées : Weapon + Finesse et les autres bonus situationnels de jet de contact ne sont pas + modélisés ; la re-tentative est pilotée par le moteur au début du tour + (coût RAW : action standard) ; la décharge sur tout contact (même + involontaire) et les attaques à l'arme tenue pendant la charge ne sont pas + modélisés ; la charge n'est pas exposée dans l'Observation. Une cible volontaire (soi ou un allié du même camp) ne demande aucun jet. Sorts à contact à distance (CRB « Magic — Ranged Touch Attacks ») : un sort dont le JSON déclare `requires_ranged_touch: true` (les rayons du SRD — Ray of diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index f148857..da265a7 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -686,6 +686,27 @@ class PendingCast: hp_at_cast: int +@dataclass(frozen=True) +class HeldCharge: + """A touch spell whose melee touch attack missed - the charge is held. + + PF1e (SRD "Cast a Spell - Touch Attacks", Holding the Charge): "If you + don't discharge the spell in the round when you cast the spell, you can + hold the charge indefinitely. You can continue to make touch attacks + round after round." A missed melee touch therefore does NOT expend the + spell (the slot was spent at the cast; the spell itself survives to be + re-attempted). Casting another spell dissipates it ("If you cast another + spell, the touch spell dissipates"). Ranged touch attacks are NOT held: + SRD "Ranged Touch Spells in Combat" - "ranged touch attacks cannot be + held until a later turn". + """ + + spell_name: str + target_id: str + metamagic: tuple[str, ...] + slot_level: int + + @dataclass class CombatantState: """Mutable runtime state of one combatant on the grid.""" @@ -780,6 +801,12 @@ class CombatantState: # model): the spell begun on this combatant's turn, resolved just before # its NEXT turn begins; None when no casting is in progress. pending_cast: PendingCast | None = None + # PF1e held touch charge (SRD "Cast a Spell - Touch Attacks", Holding the + # Charge, not in the frozen data model): a touch spell whose melee touch + # attack missed; re-attempted at the start of each later turn of this + # combatant while the target lives and stands in reach, dissipated when + # this combatant casts another spell. None when no charge is held. + held_charge: HeldCharge | None = None def usable_attacks(self) -> list[AttackSpec]: return [ @@ -1748,6 +1775,49 @@ class CombatEngine: metamagic=pending.metamagic, slot_level=pending.slot_level, ) + def _resolve_held_charge(self, caster: CombatantState) -> None: + """PF1e (SRD "Cast a Spell - Touch Attacks", Holding the Charge): a + missed melee touch attack leaves the charge held - "You can continue + to make touch attacks round after round." Re-attempt it at the start + of each of the caster's later turns while the held target is alive + and within the spell's touch range; a miss re-sets the charge, a hit + delivers through the normal gates, and an SR suppression consumes it + (the spell discharges into the resistance). + + Documented simplifications: the re-attempt is engine-driven at turn + start instead of costing a standard action; discharge-on-any-touch + and unarmed-attack interactions while holding are not modeled; the + charge is not exposed in the Observation.""" + held = caster.held_charge + if held is None: + return + spell = self._spell_registry.get(held.spell_name) + if spell is None: + caster.held_charge = None + return + target = next( + (s for s in self._states if s.combatant.id == held.target_id), None + ) + if target is None or not target.active: + caster.held_charge = None + self._log( + f"round {self._current_round} {caster.combatant.id}: " + f"held charge of {held.spell_name} dissipates (target gone)" + ) + return + dist_ft = self.creature_distance(caster, target) * _SQUARE_FT + if dist_ft > spell_range_ft(spell, caster.combatant.level): + return # out of reach: still holding, re-attempt a later turn + caster.held_charge = None # consumed by this attempt; a miss re-sets + self._log( + f"round {self._current_round} {caster.combatant.id}: " + f"re-attempts held charge {held.spell_name}" + ) + self._deliver_cast( + caster, target, spell, held.spell_name, None, + metamagic=held.metamagic, slot_level=held.slot_level, + ) + def _forfeit_pending_cast(self, caster: CombatantState) -> None: """PF1e (SRD "Casting Time"): "you must continue the concentration from the current round to just before your turn in the next round @@ -1785,6 +1855,9 @@ class CombatEngine: # the turn; the value is the battle-over flag. state.last_action_results = [] return gate + # SRD "Holding the Charge": a held touch spell is re-attempted at + # the start of a later turn (before the caster acts). + self._resolve_held_charge(state) self._apply_fear_aura(state) state.moved_this_turn = False state.power_attack = False @@ -5990,6 +6063,15 @@ class CombatEngine: kind="cast_spell", ok=False, reason="no_slot", detail=f"level {used_level}", ) + # PF1e (SRD "Holding the Charge"): "If you cast another spell, the + # touch spell dissipates." + if caster.held_charge is not None: + self._log( + f"round {self._current_round} {caster.combatant.id}: " + f"held charge of {caster.held_charge.spell_name} dissipates " + f"(casting {spell_name})" + ) + caster.held_charge = None # PF1e (CRB, Concentration, B24): grappled/defensive/threatened casts # must clear their interruption gate before the spell is delivered. if self._spell_cast_interrupted( @@ -6047,7 +6129,9 @@ class CombatEngine: """ has_area = any(isinstance(e, AreaEffect) for e in spell.effects) blocked, delivery_crit = self._spell_delivery_blocked( - caster, target, spell, has_area=has_area, attack_penalty=attack_penalty + caster, target, spell, spell_name, has_area=has_area, + attack_penalty=attack_penalty, + metamagic=metamagic, slot_level=slot_level, ) if blocked: return @@ -6100,17 +6184,25 @@ class CombatEngine: caster: CombatantState, target: CombatantState | None, spell: SpellSpec, + spell_name: str, *, has_area: bool, attack_penalty: int = 0, + metamagic: tuple[str, ...] = (), + slot_level: int | None = None, ) -> tuple[bool, bool]: """Delivery gates: a hostile touch spell must land its melee touch attack (B14) and a hostile ranged touch spell (ray) must land its ranged touch attack (cover, concealment and crits included), then spell resistance must be overcome. Returns ``(blocked, crit)``: - True blocked = no effect (the slot is already spent); ``crit`` is True - when a damage-dealing touch delivery was confirmed on a natural 20 - (the spell's damage dice are then doubled).""" + True blocked = no effect; ``crit`` is True when a damage-dealing + touch delivery was confirmed on a natural 20 (the spell's damage + dice are then doubled). A MISSED melee touch does not expend the + spell - SRD "Holding the Charge": "you can hold the charge + indefinitely. You can continue to make touch attacks round after + round" - so the caster keeps a HeldCharge (the slot stays spent). + A missed ranged touch IS expended (SRD: "ranged touch attacks + cannot be held until a later turn").""" crit = False deals_damage = any(isinstance(e, DamageEffect) for e in spell.effects) # PF1e multi-projectile rays (Scorching Ray): each projectile rolls its @@ -6127,6 +6219,16 @@ class CombatEngine: attack_penalty=attack_penalty, deals_damage=deals_damage, ) if not hit: + caster.held_charge = HeldCharge( + spell_name=spell_name, + target_id=target.combatant.id, + metamagic=metamagic, + slot_level=spell.level if slot_level is None else slot_level, + ) + self._log( + f"round {self._current_round} {caster.combatant.id}: " + f"holds the charge of {spell_name}" + ) return True, False if ( target is not None diff --git a/tests/test_conformance_spellflow.py b/tests/test_conformance_spellflow.py index e0bb69b..426390d 100644 --- a/tests/test_conformance_spellflow.py +++ b/tests/test_conformance_spellflow.py @@ -7,12 +7,14 @@ B7 (CRB, Metamagic Feats): metamagic is chosen PER CAST, costs slot levels and opposed rolls are not affected"); only Heighten Spell raises the DC to the heightened level (SRD heighten-spell). B14 (CRB, Magic, Range: Touch): delivering a touch spell to an unwilling - creature requires a melee touch attack (miss = spell wasted); a willing - target (self/ally) needs no roll. + creature requires a melee touch attack (a miss HOLDS the charge per SRD + "Holding the Charge" - the spell survives to be re-attempted; the slot + is spent at the cast); a willing target (self/ally) needs no roll. Ranged touch (CRB, Magic, Ranged Touch Attacks): a spell with requires_ranged_touch (a ray) must land a ranged touch attack (BAB + Dex - vs touch AC, no range increment) on a hostile target; auto-hit spells - like Magic Missile need no roll. + vs touch AC, no range increment) on a hostile target; a miss expends the + spell (SRD: "ranged touch attacks cannot be held until a later turn"); + auto-hit spells like Magic Missile need no roll. B24 (CRB, Concentration): casting defensively forgoes the AoO but requires a DC 15 + 2 x spell level check; casting while grappled requires DC 10 + grappler's CMB + spell level; Combat Casting's +4 applies to @@ -318,10 +320,14 @@ class TestHostileTouchDelivery: """B14: a touch spell delivered to an unwilling creature requires a melee touch attack (CRB, Magic — Range: Touch).""" - def test_missed_touch_attack_wastes_the_spell(self) -> None: + def test_missed_touch_attack_holds_the_charge(self) -> None: # Given: an enemy with touch AC 10, caster bonus +1 (BAB 1, Str 10) # When: Zap is cast with a touch attack roll of 5 (total 6) - # Then: the spell misses, deals no damage, and the slot is spent. + # Then: the spell misses and deals no damage; the slot stays spent + # but the SPELL survives as a held charge (SRD "Holding the + # Charge": "If the attack misses, you are still holding the + # charge" - "you can continue to make touch attacks round after + # round"). caster = _caster() c, f = _state(caster, "players", (0, 0)), _state(_foe(melee=False), "monsters", (0, 1)) engine = _engine([c, f], [5]) # only the attack roll — no damage roll @@ -329,6 +335,77 @@ class TestHostileTouchDelivery: assert f.hp == 100 assert c.spell_slots_left[1] == 0 assert any("touch attack vs foe" in line and "MISS" in line for line in engine._transcript) + assert c.held_charge is not None + assert c.held_charge.spell_name == "Zap" + assert c.held_charge.target_id == "foe" + assert any("holds the charge of Zap" in line for line in engine._transcript) + + def test_held_charge_re_attempt_hits_and_delivers(self) -> None: + # Given: a caster holding Zap on an adjacent enemy (missed first roll) + # When: the next-turn re-attempt roll 9 + 1 = 10 meets touch AC 10 + # Then: the spell delivers for its 1d6 and the charge is cleared. + caster = _caster() + c, f = _state(caster, "players", (0, 0)), _state(_foe(melee=False), "monsters", (0, 1)) + engine = _engine([c, f], [5, 9, 4]) # miss, hit, 1d6 damage + engine._cast_spell(c, f, "Zap") + assert c.held_charge is not None + engine._resolve_held_charge(c) + assert f.hp == 100 - 4 + assert c.held_charge is None + assert any("re-attempts held charge Zap" in line for line in engine._transcript) + + def test_held_charge_re_attempt_miss_still_holds(self) -> None: + # Given: the same held charge + # When: the re-attempt misses too (roll 2 + 1 = 3 < 10) + # Then: the charge persists for a later turn (no damage rolled). + caster = _caster() + c, f = _state(caster, "players", (0, 0)), _state(_foe(melee=False), "monsters", (0, 1)) + engine = _engine([c, f], [5, 2]) + engine._cast_spell(c, f, "Zap") + engine._resolve_held_charge(c) + assert f.hp == 100 + assert c.held_charge is not None + assert c.held_charge.spell_name == "Zap" + + def test_held_charge_out_of_reach_waits_without_rolling(self) -> None: + # Given: the held target has left touch range (5 ft) + # When: the re-attempt runs + # Then: no attack roll is consumed and the charge is kept. + caster = _caster() + c, f = _state(caster, "players", (0, 0)), _state(_foe(melee=False), "monsters", (0, 1)) + engine = _engine([c, f], [5]) + engine._cast_spell(c, f, "Zap") + f.pos = (5, 5) + engine._resolve_held_charge(c) # empty rng queue: would raise on a roll + assert c.held_charge is not None + + def test_held_charge_dissipates_when_target_dies(self) -> None: + # Given: the held charge's target died in the meantime + # When: the re-attempt runs + # Then: the charge is gone, no roll consumed. + caster = _caster() + c, f = _state(caster, "players", (0, 0)), _state(_foe(melee=False), "monsters", (0, 1)) + engine = _engine([c, f], [5]) + engine._cast_spell(c, f, "Zap") + f.hp = -50 + engine._resolve_held_charge(c) + assert c.held_charge is None + assert any("dissipates (target gone)" in line for line in engine._transcript) + + def test_casting_another_spell_dissipates_the_charge(self) -> None: + # Given: a caster holding Zap + # When: the caster casts another spell (SRD: "If you cast another + # spell, the touch spell dissipates") + # Then: the charge is gone before the new cast resolves. + caster = _caster(spell_slots={1: 2}) + c, f = _state(caster, "players", (0, 0)), _state(_foe(melee=False), "monsters", (0, 1)) + engine = _engine([c, f], [5, 3, 3]) # touch miss, then Bolt 2d4 + engine._cast_spell(c, f, "Zap") + assert c.held_charge is not None + engine._cast_spell(c, f, "Bolt") + assert c.held_charge is None + assert any("dissipates (casting Bolt)" in line for line in engine._transcript) + assert f.hp == 100 - 6 def test_successful_touch_attack_delivers_the_spell(self) -> None: # Given: the same enemy @@ -388,13 +465,16 @@ class TestRangedTouchDelivery: def test_missed_ranged_touch_attack_wastes_the_spell(self) -> None: # Given: an enemy with touch AC 10, caster bonus +1 (BAB 1, Dex 10) # When: Ray is cast with an attack roll of 5 (total 6) - # Then: the spell misses, deals no damage, and the slot is spent. + # Then: the spell misses, deals no damage, and the slot is spent - + # and the spell is NOT held (SRD: "ranged touch attacks cannot be + # held until a later turn"). c = _state(_caster(), "players", (0, 0)) f = _state(_foe(melee=False), "monsters", (0, 1)) engine = _engine([c, f], [5]) # only the attack roll — no damage roll engine._cast_spell(c, f, "Ray") assert f.hp == 100 assert c.spell_slots_left[1] == 0 + assert c.held_charge is None assert any( "ranged touch attack vs foe" in line and "MISS" in line for line in engine._transcript