package plugin

import (
	"fmt"
	"hash/fnv"
	"math"
	"time"

	"gogobee/internal/db"
	"gogobee/internal/flavor"
)

// Phase 11 D1d — TwinBee narration + DM mood triggers. Implements
// `gogobee_dungeon_zones.md` §3 (TwinBee GM System) on top of the D1b
// state machine.
//
// Flavor lines come from the canonical pools in internal/flavor
// (twinbee_gm_flavor.go) — this file does not author new prose.
// Per-zone overrides are routed through zoneRoomEntryPool / bossEntryPool.
//
// Mood-banded *flavor* selection is a D6 polish item. D1d wires the
// score, the event triggers, and the passive decay; mood currently
// surfaces as the human label in `!zone status` and gates downstream
// generosity (loot rolls, hint drops) once those land.

// DMNarrationType — see design doc §7.
type DMNarrationType string

const (
	DMRoomEntry    DMNarrationType = "room_entry"
	DMCombatStart  DMNarrationType = "combat_start"
	DMCombatEnd    DMNarrationType = "combat_end"
	DMNat20        DMNarrationType = "nat_20"
	DMNat1         DMNarrationType = "nat_1"
	DMBossEntry    DMNarrationType = "boss_entry"
	DMBossDeath    DMNarrationType = "boss_death"
	DMPlayerDeath  DMNarrationType = "player_death"
	DMZoneComplete DMNarrationType = "zone_complete"
	DMTrapDetected DMNarrationType = "trap_detected"
	DMTrapTripped  DMNarrationType = "trap_tripped"
	DMLore         DMNarrationType = "lore"
)

// MoodBand — five-state band derived from the 0–100 score.
type MoodBand int

const (
	MoodBandHostile MoodBand = iota
	MoodBandGrumpy
	MoodBandNeutral
	MoodBandFriendly
	MoodBandEffusive
)

// moodBand maps a raw score to its band per design doc §3.2.
func moodBand(score int) MoodBand {
	switch {
	case score >= 80:
		return MoodBandEffusive
	case score >= 60:
		return MoodBandFriendly
	case score >= 40:
		return MoodBandNeutral
	case score >= 20:
		return MoodBandGrumpy
	default:
		return MoodBandHostile
	}
}

// zoneRoomEntryPool returns the zone-specific RoomEntry pool when one
// exists, else the generic pool. Pools live in internal/flavor.
func zoneRoomEntryPool(zoneID ZoneID) []string {
	switch zoneID {
	case ZoneGoblinWarrens:
		return append(append([]string{}, flavor.RoomEntryGoblinWarrens...), flavor.RoomEntryGeneric...)
	case ZoneCryptValdris:
		return append(append([]string{}, flavor.RoomEntryCryptValdris...), flavor.RoomEntryGeneric...)
	case ZoneForestShadows:
		return append(append([]string{}, flavor.RoomEntryForestShadows...), flavor.RoomEntryGeneric...)
	case ZoneSunkenTemple:
		return append(append([]string{}, flavor.RoomEntrySunkenTemple...), flavor.RoomEntryGeneric...)
	case ZoneManorBlackspire:
		return append(append([]string{}, flavor.RoomEntryHauntedManor...), flavor.RoomEntryGeneric...)
	case ZoneUnderforge:
		return append(append([]string{}, flavor.RoomEntryUnderforge...), flavor.RoomEntryGeneric...)
	case ZoneUnderdark:
		return append(append([]string{}, flavor.RoomEntryUnderdark...), flavor.RoomEntryGeneric...)
	case ZoneFeywildCrossing:
		return append(append([]string{}, flavor.RoomEntryFeywildCrossing...), flavor.RoomEntryGeneric...)
	case ZoneDragonsLair:
		return append(append([]string{}, flavor.RoomEntryDragonsLair...), flavor.RoomEntryGeneric...)
	case ZoneAbyssPortal:
		return append(append([]string{}, flavor.RoomEntryAbyssPortal...), flavor.RoomEntryGeneric...)
	case ZoneOssuaryAscendant:
		return append(append([]string{}, flavor.RoomEntryOssuaryAscendant...), flavor.RoomEntryGeneric...)
	case ZoneFirstHoard:
		return append(append([]string{}, flavor.RoomEntryFirstHoard...), flavor.RoomEntryGeneric...)
	case ZoneUnplace:
		return append(append([]string{}, flavor.RoomEntryUnplace...), flavor.RoomEntryGeneric...)
	case ZoneDrownedStar:
		return append(append([]string{}, flavor.RoomEntryDrownedStar...), flavor.RoomEntryGeneric...)
	case ZoneLastMeridian:
		return append(append([]string{}, flavor.RoomEntryLastMeridian...), flavor.RoomEntryGeneric...)
	}
	return flavor.RoomEntryGeneric
}

