Files
gogobee/internal/plugin/dnd_spell_combat_test.go
prosolis 01c70f5297 Adv 2.0 D&D Phase 9 SP3: wire pending spells into combat
applyPendingCast resolves c.PendingCast against the upcoming fight before
SimulateCombat. Damage spells (Fire Bolt, Burning Hands, Magic Missile,
Fireball, etc.) emit a pre-combat spell_cast event via new
CombatModifiers.SpellPreDamage{,Desc}. Control spells (Hold Person, Sleep,
Command) set SpellEnemySkipFirst so the engine skips the enemy's round-1
attack with a spell_held event; Hold-family also primes AutoCritFirst.
Buffs (Mage Armor, Bless, Hunter's Mark, Shield of Faith, Aid, Spiritual
Weapon, Mirror Image, Greater Invisibility) fold into stats/mods directly.

Concentration-on-damage break is left for Phase 11 (turn-based bosses);
ConcentrationSpell persists across fights until manually dropped.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:25:21 -07:00

203 lines
6.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package plugin
import (
"strings"
"testing"
)
// TestApplySpellDamageAttack_HitsHard — Fire Bolt on a low-AC enemy with
// a +20 attack bonus is essentially guaranteed; ensure damage lands and
// the description carries the spell name.
func TestApplySpellDamageAttack_HitsHard(t *testing.T) {
spell, ok := lookupSpell("fire_bolt")
if !ok {
t.Fatal("fire_bolt missing from registry")
}
mods := &CombatModifiers{}
enemy := &CombatStats{AC: 5}
// Run a few iterations — d20+20 vs AC 5 always hits, so damage > 0.
hits := 0
for i := 0; i < 50; i++ {
mods.SpellPreDamage = 0
mods.SpellPreDamageDesc = ""
applySpellDamageAttack(spell, 20, mods, enemy, 0, 1)
if mods.SpellPreDamage > 0 {
hits++
}
}
if hits == 0 {
t.Errorf("expected hits with +20 atk vs AC5; got 0")
}
}
// TestApplySpellDamageAttack_AlwaysMisses — d20+0 vs AC 100 cannot hit
// outside a nat 20 (5% rate); ensure most rolls are recorded as misses.
func TestApplySpellDamageAttack_AlwaysMisses(t *testing.T) {
spell, _ := lookupSpell("fire_bolt")
enemy := &CombatStats{AC: 100}
misses := 0
for i := 0; i < 100; i++ {
mods := &CombatModifiers{}
applySpellDamageAttack(spell, 0, mods, enemy, 0, 1)
if strings.Contains(mods.SpellPreDamageDesc, "missed") {
misses++
}
}
if misses < 80 {
t.Errorf("expected >=80 misses out of 100; got %d", misses)
}
}
// TestApplySpellControl_AutoCritOnHoldPerson — failed save sets both the
// skip-first flag and AutoCritFirst (paralyzed → melee crit).
func TestApplySpellControl_AutoCritOnHoldPerson(t *testing.T) {
spell, ok := lookupSpell("hold_person")
if !ok {
t.Fatal("hold_person missing from registry")
}
// DC 99 ensures the save fails virtually every roll.
enemy := &CombatStats{AttackBonus: 0}
got := false
for i := 0; i < 20 && !got; i++ {
mods := &CombatModifiers{}
applySpellControl(spell, 99, mods, enemy, 2)
if mods.SpellEnemySkipFirst && mods.AutoCritFirst {
got = true
}
}
if !got {
t.Errorf("expected hold_person to set skip+autocrit on a failed save")
}
}
// TestApplySpellBuff_MageArmor — Mage Armor sets AC = 13 + DEX when higher
// than current; leaves it alone otherwise.
func TestApplySpellBuff_MageArmor(t *testing.T) {
spell, _ := lookupSpell("mage_armor")
c := &DnDCharacter{DEX: 16} // +3 mod → AC 16
stats := &CombatStats{AC: 12}
mods := &CombatModifiers{}
applySpellBuff(spell, c, stats, mods, 1)
if stats.AC != 16 {
t.Errorf("Mage Armor AC: got %d, want 16", stats.AC)
}
// Already-higher AC (full plate at 18) shouldn't drop.
stats.AC = 18
applySpellBuff(spell, c, stats, mods, 1)
if stats.AC != 18 {
t.Errorf("Mage Armor regression: got %d, want 18 preserved", stats.AC)
}
}
// TestApplySpellBuff_Bless — Bless boosts AttackBonus by 2 (1d4 average).
func TestApplySpellBuff_Bless(t *testing.T) {
spell, _ := lookupSpell("bless")
stats := &CombatStats{AttackBonus: 5}
mods := &CombatModifiers{}
applySpellBuff(spell, &DnDCharacter{}, stats, mods, 1)
if stats.AttackBonus != 7 {
t.Errorf("Bless: got %d, want 7", stats.AttackBonus)
}
}
// TestApplyMagicMissile — Magic Missile auto-damages with no roll. 3 darts
// at base, +1 per upcast level. Each dart deals 2..5 (1d4+1).
func TestApplyMagicMissile(t *testing.T) {
spell, _ := lookupSpell("magic_missile")
mods := &CombatModifiers{}
applySpellDamageAuto(spell, mods, 1, 1)
// 3 darts × 2..5 → 6..15
if mods.SpellPreDamage < 6 || mods.SpellPreDamage > 15 {
t.Errorf("MM L1: damage %d outside 6..15", mods.SpellPreDamage)
}
// Upcast L3: 5 darts → 10..25
mods = &CombatModifiers{}
applySpellDamageAuto(spell, mods, 3, 1)
if mods.SpellPreDamage < 10 || mods.SpellPreDamage > 25 {
t.Errorf("MM L3: damage %d outside 10..25", mods.SpellPreDamage)
}
}
// TestRollSpellDamageDice_CantripScaling — Fire Bolt scales 1d10 → 2d10
// at L5, 3d10 at L11, 4d10 at L17.
func TestRollSpellDamageDice_CantripScaling(t *testing.T) {
spell, _ := lookupSpell("fire_bolt")
cases := []struct {
level int
minRoll int
maxRoll int
}{
{1, 1, 10},
{5, 2, 20},
{11, 3, 30},
{17, 4, 40},
}
for _, tc := range cases {
// 30 samples; check all stay in range.
for i := 0; i < 30; i++ {
got := rollSpellDamageDice(spell, 0, tc.level)
if got < tc.minRoll || got > tc.maxRoll {
t.Errorf("Fire Bolt L%d: got %d, want %d..%d", tc.level, got, tc.minRoll, tc.maxRoll)
break
}
}
}
}
// TestSimulateCombat_SpellPreDamageEvent — verify the engine surfaces a
// "spell_cast" pre-combat event when SpellPreDamageDesc is set.
func TestSimulateCombat_SpellPreDamageEvent(t *testing.T) {
player := Combatant{
Name: "Caster",
Stats: CombatStats{MaxHP: 50, Attack: 10, Defense: 5, AC: 14, AttackBonus: 5},
Mods: CombatModifiers{
DamageReduct: 1.0,
SpellPreDamage: 12,
SpellPreDamageDesc: "Fireball",
},
IsPlayer: true,
}
enemy := Combatant{
Name: "Goblin",
Stats: CombatStats{MaxHP: 30, Attack: 8, Defense: 3, AC: 12, AttackBonus: 3},
Mods: CombatModifiers{DamageReduct: 1.0},
}
result := SimulateCombat(player, enemy, defaultCombatPhases)
found := false
for _, ev := range result.Events {
if ev.Action == "spell_cast" && ev.Desc == "Fireball" && ev.Damage == 12 {
found = true
break
}
}
if !found {
t.Errorf("expected spell_cast Fireball/12 pre-combat event; events=%+v", result.Events)
}
}
// TestSimulateCombat_EnemySkipFirst — when SpellEnemySkipFirst is set,
// round 1 must contain a "spell_held" event.
func TestSimulateCombat_EnemySkipFirst(t *testing.T) {
player := Combatant{
Stats: CombatStats{MaxHP: 30, Attack: 10, Defense: 5, AC: 14, AttackBonus: 5},
Mods: CombatModifiers{DamageReduct: 1.0, SpellEnemySkipFirst: true},
IsPlayer: true,
}
enemy := Combatant{
Stats: CombatStats{MaxHP: 30, Attack: 8, Defense: 3, AC: 12, AttackBonus: 3},
Mods: CombatModifiers{DamageReduct: 1.0},
}
result := SimulateCombat(player, enemy, defaultCombatPhases)
heldRound1 := false
for _, ev := range result.Events {
if ev.Round == 1 && ev.Action == "spell_held" {
heldRound1 = true
break
}
}
if !heldRound1 {
t.Errorf("expected spell_held event in round 1; got events=%+v", result.Events)
}
}