From 01c70f529781733375f96e3ac2162cec3c2f70c8 Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Fri, 8 May 2026 08:31:08 -0700 Subject: [PATCH] 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) --- internal/plugin/combat_bridge.go | 2 + internal/plugin/combat_engine.go | 56 +++++- internal/plugin/dnd_spell_combat.go | 244 +++++++++++++++++++++++ internal/plugin/dnd_spell_combat_test.go | 202 +++++++++++++++++++ 4 files changed, 499 insertions(+), 5 deletions(-) create mode 100644 internal/plugin/dnd_spell_combat.go create mode 100644 internal/plugin/dnd_spell_combat_test.go diff --git a/internal/plugin/combat_bridge.go b/internal/plugin/combat_bridge.go index 84280d2..ea0556d 100644 --- a/internal/plugin/combat_bridge.go +++ b/internal/plugin/combat_bridge.go @@ -49,6 +49,7 @@ func (p *AdventurePlugin) runArenaCombat( slog.Info("dnd: armed ability fired", "user", userID, "ability", firedName) } applyDnDArenaMonsterLayer(&enemyStats, monster.ThreatLevel) + applyPendingCast(userID, dndChar, &playerStats, &playerMods, &enemyStats) selected := SelectConsumables(consumables, playerStats, enemyStats, arenaRound, arenaTier) ApplyConsumableMods(&playerStats, &playerMods, selected) @@ -172,6 +173,7 @@ func (p *AdventurePlugin) runDungeonCombat( slog.Info("dnd: armed ability fired", "user", userID, "ability", firedName) } applyDnDDungeonMonsterLayer(&enemyStats, loc.Tier) + applyPendingCast(userID, dndChar, &playerStats, &playerMods, &enemyStats) selected := SelectConsumables(consumables, playerStats, enemyStats, 0, loc.Tier) ApplyConsumableMods(&playerStats, &playerMods, selected) diff --git a/internal/plugin/combat_engine.go b/internal/plugin/combat_engine.go index 90b61e7..705b7d0 100644 --- a/internal/plugin/combat_engine.go +++ b/internal/plugin/combat_engine.go @@ -52,6 +52,17 @@ type CombatModifiers struct { LuckyReroll bool // Halfling: reroll the first nat 1 of the fight RageReady bool // Orc: when HP first drops <50%, next attack deals +50% damage 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 { @@ -155,6 +166,10 @@ type combatState struct { raged bool // Orc Rage already triggered this fight 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 events []CombatEvent } @@ -163,10 +178,11 @@ func SimulateCombat(player, enemy Combatant, phases []CombatPhase) CombatResult st := &combatState{ playerHP: player.Stats.MaxHP, enemyHP: enemy.Stats.MaxHP, - wardCharges: player.Mods.WardCharges, - sporeRounds: player.Mods.SporeCloud, - reflectFrac: player.Mods.ReflectNext, - autoCrit: player.Mods.AutoCritFirst, + wardCharges: player.Mods.WardCharges, + sporeRounds: player.Mods.SporeCloud, + reflectFrac: player.Mods.ReflectNext, + autoCrit: player.Mods.AutoCritFirst, + enemySkipFirst: player.Mods.SpellEnemySkipFirst, } 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 // 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 { 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 - abilityDealtDamage := false + abilityDealtDamage := enemyHeldThisRound if enemy.Ability != nil { if abilityFires(enemy.Ability, phaseName, st) { if applyAbility(st, player, enemy, phase, result) { diff --git a/internal/plugin/dnd_spell_combat.go b/internal/plugin/dnd_spell_combat.go new file mode 100644 index 0000000..5537fb8 --- /dev/null +++ b/internal/plugin/dnd_spell_combat.go @@ -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 +} diff --git a/internal/plugin/dnd_spell_combat_test.go b/internal/plugin/dnd_spell_combat_test.go new file mode 100644 index 0000000..1ebd164 --- /dev/null +++ b/internal/plugin/dnd_spell_combat_test.go @@ -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) + } +}