// bossEntryPool returns the boss-specific pool when one exists, else
// the generic boss-entry pool.
func bossEntryPool(zoneID ZoneID) []string {
	switch zoneID {
	case ZoneGoblinWarrens:
		return flavor.BossEntryGrol
	case ZoneCryptValdris:
		return flavor.BossEntryValdris
	case ZoneForestShadows:
		return flavor.BossEntryHollowKing
	case ZoneSunkenTemple:
		return flavor.BossEntryDreamingAboleth
	case ZoneManorBlackspire:
		return flavor.BossEntryAldricBlackspire
	case ZoneUnderforge:
		return flavor.BossEntryEmberlordThyrak
	case ZoneUnderdark:
		return flavor.BossEntryIlvaras
	case ZoneFeywildCrossing:
		return flavor.BossEntryThornmother
	case ZoneDragonsLair:
		return flavor.BossEntryInfernax
	case ZoneAbyssPortal:
		return flavor.BossEntryBelaxath
	case ZoneOssuaryAscendant:
		return flavor.BossEntryValdrisAscendant
	case ZoneFirstHoard:
		return flavor.BossEntryAurvandryx
	case ZoneUnplace:
		return flavor.BossEntrySeamstress
	case ZoneDrownedStar:
		return flavor.BossEntrySeraphel
	case ZoneLastMeridian:
		return flavor.BossEntryCustodian
	}
	return flavor.BossEntryGeneric
}

// pickPool routes a narration type to the right []string pool from
// internal/flavor. Returns nil for kinds that have no pool yet (caller
// is expected to skip rendering or fall back to a static line).
func pickPool(zoneID ZoneID, kind DMNarrationType) []string {
	switch kind {
	case DMRoomEntry:
		return zoneRoomEntryPool(zoneID)
	case DMBossEntry:
		return bossEntryPool(zoneID)
	case DMCombatStart:
		return flavor.CombatStart
	case DMCombatEnd:
		return flavor.CombatVictory
	case DMNat20:
		return flavor.Nat20
	case DMNat1:
		return flavor.Nat1
	case DMBossDeath:
		return flavor.BossDeath
	case DMPlayerDeath:
		return flavor.PlayerDeath
	case DMZoneComplete:
		return flavor.ZoneComplete
	case DMTrapDetected:
		return flavor.TrapDetected
	case DMTrapTripped:
		return flavor.TrapTriggered
	case DMLore:
		return zoneLorePool(zoneID)
	}
	return nil
}

// zoneLorePool returns the zone-specific lore pool prepended to the
// generic LoreLines pool. Tier 1 zones ship dedicated lore (D2b); other
// tiers fall back to the generic pool until their flavor files land.
func zoneLorePool(zoneID ZoneID) []string {
	switch zoneID {
	case ZoneGoblinWarrens:
		return append(append([]string{}, flavor.LoreLinesWarrens...), flavor.LoreLines...)
	case ZoneCryptValdris:
		return append(append([]string{}, flavor.LoreLinesCrypt...), flavor.LoreLines...)
	case ZoneForestShadows:
		return append(append([]string{}, flavor.LoreLinesForestShadows...), flavor.LoreLines...)
	case ZoneSunkenTemple:
		return append(append([]string{}, flavor.LoreLinesSunkenTemple...), flavor.LoreLines...)
	case ZoneManorBlackspire:
		return append(append([]string{}, flavor.LoreLinesManorBlackspire...), flavor.LoreLines...)
	case ZoneUnderforge:
		return append(append([]string{}, flavor.LoreLinesUnderforge...), flavor.LoreLines...)
	case ZoneUnderdark:
		return append(append([]string{}, flavor.LoreLinesUnderdark...), flavor.LoreLines...)
	case ZoneFeywildCrossing:
		return append(append([]string{}, flavor.LoreLinesFeywildCrossing...), flavor.LoreLines...)
	case ZoneDragonsLair:
		return append(append([]string{}, flavor.LoreLinesDragonsLair...), flavor.LoreLines...)
	case ZoneAbyssPortal:
		return append(append([]string{}, flavor.LoreLinesAbyssPortal...), flavor.LoreLines...)
	case ZoneOssuaryAscendant:
		return append(append([]string{}, flavor.LoreLinesOssuaryAscendant...), flavor.LoreLines...)
	case ZoneFirstHoard:
		return append(append([]string{}, flavor.LoreLinesFirstHoard...), flavor.LoreLines...)
	case ZoneUnplace:
		return append(append([]string{}, flavor.LoreLinesUnplace...), flavor.LoreLines...)
	case ZoneDrownedStar:
		return append(append([]string{}, flavor.LoreLinesDrownedStar...), flavor.LoreLines...)
	case ZoneLastMeridian:
		return append(append([]string{}, flavor.LoreLinesLastMeridian...), flavor.LoreLines...)
	}
	return flavor.LoreLines
}

