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:
2026-09-12 13:47:03 +02:00
parent 35c8d101b5
commit ec2088ab90
3 changed files with 323 additions and 59 deletions
+155
View File
@@ -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