fix(rules): apply cover concealment and criticals to touch attacks
CRB 'Cast a Spell - Touch Attacks': 'You can score critical hits with either type of attack as long as the spell deals damage'; CRB 'Range - Touch': a touch spell threatens on a natural 20 and deals double damage on a successful confirmation. The melee and ranged spell-touch gates now add the target's cover bonus to touch AC (new _attack_cover_bonus helper shared with resolve_attack), roll the concealment miss chance after the hit (blinded attacker = total concealment convention), and resolve crit threat/confirm with damage doubling for damage-dealing touch spells. Non-damage touch spells do not threaten (no die consumed). Golden transcript unaffected. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -844,7 +844,19 @@ Règles modélisées :
|
||||
n'y a donc **pas** de pénalité par incrément de portée ; en cas d'échec le
|
||||
sort n'a aucun effet (l'emplacement est brûlé). Simplifications documentées
|
||||
(miroir du contact de mêlée) : le modificateur de taille et Weapon Finesse
|
||||
ne sont pas modélisés. Magic Missile (touche automatiquement), les sorts de
|
||||
ne sont pas modélisés. Conformance L3 (CRB « Cast a Spell — Touch Attacks »,
|
||||
« Cover », « Concealment », « Range — Touch ») : le jet de contact (mêlée ou
|
||||
distance) est une **attaque** — la CA de contact inclut le **couvert** de la
|
||||
cible (terrain +2/+4/+8 et couvert mou +4 à distance, même chemin
|
||||
`_attack_cover_bonus` que `resolve_attack` ; mêlée = ligne terrain seule), la
|
||||
**dissimulation** peut faire rater l'attaque après la touche (jet de d100,
|
||||
convention `max(concealment de la cible, 50)` si l'attaquant est `blinded`,
|
||||
journalisé « spell lost » / « spell delivered »), et un sort de contact qui
|
||||
**inflige des dégâts** menace le critique sur un 20 naturel et **double ses
|
||||
dégâts** sur confirmation réussie (le jet de confirmation est un jet
|
||||
d'attaque contre la même CA ; un sort de contact sans dégâts — Grace,
|
||||
Ray of Enfeeblement — ne menace pas, aucun dé consommé). Magic Missile
|
||||
(touche automatiquement), les sorts de
|
||||
zone et les sorts à sauvegarde simple ne sont pas concernés. Un sort ciblé avec sauvegarde fait pleins dégâts
|
||||
sur un échec et **aucun dégât sur un succès** (la sauvegarde annule) ; le
|
||||
demi-dégât sur sauvegarde réussie ne s'applique que là où l'effet le déclare
|
||||
|
||||
+155
-58
@@ -2436,18 +2436,8 @@ class CombatEngine:
|
||||
ac = base_ac + resolve_modifiers(ac_mods)
|
||||
ac += target.defensive_ac_bonus
|
||||
ac += target.aid_ac_bonus
|
||||
occupied = frozenset(
|
||||
cell
|
||||
for s in self._states
|
||||
if s.active and s is not attacker and s is not target
|
||||
for cell in s.occupied_cells()
|
||||
)
|
||||
ac += cover_bonus_cells(
|
||||
self._grid,
|
||||
attacker.occupied_cells(),
|
||||
target.occupied_cells(),
|
||||
ranged=self._is_ranged_attack(weapon),
|
||||
occupied=occupied,
|
||||
ac += self._attack_cover_bonus(
|
||||
attacker, target, ranged=self._is_ranged_attack(weapon)
|
||||
)
|
||||
hit = roll == _NATURAL_TWENTY or (roll != _NATURAL_ONE and total >= ac)
|
||||
damage = 0
|
||||
@@ -2595,6 +2585,25 @@ class CombatEngine:
|
||||
damage, weapon, rolls, target=target
|
||||
)
|
||||
|
||||
def _attack_cover_bonus(
|
||||
self, attacker: CombatantState, target: CombatantState, *, ranged: bool
|
||||
) -> int:
|
||||
"""Cover the target has against one attack from ``attacker`` (terrain +
|
||||
soft cover from interposed creatures on ranged lines only)."""
|
||||
occupied = frozenset(
|
||||
cell
|
||||
for s in self._states
|
||||
if s.active and s is not attacker and s is not target
|
||||
for cell in s.occupied_cells()
|
||||
)
|
||||
return cover_bonus_cells(
|
||||
self._grid,
|
||||
attacker.occupied_cells(),
|
||||
target.occupied_cells(),
|
||||
ranged=ranged,
|
||||
occupied=occupied,
|
||||
)
|
||||
|
||||
def _concealment_chance(self, target: CombatantState) -> int:
|
||||
"""Highest miss chance (%) among the target's active concealment conditions."""
|
||||
chances = [ac.condition.miss_chance for ac in target.active_conditions]
|
||||
@@ -5202,14 +5211,16 @@ class CombatEngine:
|
||||
):
|
||||
return None
|
||||
# PF1e (CRB): hostile touch delivery needs a melee touch attack (B14)
|
||||
# and spell resistance can still turn the spell away.
|
||||
if self._spell_delivery_blocked(
|
||||
# and spell resistance can still turn the spell away. A confirmed
|
||||
# touch crit doubles the spell's damage (CRB "Range — Touch").
|
||||
blocked, delivery_crit = self._spell_delivery_blocked(
|
||||
caster, target, spell, has_area=has_area, attack_penalty=attack_penalty
|
||||
):
|
||||
)
|
||||
if blocked:
|
||||
return None
|
||||
self._apply_and_log_cast(
|
||||
caster, target, spell, spell_name, center_pos,
|
||||
metamagic=applied, slot_level=used_level,
|
||||
metamagic=applied, slot_level=used_level, spell_crit=delivery_crit,
|
||||
)
|
||||
return None
|
||||
|
||||
@@ -5247,36 +5258,47 @@ class CombatEngine:
|
||||
*,
|
||||
has_area: bool,
|
||||
attack_penalty: int = 0,
|
||||
) -> bool:
|
||||
) -> 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, then spell resistance must be overcome. True =
|
||||
no effect (the slot is already spent). ``attack_penalty`` applies to
|
||||
both touch rolls (Spell Combat)."""
|
||||
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)."""
|
||||
crit = False
|
||||
deals_damage = any(isinstance(e, DamageEffect) for e in spell.effects)
|
||||
if (
|
||||
target is not None
|
||||
and spell.range == "touch"
|
||||
and target.side != caster.side
|
||||
and not self._spell_touch_attack_hits(
|
||||
caster, target, attack_penalty=attack_penalty
|
||||
)
|
||||
):
|
||||
return True
|
||||
hit, crit = self._spell_touch_attack_hits(
|
||||
caster, target,
|
||||
attack_penalty=attack_penalty, deals_damage=deals_damage,
|
||||
)
|
||||
if not hit:
|
||||
return True, False
|
||||
if (
|
||||
target is not None
|
||||
and spell.requires_ranged_touch
|
||||
and not has_area
|
||||
and target.side != caster.side
|
||||
and not self._spell_ranged_touch_attack_hits(
|
||||
caster, target, attack_penalty=attack_penalty
|
||||
)
|
||||
):
|
||||
return True
|
||||
return (
|
||||
hit, ranged_crit = self._spell_ranged_touch_attack_hits(
|
||||
caster, target,
|
||||
attack_penalty=attack_penalty, deals_damage=deals_damage,
|
||||
)
|
||||
if not hit:
|
||||
return True, False
|
||||
crit = ranged_crit
|
||||
if (
|
||||
target is not None
|
||||
and not has_area
|
||||
and not self._check_spell_resistance(caster, target)
|
||||
)
|
||||
):
|
||||
return True, False
|
||||
return False, crit
|
||||
|
||||
def _spell_touch_attack_hits(
|
||||
self,
|
||||
@@ -5284,17 +5306,25 @@ class CombatEngine:
|
||||
target: CombatantState,
|
||||
*,
|
||||
attack_penalty: int = 0,
|
||||
) -> bool:
|
||||
deals_damage: bool = False,
|
||||
) -> tuple[bool, bool]:
|
||||
"""Melee touch attack to deliver a touch-range spell (B14).
|
||||
|
||||
PF1e (CRB): d20 + caster's melee attack bonus vs the target's touch
|
||||
AC. ``attack_penalty`` folds into the bonus (Spell Combat: "any
|
||||
attack roll made as part of this spell also takes this penalty").
|
||||
Engine simplification (documented): the bonus is BAB + Strength
|
||||
modifier — Weapon Finesse and other situational touch bonuses are
|
||||
not modeled; natural 20 hits and natural 1 misses per the engine's
|
||||
attack-roll convention. The touch AC reuses the resolve_attack
|
||||
denies-Dex path (flat-footed/running target, invisible attacker).
|
||||
AC, PLUS the target's cover bonus (a touch attack is an attack roll —
|
||||
CRB "Cover") and subject to the target's concealment miss chance (CRB
|
||||
"Concealment"); a spell that deals damage threatens on a natural 20
|
||||
and deals double damage on a successful confirmation (CRB "Cast a
|
||||
Spell — Touch Attacks": "You can score critical hits with either type
|
||||
of attack as long as the spell deals damage"; CRB "Range — Touch").
|
||||
``attack_penalty`` folds into the bonus (Spell Combat: "any attack
|
||||
roll made as part of this spell also takes this penalty"). Engine
|
||||
simplification (documented): the bonus is BAB + Strength modifier —
|
||||
Weapon Finesse and other situational touch bonuses are not modeled;
|
||||
natural 20 hits and natural 1 misses per the engine's attack-roll
|
||||
convention. The touch AC reuses the resolve_attack denies-Dex path
|
||||
(flat-footed/running target, invisible attacker).
|
||||
Returns ``(hit, crit)``.
|
||||
"""
|
||||
roll = self._rng.d20()
|
||||
bonus = caster.combatant.bab + caster.combatant.abilities.str_mod + attack_penalty
|
||||
@@ -5307,13 +5337,11 @@ class CombatEngine:
|
||||
ac = target.combatant.ac.touch - max(0, target.combatant.abilities.dex_mod)
|
||||
else:
|
||||
ac = target.combatant.ac.touch
|
||||
hit = roll == _NATURAL_TWENTY or (roll != _NATURAL_ONE and roll + bonus >= ac)
|
||||
self._log(
|
||||
f"round {self._current_round} {caster.combatant.id}: touch attack vs "
|
||||
f"{target.combatant.id} d20={roll}+{bonus}={roll + bonus} AC {ac} "
|
||||
f"-> {'HIT' if hit else 'MISS'}"
|
||||
ac += self._attack_cover_bonus(caster, target, ranged=False)
|
||||
return self._resolve_touch_delivery(
|
||||
caster, target, label="touch attack",
|
||||
roll=roll, bonus=bonus, ac=ac, deals_damage=deals_damage,
|
||||
)
|
||||
return hit
|
||||
|
||||
def _spell_ranged_touch_attack_hits(
|
||||
self,
|
||||
@@ -5321,19 +5349,24 @@ class CombatEngine:
|
||||
target: CombatantState,
|
||||
*,
|
||||
attack_penalty: int = 0,
|
||||
) -> bool:
|
||||
deals_damage: bool = False,
|
||||
) -> tuple[bool, bool]:
|
||||
"""Ranged touch attack to deliver a ray spell.
|
||||
|
||||
PF1e (CRB, Magic — Ranged Touch Attacks): d20 + caster's ranged attack
|
||||
bonus vs the target's touch AC; the spell's range is a single absolute
|
||||
value so there is no range-increment penalty. ``attack_penalty`` folds
|
||||
into the bonus (Spell Combat: "any attack roll made as part of this
|
||||
spell also takes this penalty"). Engine simplification
|
||||
(documented): the bonus is BAB + Dexterity modifier — size modifier,
|
||||
Weapon Finesse and other situational bonuses are not modeled; natural
|
||||
20 hits and natural 1 misses per the engine's attack-roll convention.
|
||||
The touch AC reuses the resolve_attack denies-Dex path (flat-footed/
|
||||
running target, invisible attacker).
|
||||
bonus vs the target's touch AC, PLUS the target's ranged cover bonus
|
||||
(terrain + soft cover, CRB "Cover") and subject to the concealment
|
||||
miss chance (CRB "Concealment"); a ray that deals damage can score a
|
||||
critical hit (CRB "Cast a Spell — Touch Attacks"). The spell's range
|
||||
is a single absolute value so there is no range-increment penalty.
|
||||
``attack_penalty`` folds into the bonus (Spell Combat: "any attack
|
||||
roll made as part of this spell also takes this penalty"). Engine
|
||||
simplification (documented): the bonus is BAB + Dexterity modifier —
|
||||
size modifier, Weapon Finesse and other situational bonuses are not
|
||||
modeled; natural 20 hits and natural 1 misses per the engine's
|
||||
attack-roll convention. The touch AC reuses the resolve_attack
|
||||
denies-Dex path (flat-footed/running target, invisible attacker).
|
||||
Returns ``(hit, crit)``.
|
||||
"""
|
||||
roll = self._rng.d20()
|
||||
bonus = caster.combatant.bab + caster.combatant.abilities.dex_mod + attack_penalty
|
||||
@@ -5346,13 +5379,62 @@ class CombatEngine:
|
||||
ac = target.combatant.ac.touch - max(0, target.combatant.abilities.dex_mod)
|
||||
else:
|
||||
ac = target.combatant.ac.touch
|
||||
ac += self._attack_cover_bonus(caster, target, ranged=True)
|
||||
return self._resolve_touch_delivery(
|
||||
caster, target, label="ranged touch attack",
|
||||
roll=roll, bonus=bonus, ac=ac, deals_damage=deals_damage,
|
||||
)
|
||||
|
||||
def _resolve_touch_delivery(
|
||||
self,
|
||||
caster: CombatantState,
|
||||
target: CombatantState,
|
||||
*,
|
||||
label: str,
|
||||
roll: int,
|
||||
bonus: int,
|
||||
ac: int,
|
||||
deals_damage: bool,
|
||||
) -> tuple[bool, bool]:
|
||||
"""Finish a touch-attack roll: hit check, crit threat/confirm (only
|
||||
when the spell deals damage, CRB "Cast a Spell — Touch Attacks"), then
|
||||
the concealment miss chance (a blinded caster faces total concealment,
|
||||
same max(target, 50) convention as _resolve_hit_damage)."""
|
||||
hit = roll == _NATURAL_TWENTY or (roll != _NATURAL_ONE and roll + bonus >= ac)
|
||||
self._log(
|
||||
f"round {self._current_round} {caster.combatant.id}: ranged touch attack vs "
|
||||
f"round {self._current_round} {caster.combatant.id}: {label} vs "
|
||||
f"{target.combatant.id} d20={roll}+{bonus}={roll + bonus} AC {ac} "
|
||||
f"-> {'HIT' if hit else 'MISS'}"
|
||||
)
|
||||
return hit
|
||||
if not hit:
|
||||
return False, False
|
||||
crit = False
|
||||
if deals_damage and roll == _NATURAL_TWENTY:
|
||||
confirm = self._rng.d20()
|
||||
crit = confirm == _NATURAL_TWENTY or (
|
||||
confirm != _NATURAL_ONE and confirm + bonus >= ac
|
||||
)
|
||||
self._log(
|
||||
f"round {self._current_round} {caster.combatant.id}: {label} "
|
||||
f"crit confirm d20={confirm}+{bonus}={confirm + bonus} AC {ac} "
|
||||
f"-> {'CRIT' if crit else 'no crit'}"
|
||||
)
|
||||
concealment = self._concealment_chance(target)
|
||||
blinded = self._has_condition_named(caster, "blinded")
|
||||
miss_chance = max(concealment, _TOTAL_CONCEALMENT_PCT) if blinded else concealment
|
||||
if miss_chance > 0:
|
||||
pct = self._rng.d100()
|
||||
missed = pct <= miss_chance
|
||||
source = "attacker blinded" if blinded else "concealment"
|
||||
self._log(
|
||||
f"round {self._current_round} {caster.combatant.id}: "
|
||||
f"{source} vs {target.combatant.id}: "
|
||||
f"d100({pct}) {'<=' if missed else '>'} {miss_chance}% "
|
||||
f"— {'spell lost' if missed else 'spell delivered'}"
|
||||
)
|
||||
if missed:
|
||||
return False, False
|
||||
return True, crit
|
||||
|
||||
def _spell_cast_rejected(
|
||||
self,
|
||||
@@ -5425,13 +5507,16 @@ class CombatEngine:
|
||||
*,
|
||||
metamagic: tuple[str, ...] = (),
|
||||
slot_level: int | None = None,
|
||||
spell_crit: bool = False,
|
||||
) -> None:
|
||||
"""Resolve save, apply effects and log a spell cast (post-gates).
|
||||
|
||||
``metamagic`` holds the feats chosen for THIS cast (already filtered
|
||||
to known ones, B7); ``slot_level`` is the spell's level in the slot
|
||||
used — the save DC is computed from it (PF1e CRB: a metamagic spell
|
||||
uses the DC of the higher slot level).
|
||||
uses the DC of the higher slot level). ``spell_crit`` is a confirmed
|
||||
touch-delivery critical: the spell's damage dice are doubled (CRB
|
||||
"Range — Touch": "deals double damage on a successful critical hit").
|
||||
"""
|
||||
dc = self._spell_dc(caster, spell, slot_level=slot_level)
|
||||
save_result = (
|
||||
@@ -5454,6 +5539,7 @@ class CombatEngine:
|
||||
empower="Empower Spell" in metamagic,
|
||||
maximize="Maximize Spell" in metamagic,
|
||||
dc=dc, center_pos=center_pos, save_type=spell.save,
|
||||
crit=spell_crit,
|
||||
)
|
||||
save_str = _save_label(save_result)
|
||||
if target is None and center_pos is not None:
|
||||
@@ -5509,6 +5595,7 @@ class CombatEngine:
|
||||
dc: int = 15,
|
||||
center_pos: tuple[int, int] | None = None,
|
||||
save_type: SaveType = "none",
|
||||
crit: bool = False,
|
||||
) -> list[str]:
|
||||
saved = save_result is not None and save_result.success
|
||||
return [
|
||||
@@ -5517,6 +5604,7 @@ class CombatEngine:
|
||||
caster=caster, spell_name=spell_name,
|
||||
empower=empower, maximize=maximize, saved=saved,
|
||||
dc=dc, center_pos=center_pos, save_type=save_type,
|
||||
crit=crit,
|
||||
)
|
||||
for effect in effects
|
||||
]
|
||||
@@ -5536,6 +5624,7 @@ class CombatEngine:
|
||||
dc: int = 15,
|
||||
center_pos: tuple[int, int] | None = None,
|
||||
save_type: SaveType = "none",
|
||||
crit: bool = False,
|
||||
) -> str:
|
||||
if isinstance(effect, AreaEffect):
|
||||
return self._apply_area_effect(
|
||||
@@ -5551,7 +5640,7 @@ class CombatEngine:
|
||||
return self._apply_targeted_spell_effect(
|
||||
effect, target, caster_live, target_live,
|
||||
caster=caster, saved=saved, empower=empower, maximize=maximize,
|
||||
spell_name=spell_name,
|
||||
spell_name=spell_name, crit=crit,
|
||||
)
|
||||
|
||||
def _apply_targeted_spell_effect(
|
||||
@@ -5566,11 +5655,13 @@ class CombatEngine:
|
||||
empower: bool,
|
||||
maximize: bool,
|
||||
spell_name: str,
|
||||
crit: bool = False,
|
||||
) -> str:
|
||||
if isinstance(effect, DamageEffect):
|
||||
return self._apply_damage_effect(
|
||||
effect, target, caster_live, target_live,
|
||||
saved=saved, empower=empower, maximize=maximize, caster=caster,
|
||||
crit=crit,
|
||||
)
|
||||
if isinstance(effect, ConditionEffect):
|
||||
return self._apply_condition_effect(effect, target, saved=saved, spell_name=spell_name)
|
||||
@@ -5589,10 +5680,16 @@ class CombatEngine:
|
||||
empower: bool,
|
||||
maximize: bool,
|
||||
caster: CombatantState,
|
||||
crit: bool = False,
|
||||
) -> str:
|
||||
raw = effect.formula.roll(self._rng)
|
||||
if maximize:
|
||||
raw = effect.formula.max_value()
|
||||
if crit:
|
||||
# PF1e (CRB "Range — Touch"): a touch/ranged-touch spell that
|
||||
# deals damage threatens on a natural 20 and deals double damage
|
||||
# on a successful critical hit.
|
||||
raw *= 2
|
||||
if saved and effect.half_on_save:
|
||||
dmg = max(0, raw // 2)
|
||||
elif saved and effect.save_negates:
|
||||
|
||||
@@ -393,6 +393,161 @@ class TestRangedTouchDelivery:
|
||||
assert ally.hp == 100 - 4
|
||||
assert not any("ranged touch attack" in line for line in engine._transcript)
|
||||
|
||||
|
||||
class TestTouchDeliveryDefense:
|
||||
"""L3 conformance: a touch/ranged-touch attack is an attack roll, so the
|
||||
target keeps its cover bonus (CRB "Cover"), concealment can make it miss
|
||||
(CRB "Concealment"), and a damage-dealing touch spell threatens on a
|
||||
natural 20 and deals double damage on confirmation (CRB "Cast a Spell —
|
||||
Touch Attacks", "Range — Touch")."""
|
||||
|
||||
def _wall_engine(
|
||||
self, queue: list[int]
|
||||
) -> tuple[CombatEngine, CombatantState, CombatantState]:
|
||||
legend = {
|
||||
".": TerrainType(type="floor", move_cost=1),
|
||||
"#": TerrainType(type="wall", move_cost=None, blocks_los=True),
|
||||
"W": TerrainType(type="ruin", move_cost=None, cover_bonus=2),
|
||||
}
|
||||
grid = Grid.from_spec(
|
||||
MapSpec(
|
||||
name="cover",
|
||||
terrain=("..W..", "..W..", ".#...", "....."),
|
||||
legend=legend,
|
||||
)
|
||||
)
|
||||
c = _state(_caster(), "players", (0, 0))
|
||||
f = _state(_foe(melee=False), "monsters", (1, 4))
|
||||
return (
|
||||
CombatEngine(ScriptedRng(queue), grid, [c, f], spell_registry=_REGISTRY),
|
||||
c,
|
||||
f,
|
||||
)
|
||||
|
||||
def test_ranged_touch_terrain_cover_raises_touch_ac(self) -> None:
|
||||
# Given: ruins grazing the firing line (+2 partial cover, LoE intact)
|
||||
# When: Ray is cast with roll 10 (total 11)
|
||||
# Then: the attack is resolved against touch AC 10 + 2 = 12 and misses.
|
||||
engine, c, f = self._wall_engine([10])
|
||||
engine._cast_spell(c, f, "Ray")
|
||||
assert f.hp == 100
|
||||
assert any(
|
||||
"ranged touch attack vs foe" in line and "AC 12" in line
|
||||
for line in engine._transcript
|
||||
)
|
||||
|
||||
def test_ranged_touch_soft_cover_from_interposed_ally(self) -> None:
|
||||
# Given: an ally standing between the caster and the target
|
||||
# When: Ray is cast with roll 5 (total 6)
|
||||
# Then: the target gains soft cover +4 — resolved vs AC 14, miss.
|
||||
c = _state(_caster(), "players", (0, 0))
|
||||
ally = _state(_foe(), "players", (0, 1))
|
||||
f = _state(_foe(melee=False), "monsters", (0, 2))
|
||||
engine = _engine([c, ally, f], [5])
|
||||
engine._cast_spell(c, f, "Ray")
|
||||
assert f.hp == 100
|
||||
assert any(
|
||||
"ranged touch attack vs foe" in line and "AC 14" in line
|
||||
for line in engine._transcript
|
||||
)
|
||||
|
||||
def test_concealment_makes_a_landed_touch_spell_miss(self) -> None:
|
||||
# Given: a blurred target (20% concealment); the touch roll hits
|
||||
# When: the concealment d100 comes up under the miss chance
|
||||
# Then: the spell is lost (slot burned, no effect).
|
||||
c = _state(_caster(), "players", (0, 0))
|
||||
f = _state(_foe(melee=False), "monsters", (0, 1))
|
||||
f.active_conditions.append(
|
||||
ActiveCondition(
|
||||
condition=CONDITIONS_BY_NAME["blurred"],
|
||||
rounds_left=5,
|
||||
source="test",
|
||||
)
|
||||
)
|
||||
engine = _engine([c, f], [15, 10])
|
||||
engine._cast_spell(c, f, "Zap")
|
||||
assert f.hp == 100
|
||||
assert c.spell_slots_left[1] == 0
|
||||
assert any(
|
||||
"concealment vs foe" in line and "spell lost" in line
|
||||
for line in engine._transcript
|
||||
)
|
||||
|
||||
def test_concealment_high_d100_delivers_the_touch_spell(self) -> None:
|
||||
# Given: the same blurred target, hit roll and a high concealment die
|
||||
# When: Zap resolves
|
||||
# Then: the spell lands for its normal 1d6 (4).
|
||||
c = _state(_caster(), "players", (0, 0))
|
||||
f = _state(_foe(melee=False), "monsters", (0, 1))
|
||||
f.active_conditions.append(
|
||||
ActiveCondition(
|
||||
condition=CONDITIONS_BY_NAME["blurred"],
|
||||
rounds_left=5,
|
||||
source="test",
|
||||
)
|
||||
)
|
||||
engine = _engine([c, f], [15, 90, 4])
|
||||
engine._cast_spell(c, f, "Zap")
|
||||
assert f.hp == 100 - 4
|
||||
assert any(
|
||||
"concealment vs foe" in line and "spell delivered" in line
|
||||
for line in engine._transcript
|
||||
)
|
||||
|
||||
def test_touch_nat20_confirmed_crit_doubles_damage(self) -> None:
|
||||
# Given: a damage-dealing touch spell (Zap 1d6), natural 20 threatens
|
||||
# When: the confirm roll (10+1 >= 10) succeeds
|
||||
# Then: the spell deals double damage (4 -> 8).
|
||||
c = _state(_caster(), "players", (0, 0))
|
||||
f = _state(_foe(melee=False), "monsters", (0, 1))
|
||||
engine = _engine([c, f], [20, 10, 4])
|
||||
engine._cast_spell(c, f, "Zap")
|
||||
assert f.hp == 100 - 8
|
||||
assert any(
|
||||
"touch attack crit confirm" in line and "-> CRIT" in line
|
||||
for line in engine._transcript
|
||||
)
|
||||
|
||||
def test_touch_crit_failed_confirmation_deals_normal_damage(self) -> None:
|
||||
# Given: natural 20 threatens
|
||||
# When: the confirm roll is a natural 1 (auto-fail)
|
||||
# Then: the spell still hits for normal damage (4).
|
||||
c = _state(_caster(), "players", (0, 0))
|
||||
f = _state(_foe(melee=False), "monsters", (0, 1))
|
||||
engine = _engine([c, f], [20, 1, 4])
|
||||
engine._cast_spell(c, f, "Zap")
|
||||
assert f.hp == 100 - 4
|
||||
assert any(
|
||||
"touch attack crit confirm" in line and "-> no crit" in line
|
||||
for line in engine._transcript
|
||||
)
|
||||
|
||||
def test_ranged_touch_nat20_crit_doubles_damage(self) -> None:
|
||||
# Given: a damage ray (1d6), natural 20 threatens
|
||||
# When: the confirm succeeds
|
||||
# Then: double damage (4 -> 8).
|
||||
c = _state(_caster(), "players", (0, 0))
|
||||
f = _state(_foe(melee=False), "monsters", (0, 1))
|
||||
engine = _engine([c, f], [20, 10, 4])
|
||||
engine._cast_spell(c, f, "Ray")
|
||||
assert f.hp == 100 - 8
|
||||
assert any(
|
||||
"ranged touch attack crit confirm" in line and "-> CRIT" in line
|
||||
for line in engine._transcript
|
||||
)
|
||||
|
||||
def test_non_damage_touch_spell_does_not_threaten(self) -> None:
|
||||
# Given: a condition-only touch spell (Grace) cast on a natural 20
|
||||
# When: the delivery resolves with only the attack die in the queue
|
||||
# Then: no confirmation roll is consumed (CRB: criticals only for
|
||||
# spells that deal damage) and the condition still lands.
|
||||
c = _state(_caster(), "players", (0, 0))
|
||||
f = _state(_foe(melee=False), "monsters", (0, 1))
|
||||
engine = _engine([c, f], [20])
|
||||
engine._cast_spell(c, f, "Grace")
|
||||
assert any("dazzled" in line for line in engine._transcript)
|
||||
assert not any("crit confirm" in line for line in engine._transcript)
|
||||
|
||||
def test_magic_missile_still_auto_hits(self) -> None:
|
||||
# Given: the real Magic Missile spell (no ranged touch requirement)
|
||||
# When: cast with only a damage roll in the queue
|
||||
|
||||
Reference in New Issue
Block a user