// bossSignaturePool returns the zone-boss ability-callout pool (one-line
// cinematic suffix sampled at boss-entry render). Returns nil when the
// zone has no signature pool yet — caller skips the suffix.
func bossSignaturePool(zoneID ZoneID) []string {
	switch zoneID {
	case ZoneGoblinWarrens:
		return flavor.GrolSignatureCallouts
	case ZoneCryptValdris:
		return flavor.ValdrisSignatureCallouts
	case ZoneForestShadows:
		return flavor.HollowKingSignatureCallouts
	case ZoneSunkenTemple:
		return flavor.AbolethSignatureCallouts
	case ZoneManorBlackspire:
		return flavor.AldricSignatureCallouts
	case ZoneUnderforge:
		return flavor.ThyrakSignatureCallouts
	case ZoneUnderdark:
		return flavor.IlvarasSignatureCallouts
	case ZoneFeywildCrossing:
		return flavor.ThornmotherSignatureCallouts
	case ZoneDragonsLair:
		return flavor.InfernaxSignatureCallouts
	case ZoneAbyssPortal:
		return flavor.BelaxathSignatureCallouts
	case ZoneOssuaryAscendant:
		return flavor.ValdrisAscendantSignatureCallouts
	case ZoneFirstHoard:
		return flavor.AurvandryxSignatureCallouts
	case ZoneUnplace:
		return flavor.SeamstressSignatureCallouts
	case ZoneDrownedStar:
		return flavor.SeraphelSignatureCallouts
	case ZoneLastMeridian:
		return flavor.CustodianSignatureCallouts
	}
	return nil
}

// bossPhaseTwoPool returns the zone-boss phase-two callout pool — the
// line surfaced when the boss crosses its PhaseTwoAt HP threshold mid-fight.
// Returns nil for bosses with no phase-two transition (Grol, Aboleth).
func bossPhaseTwoPool(zoneID ZoneID) []string {
	switch zoneID {
	case ZoneCryptValdris:
		return flavor.ValdrisPhaseTwoLines
	case ZoneForestShadows:
		return flavor.HollowKingPhaseTwoLines
	case ZoneManorBlackspire:
		return flavor.AldricPhaseTwoLines
	case ZoneUnderforge:
		return flavor.ThyrakPhaseTwoLines
	case ZoneUnderdark:
		return flavor.IlvarasPhaseTwoLines
	case ZoneFeywildCrossing:
		return flavor.ThornmotherPhaseTwoLines
	case ZoneDragonsLair:
		return flavor.InfernaxPhaseTwoLines
	case ZoneAbyssPortal:
		return flavor.BelaxathPhaseTwoLines
	case ZoneOssuaryAscendant:
		return flavor.ValdrisAscendantPhaseTwoLines
	case ZoneFirstHoard:
		return flavor.AurvandryxPhaseTwoLines
	case ZoneUnplace:
		return flavor.SeamstressPhaseTwoLines
	case ZoneDrownedStar:
		return flavor.SeraphelPhaseTwoLines
	case ZoneLastMeridian:
		return flavor.CustodianPhaseTwoLines
	}
	return nil
}

// bossPhaseTwoLine renders the zone-specific phase-two transition line.
// Empty string when the zone has no phase-two pool.
func bossPhaseTwoLine(zoneID ZoneID, runID string, roomIdx int) string {
	pool := bossPhaseTwoPool(zoneID)
	if len(pool) == 0 {
		return ""
	}
	line := pickLineDeterministic(pool, runID, roomIdx^0x71F4A2D3)
	if line == "" {
		return ""
	}
	return "🎭 **TwinBee:** " + line
}

