diff --git a/internal/plugin/combat_engine.go b/internal/plugin/combat_engine.go index 98b1fd1..de23a9f 100644 --- a/internal/plugin/combat_engine.go +++ b/internal/plugin/combat_engine.go @@ -53,6 +53,11 @@ type CombatModifiers struct { PetAttackDmg int PetDeflectProc float64 PetWhiffProc float64 // pet distracts enemy → guaranteed miss + // Spiritual Weapon — separate channel from the pet so the spectral mace + // gets its own narration when a cleric without a companion casts it. + // Damage formula mirrors PetAttack (Dmg + d5), proc rolls per round. + SpiritWeaponProc float64 + SpiritWeaponDmg int SniperKillProc float64 // Arina instant-kill MistyHealProc float64 MistyHealAmt int @@ -645,6 +650,19 @@ func simulateRound(st *combatState, player, enemy *Combatant, phase *CombatPhase } } + // Spiritual Weapon strike + if player.Mods.SpiritWeaponProc > 0 && st.randFloat() < player.Mods.SpiritWeaponProc { + swDmg := player.Mods.SpiritWeaponDmg + st.roll(5) + st.enemyHP = max(0, st.enemyHP-swDmg) + st.events = append(st.events, CombatEvent{ + Round: st.round, Phase: phaseName, Actor: "spirit_weapon", Action: "spirit_weapon_strike", + Damage: swDmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP, + }) + if enemyDown(st, phaseName) { + return true + } + } + // Misty heal if player.Mods.MistyHealProc > 0 && st.randFloat() < player.Mods.MistyHealProc { healAmt := player.Mods.MistyHealAmt diff --git a/internal/plugin/combat_narrative.go b/internal/plugin/combat_narrative.go index 5337ff3..09509cb 100644 --- a/internal/plugin/combat_narrative.go +++ b/internal/plugin/combat_narrative.go @@ -236,6 +236,9 @@ func renderEvent(e CombatEvent, playerName, enemyName string, result CombatResul case "pet_attack": return fmt.Sprintf(pickRand(narrativePetAttack), e.Damage) + case "spirit_weapon_strike": + return fmt.Sprintf(pickRand(narrativeSpiritWeapon), e.Damage) + case "pet_deflect": return pickRand(narrativePetDeflect) @@ -526,6 +529,13 @@ var narrativePetAttack = []string{ "🐾 Your faithful companion lands a hit for %d damage. More faithful than accurate, but today both applied.", } +var narrativeSpiritWeapon = []string{ + "✨ The spectral mace swings on its own and lands for %d damage. Floating menace, well-balanced.", + "✨ Your spiritual weapon hovers, picks an angle, strikes — %d damage. No grip, all conviction.", + "✨ A glowing weapon arcs in from beside you. %d damage. The enemy keeps trying to track it. Cannot.", + "✨ The spectral blade flickers, then bites. %d damage. It does not tire. It does not blink.", +} + var narrativePetDeflect = []string{ "🐾 Your pet intercepts the blow. Damage halved. Your pet is now your best piece of equipment.", "🐾 Your pet pushes you aside at the last second. Impact reduced. You did not ask to be pushed. Results speak for themselves.", diff --git a/internal/plugin/combat_session.go b/internal/plugin/combat_session.go index 16f58c4..47fe6ba 100644 --- a/internal/plugin/combat_session.go +++ b/internal/plugin/combat_session.go @@ -121,6 +121,8 @@ type CombatStatuses struct { BuffDamageBonus float64 `json:"buff_damage_bonus,omitempty"` BuffPetProc float64 `json:"buff_pet_proc,omitempty"` BuffDamageReductMul float64 `json:"buff_damage_reduct_mul,omitempty"` + BuffSpiritProc float64 `json:"buff_spirit_proc,omitempty"` + BuffSpiritDmg int `json:"buff_spirit_dmg,omitempty"` } // applyBuffDelta folds one resolved buff (the result of a !cast / !consume @@ -134,6 +136,8 @@ func (s *CombatStatuses) applyBuffDelta(d turnBuffDelta) { s.BuffCritRate += d.dCrit s.BuffDamageBonus += d.dDmgBonus s.BuffPetProc += d.dPetProc + s.BuffSpiritProc += d.dSpiritProc + s.BuffSpiritDmg += d.dSpiritDmg if d.dReductMul > 0 && d.dReductMul != 1 { if s.BuffDamageReductMul == 0 { s.BuffDamageReductMul = d.dReductMul diff --git a/internal/plugin/combat_session_build.go b/internal/plugin/combat_session_build.go index 6735ae3..25b643b 100644 --- a/internal/plugin/combat_session_build.go +++ b/internal/plugin/combat_session_build.go @@ -149,6 +149,8 @@ func applySessionBuffs(player *Combatant, s CombatStatuses) { player.Mods.DamageBonus += s.BuffDamageBonus player.Mods.PetAttackProc += s.BuffPetProc player.Mods.PetAttackDmg += s.BuffPetDmg + player.Mods.SpiritWeaponProc += s.BuffSpiritProc + player.Mods.SpiritWeaponDmg += s.BuffSpiritDmg if s.BuffDamageReductMul > 0 { player.Mods.DamageReduct *= s.BuffDamageReductMul } diff --git a/internal/plugin/combat_turn_engine.go b/internal/plugin/combat_turn_engine.go index c210e65..91f82ff 100644 --- a/internal/plugin/combat_turn_engine.go +++ b/internal/plugin/combat_turn_engine.go @@ -219,6 +219,10 @@ func (te *turnEngine) stepPlayerTurn(action PlayerAction) { te.finish(CombatStatusWon) return } + if te.spiritWeaponStrike() { + te.finish(CombatStatusWon) + return + } te.sess.Phase = CombatPhaseEnemyTurn } @@ -244,6 +248,24 @@ func (te *turnEngine) petStrike() bool { return enemyDown(st, turnCombatPhase.Name) } +// spiritWeaponStrike resolves the spell's bonus-action attack each round when +// the spiritual_weapon buff is active. Same per-turn cadence as petStrike, but +// rolls and narrates on its own channel so the spectral mace doesn't borrow +// pet flavor on a petless caster. Returns true if the strike dropped the enemy. +func (te *turnEngine) spiritWeaponStrike() bool { + st := te.st + if te.player.Mods.SpiritWeaponProc <= 0 || st.randFloat() >= te.player.Mods.SpiritWeaponProc { + return false + } + dmg := te.player.Mods.SpiritWeaponDmg + st.roll(5) + st.enemyHP = max(0, st.enemyHP-dmg) + st.events = append(st.events, CombatEvent{ + Round: st.round, Phase: turnCombatPhase.Name, Actor: "spirit_weapon", Action: "spirit_weapon_strike", + Damage: dmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP, + }) + return enemyDown(st, turnCombatPhase.Name) +} + // stepPlayerActionEffect resolves a !cast / !consume turn: the command handler // has already rolled the spell / picked the item and spent the resource, so the // engine only applies the HP deltas and emits the event before handing off to @@ -295,6 +317,10 @@ func (te *turnEngine) stepPlayerActionEffect(eff *turnActionEffect) { te.finish(CombatStatusWon) return } + if te.spiritWeaponStrike() { + te.finish(CombatStatusWon) + return + } if eff.EnemySkip { // fear_immune enemies shrug off control spells — the skip never arms. if enemyImmuneToControl(te.enemy, st) { diff --git a/internal/plugin/dnd_rest.go b/internal/plugin/dnd_rest.go index 6ef20d6..6146f13 100644 --- a/internal/plugin/dnd_rest.go +++ b/internal/plugin/dnd_rest.go @@ -6,6 +6,8 @@ import ( "sort" "strings" "time" + + "maunium.net/go/mautrix/id" ) // !rest short / !rest long. @@ -45,6 +47,20 @@ func restingLockoutRemaining(c *DnDCharacter) time.Duration { return remaining } +// restBlockedReason returns a player-facing message when the character +// cannot rest right now because they're mid-fight or mid-expedition. An +// empty string means rest is allowed. Both !rest short and !rest long +// honor this — the dungeon shouldn't be a campfire. +func restBlockedReason(uid id.UserID) string { + if hasActiveCombatSession(uid) { + return "You're mid-fight. Finish it (or `!flee`) before resting." + } + if exp, _ := getActiveExpedition(uid); exp != nil { + return "You can't rest while on an expedition. Use `!expedition extract` first." + } + return "" +} + func (p *AdventurePlugin) handleDnDRestCmd(ctx MessageContext, args string) error { args = strings.TrimSpace(strings.ToLower(args)) switch args { @@ -77,6 +93,9 @@ func (p *AdventurePlugin) handleDnDShortRest(ctx MessageContext) error { return p.SendDM(ctx.Sender, "Couldn't load your character.") } + if msg := restBlockedReason(ctx.Sender); msg != "" { + return p.SendDM(ctx.Sender, msg) + } if c.ShortRestCharges <= 0 { return p.SendDM(ctx.Sender, "You're out of short rest charges. Take a `!rest long` to restore them.") @@ -170,6 +189,9 @@ func (p *AdventurePlugin) handleDnDLongRest(ctx MessageContext) error { return p.SendDM(ctx.Sender, "Couldn't load your Adv 2.0 sheet.") } + if msg := restBlockedReason(ctx.Sender); msg != "" { + return p.SendDM(ctx.Sender, msg) + } if c.LastLongRestAt != nil { elapsed := time.Since(*c.LastLongRestAt) if elapsed < dndLongRestCooldown { diff --git a/internal/plugin/dnd_spell_combat.go b/internal/plugin/dnd_spell_combat.go index 8046f5b..4b36172 100644 --- a/internal/plugin/dnd_spell_combat.go +++ b/internal/plugin/dnd_spell_combat.go @@ -268,12 +268,22 @@ func applySpellBuff(spell SpellDefinition, c *DnDCharacter, stats *CombatStats, 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 + // Spectral bonus-action attack each round on its own channel so the + // narration doesn't borrow pet flavor (the cleric may have no pet). + // 1d8 + spell mod base, +1d8 per 2 slot levels above 2nd; the engine + // rolls the d5 variance, so Dmg carries the average + upcast bump. + base := 4 + spellAttackBonus(c) + if slot > 2 { + base += 4 * ((slot - 2) / 2) } - if mods.PetAttackDmg < 6 { - mods.PetAttackDmg = 6 + if base < 4 { + base = 4 + } + if mods.SpiritWeaponProc < 0.5 { + mods.SpiritWeaponProc = 0.5 + } + if mods.SpiritWeaponDmg < base { + mods.SpiritWeaponDmg = base } case "mirror_image": mods.WardCharges += 2 @@ -422,6 +432,8 @@ type turnBuffDelta struct { reflect float64 autoCrit, enemySkip bool heal int // a MaxHP-raise (Aid) collapses to an immediate heal + dSpiritProc float64 + dSpiritDmg int } // statComponent reports whether the buff has a re-applicable persistent stat @@ -429,6 +441,7 @@ type turnBuffDelta struct { func (d turnBuffDelta) statComponent() bool { return d.dAC != 0 || d.dAtk != 0 || d.dSpeed != 0 || d.dPetDmg != 0 || d.dCrit != 0 || d.dDmgBonus != 0 || d.dPetProc != 0 || + d.dSpiritProc != 0 || d.dSpiritDmg != 0 || (d.dReductMul > 0 && d.dReductMul != 1) } @@ -460,6 +473,8 @@ func diffTurnBuff(bs, as CombatStats, bm, am CombatModifiers) turnBuffDelta { autoCrit: am.AutoCritFirst && !bm.AutoCritFirst, enemySkip: am.SpellEnemySkipFirst && !bm.SpellEnemySkipFirst, heal: as.MaxHP - bs.MaxHP, + dSpiritProc: am.SpiritWeaponProc - bm.SpiritWeaponProc, + dSpiritDmg: am.SpiritWeaponDmg - bm.SpiritWeaponDmg, } if bm.DamageReduct > 0 { d.dReductMul = am.DamageReduct / bm.DamageReduct