mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
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>
203 lines
6.1 KiB
Go
203 lines
6.1 KiB
Go
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)
|
||
}
|
||
}
|