// phaseTwoCrossedInEvents reports whether enemy HP crossed the
// PhaseTwoAt fraction of startHP at any point in the events log. Returns
// false when threshold <= 0 (no phase 2) or startHP <= 0.
func phaseTwoCrossedInEvents(events []CombatEvent, startHP int, threshold float64) bool {
	if threshold <= 0 || startHP <= 0 {
		return false
	}
	cutoff := int(float64(startHP) * threshold)
	for _, ev := range events {
		if ev.EnemyHP <= cutoff {
			return true
		}
	}
	return false
}

// eliteRoomEntryPool returns the zone-specific elite-room atmospheric
// pool. Returns nil when the zone has no dedicated elite prose.
func eliteRoomEntryPool(zoneID ZoneID) []string {
	switch zoneID {
	case ZoneGoblinWarrens:
		return flavor.EliteRoomEntryWarrens
	case ZoneCryptValdris:
		return flavor.EliteRoomEntryCrypt
	case ZoneForestShadows:
		return flavor.EliteRoomEntryForestShadows
	case ZoneSunkenTemple:
		return flavor.EliteRoomEntrySunkenTemple
	case ZoneManorBlackspire:
		return flavor.EliteRoomEntryManorBlackspire
	case ZoneUnderforge:
		return flavor.EliteRoomEntryUnderforge
	case ZoneUnderdark:
		return flavor.EliteRoomEntryUnderdark
	case ZoneFeywildCrossing:
		return flavor.EliteRoomEntryFeywildCrossing
	case ZoneDragonsLair:
		return flavor.EliteRoomEntryDragonsLair
	case ZoneAbyssPortal:
		return flavor.EliteRoomEntryAbyssPortal
	}
	return nil
}

// composeBossEntry renders the boss-entry narration plus, when the zone
// has a signature pool, a single ability callout on the line below.
// Falls back to the bare boss-entry line when no signature pool exists.
func composeBossEntry(zoneID ZoneID, runID string, roomIdx int) string {
	entry := twinBeeLine(zoneID, DMBossEntry, runID, roomIdx)
	pool := bossSignaturePool(zoneID)
	if entry == "" || len(pool) == 0 {
		return entry
	}
	// Salt the suffix selector with a constant so the entry line and the
	// callout line vary independently across rooms.
	callout := pickLineDeterministic(pool, runID, roomIdx^0x5BD17EF1)
	if callout == "" {
		return entry
	}
	return entry + "\n🎭 **TwinBee:** " + callout
}

// eliteRoomEntryLine renders the zone-specific elite atmospheric line
// for an elite room. Empty string when the zone has no elite pool yet.
func eliteRoomEntryLine(zoneID ZoneID, runID string, roomIdx int) string {
	pool := eliteRoomEntryPool(zoneID)
	if len(pool) == 0 {
		return ""
	}
	line := pickLineDeterministic(pool, runID, roomIdx^0x2C9E1A37)
	if line == "" {
		return ""
	}
	return "🎭 **TwinBee:** " + line
}

// narrationCadence returns the salt index used to seed narration pickers —
// the number of rooms walked so far, 0-based. This is a *progress* read, not
// a position read (revisit R1's audit split): flavor should keep moving
// forward as the player does, so a backtrack draws fresh lines rather than
// replaying the ones they already read on the way in.
//
// Forward-only runs have RoomsTraversed == len(VisitedNodes), so this stays
// numerically identical to the pre-R1 derivation and every existing pick
// keeps landing on the same line. It is stable while the player stands
// still, which is what lets a re-read of `!status` print the same prose.
//
// The len(VisitedNodes) fallback covers rows that predate the R1 column and
// have yet to be swept by bootstrapRoomsTraversed.
func narrationCadence(run *DungeonRun) int {
	if run == nil {
		return 0
	}
	if run.RoomsTraversed > 0 {
		return run.RoomsTraversed - 1
	}
	if n := len(run.VisitedNodes); n > 0 {
		return n - 1
	}
	return run.CurrentRoom
}

