Spiritual Weapon: own channel + narration; rest blocked mid-zone

Spiritual Weapon used to ride the pet-attack channel, so a petless
cleric saw "🐾 Your faithful companion" each round and couldn't tell
the spell was firing. Split it to SpiritWeaponProc/Dmg with its own
 flavor; damage now scales with spell mod + upcast.

Rest also fired mid-dungeon — only the autorun honored RestingUntil,
the !rest commands themselves had no gate. Block both short and long
rest when an expedition or combat session is active.
This commit is contained in:
prosolis
2026-05-23 10:14:51 -07:00
parent 9eed921e4b
commit 2e6274c1b7
7 changed files with 102 additions and 5 deletions

View File

@@ -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

View File

@@ -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.",

View File

@@ -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

View File

@@ -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
}

View File

@@ -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) {

View File

@@ -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 {

View File

@@ -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