mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +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:
@@ -49,6 +49,7 @@ func (p *AdventurePlugin) runArenaCombat(
|
|||||||
slog.Info("dnd: armed ability fired", "user", userID, "ability", firedName)
|
slog.Info("dnd: armed ability fired", "user", userID, "ability", firedName)
|
||||||
}
|
}
|
||||||
applyDnDArenaMonsterLayer(&enemyStats, monster.ThreatLevel)
|
applyDnDArenaMonsterLayer(&enemyStats, monster.ThreatLevel)
|
||||||
|
applyPendingCast(userID, dndChar, &playerStats, &playerMods, &enemyStats)
|
||||||
|
|
||||||
selected := SelectConsumables(consumables, playerStats, enemyStats, arenaRound, arenaTier)
|
selected := SelectConsumables(consumables, playerStats, enemyStats, arenaRound, arenaTier)
|
||||||
ApplyConsumableMods(&playerStats, &playerMods, selected)
|
ApplyConsumableMods(&playerStats, &playerMods, selected)
|
||||||
@@ -172,6 +173,7 @@ func (p *AdventurePlugin) runDungeonCombat(
|
|||||||
slog.Info("dnd: armed ability fired", "user", userID, "ability", firedName)
|
slog.Info("dnd: armed ability fired", "user", userID, "ability", firedName)
|
||||||
}
|
}
|
||||||
applyDnDDungeonMonsterLayer(&enemyStats, loc.Tier)
|
applyDnDDungeonMonsterLayer(&enemyStats, loc.Tier)
|
||||||
|
applyPendingCast(userID, dndChar, &playerStats, &playerMods, &enemyStats)
|
||||||
|
|
||||||
selected := SelectConsumables(consumables, playerStats, enemyStats, 0, loc.Tier)
|
selected := SelectConsumables(consumables, playerStats, enemyStats, 0, loc.Tier)
|
||||||
ApplyConsumableMods(&playerStats, &playerMods, selected)
|
ApplyConsumableMods(&playerStats, &playerMods, selected)
|
||||||
|
|||||||
@@ -52,6 +52,17 @@ type CombatModifiers struct {
|
|||||||
LuckyReroll bool // Halfling: reroll the first nat 1 of the fight
|
LuckyReroll bool // Halfling: reroll the first nat 1 of the fight
|
||||||
RageReady bool // Orc: when HP first drops <50%, next attack deals +50% damage
|
RageReady bool // Orc: when HP first drops <50%, next attack deals +50% damage
|
||||||
PoisonResist bool // Dwarf: poison tick damage halved
|
PoisonResist bool // Dwarf: poison tick damage halved
|
||||||
|
|
||||||
|
// Phase 9 — pending spell resolution. Set by applyPendingCast in
|
||||||
|
// dnd_spell_combat.go before SimulateCombat runs. SpellPreDamage is
|
||||||
|
// dealt as a pre-combat event with SpellPreDamageDesc as the narrative
|
||||||
|
// hook (spell name + flavor). SpellEnemySkipFirst causes the enemy to
|
||||||
|
// skip its attack on the first round (Hold Person, Sleep, etc.).
|
||||||
|
// Buffs (Bless, Mage Armor, Hunter's Mark) are folded into stats/mods
|
||||||
|
// directly and don't surface here.
|
||||||
|
SpellPreDamage int
|
||||||
|
SpellPreDamageDesc string
|
||||||
|
SpellEnemySkipFirst bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type Combatant struct {
|
type Combatant struct {
|
||||||
@@ -155,6 +166,10 @@ type combatState struct {
|
|||||||
raged bool // Orc Rage already triggered this fight
|
raged bool // Orc Rage already triggered this fight
|
||||||
pendingRageAttack bool // next player attack gets +50% damage
|
pendingRageAttack bool // next player attack gets +50% damage
|
||||||
|
|
||||||
|
// Phase 9 spell — enemy skip-first-attack (consumed on the first round
|
||||||
|
// the enemy would otherwise attack).
|
||||||
|
enemySkipFirst bool
|
||||||
|
|
||||||
round int
|
round int
|
||||||
events []CombatEvent
|
events []CombatEvent
|
||||||
}
|
}
|
||||||
@@ -163,10 +178,11 @@ func SimulateCombat(player, enemy Combatant, phases []CombatPhase) CombatResult
|
|||||||
st := &combatState{
|
st := &combatState{
|
||||||
playerHP: player.Stats.MaxHP,
|
playerHP: player.Stats.MaxHP,
|
||||||
enemyHP: enemy.Stats.MaxHP,
|
enemyHP: enemy.Stats.MaxHP,
|
||||||
wardCharges: player.Mods.WardCharges,
|
wardCharges: player.Mods.WardCharges,
|
||||||
sporeRounds: player.Mods.SporeCloud,
|
sporeRounds: player.Mods.SporeCloud,
|
||||||
reflectFrac: player.Mods.ReflectNext,
|
reflectFrac: player.Mods.ReflectNext,
|
||||||
autoCrit: player.Mods.AutoCritFirst,
|
autoCrit: player.Mods.AutoCritFirst,
|
||||||
|
enemySkipFirst: player.Mods.SpellEnemySkipFirst,
|
||||||
}
|
}
|
||||||
|
|
||||||
result := CombatResult{
|
result := CombatResult{
|
||||||
@@ -199,6 +215,23 @@ func SimulateCombat(player, enemy Combatant, phases []CombatPhase) CombatResult
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pre-combat: queued spell. Resolved by applyPendingCast() before this
|
||||||
|
// runs — the modifiers carry the resolved damage and narrative hook.
|
||||||
|
if player.Mods.SpellPreDamageDesc != "" {
|
||||||
|
dmg := player.Mods.SpellPreDamage
|
||||||
|
if dmg > 0 {
|
||||||
|
st.enemyHP = max(0, st.enemyHP-dmg)
|
||||||
|
}
|
||||||
|
st.events = append(st.events, CombatEvent{
|
||||||
|
Round: 0, Phase: "pre_combat", Actor: "player", Action: "spell_cast",
|
||||||
|
Damage: dmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
||||||
|
Desc: player.Mods.SpellPreDamageDesc,
|
||||||
|
})
|
||||||
|
if st.enemyHP <= 0 {
|
||||||
|
return finalize(result, st, player, enemy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Pre-combat: Misty gourmet is now a per-round heal, no pre-combat damage
|
// Pre-combat: Misty gourmet is now a per-round heal, no pre-combat damage
|
||||||
|
|
||||||
// Main simulation loop
|
// Main simulation loop
|
||||||
@@ -237,8 +270,21 @@ func SimulateCombat(player, enemy Combatant, phases []CombatPhase) CombatResult
|
|||||||
func simulateRound(st *combatState, player, enemy *Combatant, phase *CombatPhase, result *CombatResult) bool {
|
func simulateRound(st *combatState, player, enemy *Combatant, phase *CombatPhase, result *CombatResult) bool {
|
||||||
phaseName := phase.Name
|
phaseName := phase.Name
|
||||||
|
|
||||||
|
// Phase 9 spell: control effect (Hold Person, Sleep, Command) skips the
|
||||||
|
// enemy's attack for one round. Logged as a dedicated event so narrative
|
||||||
|
// can read it as a held/stunned beat rather than a generic miss.
|
||||||
|
enemyHeldThisRound := false
|
||||||
|
if st.enemySkipFirst {
|
||||||
|
st.enemySkipFirst = false
|
||||||
|
enemyHeldThisRound = true
|
||||||
|
st.events = append(st.events, CombatEvent{
|
||||||
|
Round: st.round, Phase: phaseName, Actor: "enemy", Action: "spell_held",
|
||||||
|
PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// Monster ability: check at round start
|
// Monster ability: check at round start
|
||||||
abilityDealtDamage := false
|
abilityDealtDamage := enemyHeldThisRound
|
||||||
if enemy.Ability != nil {
|
if enemy.Ability != nil {
|
||||||
if abilityFires(enemy.Ability, phaseName, st) {
|
if abilityFires(enemy.Ability, phaseName, st) {
|
||||||
if applyAbility(st, player, enemy, phase, result) {
|
if applyAbility(st, player, enemy, phase, result) {
|
||||||
|
|||||||
244
internal/plugin/dnd_spell_combat.go
Normal file
244
internal/plugin/dnd_spell_combat.go
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"math/rand/v2"
|
||||||
|
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Phase 9 SP3 — combat-time resolution of pending spell casts.
|
||||||
|
//
|
||||||
|
// applyPendingCast consumes c.PendingCast (queued by !cast in dnd_cast.go)
|
||||||
|
// and folds the spell's effect into playerStats / playerMods / enemyStats
|
||||||
|
// before SimulateCombat begins. Damage spells emit a "spell_cast" pre-combat
|
||||||
|
// event via Mods.SpellPreDamage{,Desc}; control spells set
|
||||||
|
// Mods.SpellEnemySkipFirst (and AutoCritFirst on Hold Person family); buffs
|
||||||
|
// mutate stats directly.
|
||||||
|
//
|
||||||
|
// Concentration is left untouched — it persists across fights until the
|
||||||
|
// player drops it manually or casts a replacing concentration spell. The
|
||||||
|
// concentration-on-damage-break check is deferred to Phase 11 (turn-based
|
||||||
|
// boss combat).
|
||||||
|
//
|
||||||
|
// Pending cast is consumed regardless of outcome (a wasted cantrip miss is
|
||||||
|
// still a cantrip; a wasted slot-spell miss already debited the slot at
|
||||||
|
// !cast time).
|
||||||
|
|
||||||
|
func applyPendingCast(
|
||||||
|
userID id.UserID,
|
||||||
|
c *DnDCharacter,
|
||||||
|
playerStats *CombatStats,
|
||||||
|
playerMods *CombatModifiers,
|
||||||
|
enemyStats *CombatStats,
|
||||||
|
) {
|
||||||
|
if c == nil || c.PendingCast == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pc, ok := decodePendingCast(c.PendingCast)
|
||||||
|
if !ok {
|
||||||
|
c.PendingCast = ""
|
||||||
|
_ = SaveDnDCharacter(c)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
spell, ok := lookupSpell(pc.SpellID)
|
||||||
|
if !ok {
|
||||||
|
c.PendingCast = ""
|
||||||
|
_ = SaveDnDCharacter(c)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dc := spellSaveDC(c)
|
||||||
|
atk := spellAttackBonus(c)
|
||||||
|
|
||||||
|
switch spell.Effect {
|
||||||
|
case EffectDamageAttack:
|
||||||
|
applySpellDamageAttack(spell, atk, playerMods, enemyStats, pc.SlotLevel, c.Level)
|
||||||
|
case EffectDamageSave:
|
||||||
|
applySpellDamageSave(spell, dc, c, playerMods, enemyStats, pc.SlotLevel)
|
||||||
|
case EffectDamageAuto:
|
||||||
|
applySpellDamageAuto(spell, playerMods, pc.SlotLevel, c.Level)
|
||||||
|
case EffectControl:
|
||||||
|
applySpellControl(spell, dc, playerMods, enemyStats, pc.SlotLevel)
|
||||||
|
case EffectBuffSelf, EffectBuffAlly:
|
||||||
|
applySpellBuff(spell, c, playerStats, playerMods, pc.SlotLevel)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.PendingCast = ""
|
||||||
|
if err := SaveDnDCharacter(c); err != nil {
|
||||||
|
slog.Error("dnd: clear pending_cast failed", "user", userID, "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// applySpellDamageAttack — Fire Bolt, Inflict Wounds, Chill Touch, etc.
|
||||||
|
// Roll d20 + spell attack vs enemy AC; nat 20 doubles dice damage.
|
||||||
|
func applySpellDamageAttack(spell SpellDefinition, atk int, mods *CombatModifiers, enemy *CombatStats, slot, charLevel int) {
|
||||||
|
roll := 1 + rand.IntN(20)
|
||||||
|
isCrit := roll == 20
|
||||||
|
isFumble := roll == 1
|
||||||
|
if isFumble || (!isCrit && roll+atk < enemy.AC) {
|
||||||
|
mods.SpellPreDamageDesc = spell.Name + " — missed"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dmg := rollSpellDamageDice(spell, slot, charLevel)
|
||||||
|
if isCrit {
|
||||||
|
dmg *= 2
|
||||||
|
}
|
||||||
|
mods.SpellPreDamage += dmg
|
||||||
|
if isCrit {
|
||||||
|
mods.SpellPreDamageDesc = spell.Name + " — crit!"
|
||||||
|
} else {
|
||||||
|
mods.SpellPreDamageDesc = spell.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// applySpellDamageSave — Burning Hands, Fireball, Sacred Flame, etc.
|
||||||
|
// Enemy rolls a save (heuristic mod = enemy.AttackBonus / 2) vs spell DC.
|
||||||
|
// Half damage on success, full on fail. AoE flag is moot — we have one enemy.
|
||||||
|
func applySpellDamageSave(spell SpellDefinition, dc int, c *DnDCharacter, mods *CombatModifiers, enemy *CombatStats, slot int) {
|
||||||
|
saveMod := enemy.AttackBonus / 2
|
||||||
|
saveRoll := 1 + rand.IntN(20)
|
||||||
|
saved := saveRoll+saveMod >= dc
|
||||||
|
dmg := rollSpellDamageDice(spell, slot, c.Level)
|
||||||
|
if saved {
|
||||||
|
dmg /= 2
|
||||||
|
if dmg < 1 {
|
||||||
|
dmg = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mods.SpellPreDamage += dmg
|
||||||
|
if saved {
|
||||||
|
mods.SpellPreDamageDesc = fmt.Sprintf("%s — saved (half, %d dmg)", spell.Name, dmg)
|
||||||
|
} else {
|
||||||
|
mods.SpellPreDamageDesc = fmt.Sprintf("%s — %d dmg", spell.Name, dmg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// applySpellDamageAuto — Magic Missile and other no-roll damage.
|
||||||
|
// Magic Missile: 3 darts × 1d4+1, +1 dart per slot above 1st.
|
||||||
|
func applySpellDamageAuto(spell SpellDefinition, mods *CombatModifiers, slot, charLevel int) {
|
||||||
|
if spell.ID == "magic_missile" {
|
||||||
|
darts := 3
|
||||||
|
if slot > 1 {
|
||||||
|
darts += slot - 1
|
||||||
|
}
|
||||||
|
total := 0
|
||||||
|
for i := 0; i < darts; i++ {
|
||||||
|
total += 1 + rand.IntN(4) + 1
|
||||||
|
}
|
||||||
|
mods.SpellPreDamage += total
|
||||||
|
mods.SpellPreDamageDesc = fmt.Sprintf("Magic Missile (%d darts, %d dmg)", darts, total)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Fallback: roll listed dice + ability mod isn't standard for auto spells.
|
||||||
|
dmg := rollSpellDamageDice(spell, slot, charLevel)
|
||||||
|
mods.SpellPreDamage += dmg
|
||||||
|
mods.SpellPreDamageDesc = fmt.Sprintf("%s — %d dmg", spell.Name, dmg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// applySpellControl — Hold Person, Sleep, Command, Hypnotic Pattern.
|
||||||
|
// Enemy save vs DC; failure → skip first attack. Hold-family also primes
|
||||||
|
// the engine's auto-crit-on-first-hit path so melee strikes connect for
|
||||||
|
// double damage (5e: paralyzed creatures auto-crit on melee hits).
|
||||||
|
func applySpellControl(spell SpellDefinition, dc int, mods *CombatModifiers, enemy *CombatStats, slot int) {
|
||||||
|
saveMod := enemy.AttackBonus / 2
|
||||||
|
saveRoll := 1 + rand.IntN(20)
|
||||||
|
if saveRoll+saveMod >= dc {
|
||||||
|
mods.SpellPreDamageDesc = spell.Name + " — resisted"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mods.SpellEnemySkipFirst = true
|
||||||
|
switch spell.ID {
|
||||||
|
case "hold_person", "hold_monster":
|
||||||
|
mods.AutoCritFirst = true
|
||||||
|
mods.SpellPreDamageDesc = spell.Name + " — paralyzed!"
|
||||||
|
case "sleep":
|
||||||
|
mods.SpellPreDamageDesc = spell.Name + " — asleep"
|
||||||
|
default:
|
||||||
|
mods.SpellPreDamageDesc = spell.Name + " — controlled"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// applySpellBuff — folds known buff spells into stats/mods. Unknown buff
|
||||||
|
// IDs still emit a "spell active" beat so the cast registers in narrative.
|
||||||
|
func applySpellBuff(spell SpellDefinition, c *DnDCharacter, stats *CombatStats, mods *CombatModifiers, slot int) {
|
||||||
|
switch spell.ID {
|
||||||
|
case "mage_armor":
|
||||||
|
// AC = 13 + DEX, take whichever is higher.
|
||||||
|
newAC := 13 + abilityModifier(c.DEX)
|
||||||
|
if newAC > stats.AC {
|
||||||
|
stats.AC = newAC
|
||||||
|
}
|
||||||
|
case "shield_of_faith":
|
||||||
|
stats.AC += 2
|
||||||
|
case "barkskin":
|
||||||
|
if stats.AC < 16 {
|
||||||
|
stats.AC = 16
|
||||||
|
}
|
||||||
|
case "bless":
|
||||||
|
stats.AttackBonus += 2 // 1d4 average ≈ 2.5
|
||||||
|
case "guiding_bolt":
|
||||||
|
stats.AttackBonus += 2 // proxy for next-attack advantage
|
||||||
|
case "hunters_mark":
|
||||||
|
mods.DamageBonus += 0.15 // ~+1d6 per hit on a typical baseline
|
||||||
|
case "aid":
|
||||||
|
stats.MaxHP += 5
|
||||||
|
case "shillelagh":
|
||||||
|
stats.AttackBonus += 1
|
||||||
|
mods.DamageBonus += 0.05
|
||||||
|
case "spiritual_weapon":
|
||||||
|
// Spectral bonus-action attack each round. Reuse pet-attack channel.
|
||||||
|
if mods.PetAttackProc < 0.5 {
|
||||||
|
mods.PetAttackProc = 0.5
|
||||||
|
}
|
||||||
|
if mods.PetAttackDmg < 6 {
|
||||||
|
mods.PetAttackDmg = 6
|
||||||
|
}
|
||||||
|
case "mirror_image":
|
||||||
|
mods.WardCharges += 2
|
||||||
|
case "blur":
|
||||||
|
mods.SporeCloud += 3 // proxy: enemy miss chance per round
|
||||||
|
case "greater_invisibility":
|
||||||
|
mods.SporeCloud += 5
|
||||||
|
stats.AttackBonus += 2
|
||||||
|
case "ensnaring_strike":
|
||||||
|
mods.SpellEnemySkipFirst = true
|
||||||
|
}
|
||||||
|
if mods.SpellPreDamageDesc == "" {
|
||||||
|
mods.SpellPreDamageDesc = spell.Name + " — active"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// rollSpellDamageDice rolls a spell's damage dice with cantrip and upcast
|
||||||
|
// scaling. Returns at least 1 if the spell has any dice; 0 if no dice are
|
||||||
|
// listed (utility/buff spells).
|
||||||
|
func rollSpellDamageDice(spell SpellDefinition, slot, charLevel int) int {
|
||||||
|
dice, faces, flat := parseDamageDice(spell.DamageDice)
|
||||||
|
if dice == 0 || faces == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if spell.Level == 0 {
|
||||||
|
// Cantrip scaling: 5e tiered at L5, L11, L17.
|
||||||
|
switch {
|
||||||
|
case charLevel >= 17:
|
||||||
|
dice *= 4
|
||||||
|
case charLevel >= 11:
|
||||||
|
dice *= 3
|
||||||
|
case charLevel >= 5:
|
||||||
|
dice *= 2
|
||||||
|
}
|
||||||
|
} else if extra := slot - spell.Level; extra > 0 {
|
||||||
|
// Upcast: +1 die per slot above base. Approximation for the common
|
||||||
|
// "+1d6 per slot above 3rd" pattern; close enough for engine balance.
|
||||||
|
dice += extra
|
||||||
|
}
|
||||||
|
total := flat
|
||||||
|
for i := 0; i < dice; i++ {
|
||||||
|
total += 1 + rand.IntN(faces)
|
||||||
|
}
|
||||||
|
if total < 1 {
|
||||||
|
total = 1
|
||||||
|
}
|
||||||
|
return total
|
||||||
|
}
|
||||||
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