mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-16 08:52:42 +00:00
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:
202
internal/plugin/dnd_spell_combat_test.go
Normal file
202
internal/plugin/dnd_spell_combat_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user