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>
This commit is contained in:
prosolis
2026-05-08 08:31:08 -07:00
parent 9e1a1f606c
commit 01c70f5297
4 changed files with 499 additions and 5 deletions

View File

@@ -0,0 +1,202 @@
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)
}
}