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:
@@ -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