// pickLineDeterministic — stable selection across (runID, salt). Same
// inputs always yield the same line, so a player who re-reads status
// sees the same prose; different rooms / different runs vary.
func pickLineDeterministic(lines []string, runID string, salt int) string {
	if len(lines) == 0 {
		return ""
	}
	h := fnv.New64a()
	h.Write([]byte(runID))
	var sb [8]byte
	for i := 0; i < 8; i++ {
		sb[i] = byte(salt >> (8 * i))
	}
	h.Write(sb[:])
	return lines[int(h.Sum64()%uint64(len(lines)))]
}

// twinBeeLine renders the TwinBee narration block for a given event,
// drawing from the canonical internal/flavor pools.
//
// roomIdx is folded into the deterministic selector so the same room
// in the same run always renders the same prose (idempotent reads).
func twinBeeLine(zoneID ZoneID, kind DMNarrationType, runID string, roomIdx int) string {
	pool := pickPool(zoneID, kind)
	line := pickLineDeterministic(pool, runID, roomIdx)
	if line == "" {
		return ""
	}
	return "🎭 **TwinBee:** " + line
}

// tauntResponseLine renders a deterministic TwinBee reply to !zone taunt
// from the prewritten flavor.TauntResponses pool. roomIdx is folded so
// repeated taunts in the same room are stable but cross-room taunts vary.
func tauntResponseLine(runID string, roomIdx int) string {
	line := pickLineDeterministic(flavor.TauntResponses, runID, roomIdx^0x4A8D9F25)
	if line == "" {
		return ""
	}
	return "🎭 **TwinBee:** " + line
}

// complimentResponseLine renders a deterministic TwinBee reply to
// !zone compliment from the prewritten flavor.ComplimentResponses pool.
func complimentResponseLine(runID string, roomIdx int) string {
	line := pickLineDeterministic(flavor.ComplimentResponses, runID, roomIdx^0x3E172B6C)
	if line == "" {
		return ""
	}
	return "🎭 **TwinBee:** " + line
}

// moodAsidePool returns the mood-banded aside pool. Every band except
// strict Neutral (40–59) surfaces flavor — Hostile/Effusive at the
// extremes, Grumpy/Friendly in the mid-bands. Neutral stays silent.
func moodAsidePool(mood int) []string {
	switch moodBand(mood) {
	case MoodBandHostile:
		return flavor.MoodAsidesHostile
	case MoodBandGrumpy:
		return flavor.MoodAsidesGrumpy
	case MoodBandFriendly:
		return flavor.MoodAsidesFriendly
	case MoodBandEffusive:
		return flavor.MoodAsidesEffusive
	}
	return nil
}

// moodAsideLine renders a deterministic TwinBee aside reflecting the
// run's mood at the extreme bands. roomIdx is folded so the same room
// in the same run is stable; cross-room asides vary. Returns "" for
// mid-band moods.
func moodAsideLine(mood int, runID string, roomIdx int) string {
	pool := moodAsidePool(mood)
	if len(pool) == 0 {
		return ""
	}
	line := pickLineDeterministic(pool, runID, roomIdx^0x6F2D8B14)
	if line == "" {
		return ""
	}
	return "🎭 **TwinBee:** " + line
}

// ── Mood event table & application ───────────────────────────────────────────

// DMMoodEvent enumerates the mood-modifier triggers from design doc §3.2.
type DMMoodEvent string

const (
	MoodEventZoneComplete       DMMoodEvent = "zone_complete"
	MoodEventPlayerDeath        DMMoodEvent = "player_death"
	MoodEventNat20              DMMoodEvent = "nat_20"
	MoodEventNat1               DMMoodEvent = "nat_1"
	MoodEventCommunityMilestone DMMoodEvent = "community_milestone"
	MoodEventDowntime           DMMoodEvent = "downtime"
	MoodEventTaunt              DMMoodEvent = "taunt"
	MoodEventCompliment         DMMoodEvent = "compliment"
)

// moodEventDelta — design doc §3.2 mood-modifier table.
var moodEventDelta = map[DMMoodEvent]int{
	MoodEventZoneComplete:       +10,
	MoodEventPlayerDeath:        -5,
	MoodEventNat20:              +3,
	MoodEventNat1:               -2,
	MoodEventCommunityMilestone: +15,
	MoodEventDowntime:           -20,
	MoodEventTaunt:              -10,
	MoodEventCompliment:         +5,
}

// MoodEventDelta exposes the static delta for an event (test use).
func MoodEventDelta(ev DMMoodEvent) int { return moodEventDelta[ev] }

// applyMoodEvent updates the run's mood by the event's delta. Returns
// the new mood score after clamping.
func applyMoodEvent(runID string, ev DMMoodEvent) (int, error) {
	delta, ok := moodEventDelta[ev]
	if !ok {
		return 0, fmt.Errorf("unknown mood event: %s", ev)
	}
	if err := adjustGMMood(runID, delta); err != nil {
		return 0, err
	}
	r, err := getZoneRun(runID)
	if err != nil || r == nil {
		return 0, err
	}
	return r.DMMood, nil
}

// passiveDecayMood drifts the mood toward 50 at ±2/hour, scaled by
// hours elapsed since lastTouched. Pure function; caller persists.
//
// Design doc §3.2: "Mood decays toward 50 at a rate of ±2 per hour
// (passive rebalancing)." Long-idle runs reset toward neutral.
func passiveDecayMood(score int, lastTouched, now time.Time) int {
	elapsedHours := now.Sub(lastTouched).Hours()
	if elapsedHours <= 0 {
		return clampMood(score)
	}
	const ratePerHour = 2.0
	// Round to nearest unit so a 30-minute gap decays 1, a 12-minute
	// gap decays 0. Avoids the truncation bug where any sub-hour gap
	// produced no decay.
	drift := int(math.Round(elapsedHours * ratePerHour))
	if drift <= 0 {
		return clampMood(score)
	}
	switch {
	case score > 50:
		score -= drift
		if score < 50 {
			score = 50
		}
	case score < 50:
		score += drift
		if score > 50 {
			score = 50
		}
	}
	return clampMood(score)
}

func clampMood(s int) int {
	if s < 0 {
		return 0
	}
	if s > 100 {
		return 100
	}
	return s
}

// DMMoodCombatTilt — what the DM's mood does to combat dials.
//
// Effusive: monsters miss more, hit softer; player goes first more often.
// Hostile : monsters meaner, faster off the line.
// Mid-bands: small or zero effects so the DM's voice still matters
// without trivializing math at each step.
type DMMoodCombatTilt struct {
	EnemyAttackDelta int     // added to monster.Stats.Attack pre-fight
	InitiativeBias   float64 // added to player initiative roll each round
	LootQualityMod   float64 // multiplied into AdvBonusSummary.LootQuality
}

// dmMoodCombatTilt maps the run's DM mood score to the combat dials.
func dmMoodCombatTilt(mood int) DMMoodCombatTilt {
	switch moodBand(mood) {
	case MoodBandEffusive:
		return DMMoodCombatTilt{EnemyAttackDelta: -1, InitiativeBias: +5, LootQualityMod: 1.20}
	case MoodBandFriendly:
		return DMMoodCombatTilt{EnemyAttackDelta: 0, InitiativeBias: +2, LootQualityMod: 1.10}
	case MoodBandGrumpy:
		return DMMoodCombatTilt{EnemyAttackDelta: +1, InitiativeBias: -2, LootQualityMod: 0.90}
	case MoodBandHostile:
		return DMMoodCombatTilt{EnemyAttackDelta: +2, InitiativeBias: -5, LootQualityMod: 0.75}
	}
	return DMMoodCombatTilt{LootQualityMod: 1.0} // Neutral
}

// applyMoodDecayIfStale persists a passive-decay correction when the
// run has been idle long enough to drift. Cheap no-op on fresh runs.
//
// N3/P6d: owner-only, and the `owner` argument is not a convenience — the
// UPDATE below writes gm_mood as an *absolute* value derived from the snapshot
// the caller read, while adjustGMMood writes an atomic `gm_mood + delta`. Every
// command in the module takes the *sender's* advUserLock, so a party member
// running this against the leader's run holds the wrong mutex and would drop
// whatever the leader's concurrent walk banked between the read and this write.
// Decay is time-based maintenance: skipping it on a member's read loses nothing,
// because the owner's next command applies exactly the same correction.
func applyMoodDecayIfStale(r *DungeonRun, owner bool) error {
	if r == nil || !owner {
		return nil
	}
	newMood := passiveDecayMood(r.DMMood, r.LastActionAt, time.Now().UTC())
	if newMood == r.DMMood {
		return nil
	}
	if _, err := db.Get().Exec(`
		UPDATE dnd_zone_run SET gm_mood = ? WHERE run_id = ?`,
		newMood, r.RunID); err != nil {
		return err
	}
	r.DMMood = newMood
	return nil
}
