From dcc039b7d58428cbe71c2c4e819377831fed2b7e Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Sat, 16 May 2026 13:48:52 -0700 Subject: [PATCH] Close expedition/run seam on combat loss & flee Run-loss paths (turn-based elite/boss death + flee, exploration combat death/retreat, interrupt/patrol death) abandoned the zone run but left the wrapping expedition row at status='active', so the ambient ticker kept DMing about a dungeon the player had walked away from. Adds forceExtractExpeditionForRunLoss helper and wires it into every run-loss site; ambient ticker also skips when the expedition's run is no longer active as a safety net. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/plugin/combat_cmd.go | 2 ++ internal/plugin/dnd_expedition_combat.go | 2 ++ internal/plugin/dnd_expedition_extract.go | 18 ++++++++++++++++++ internal/plugin/dnd_zone_cmd.go | 3 +++ internal/plugin/expedition_ambient.go | 9 +++++++++ 5 files changed, 34 insertions(+) diff --git a/internal/plugin/combat_cmd.go b/internal/plugin/combat_cmd.go index 906492b..91c4fc9 100644 --- a/internal/plugin/combat_cmd.go +++ b/internal/plugin/combat_cmd.go @@ -267,6 +267,7 @@ func (p *AdventurePlugin) finishCombatSession(userID id.UserID, sess *CombatSess _, _ = applyMoodEvent(sess.RunID, MoodEventPlayerDeath) } _ = abandonZoneRun(userID) + forceExtractExpeditionForRunLoss(userID, "combat death") markAdventureDead(userID, "zone", zone.Display) if line := twinBeeLine(zone.ID, DMPlayerDeath, sess.RunID, cadence); line != "" { b.WriteString(line + "\n") @@ -278,6 +279,7 @@ func (p *AdventurePlugin) finishCombatSession(userID id.UserID, sess *CombatSess // but no death timer. Chosen candidate from the migration plan's // open question on flee outcome. _ = abandonZoneRun(userID) + forceExtractExpeditionForRunLoss(userID, "combat flee") b.WriteString(fmt.Sprintf("🏃 You broke off from **%s** and slipped away. Run ended — you keep your wounds, but you live.", enemy.Name)) default: diff --git a/internal/plugin/dnd_expedition_combat.go b/internal/plugin/dnd_expedition_combat.go index 8217961..7b99202 100644 --- a/internal/plugin/dnd_expedition_combat.go +++ b/internal/plugin/dnd_expedition_combat.go @@ -188,6 +188,7 @@ func (p *AdventurePlugin) runHarvestInterrupt( _, _ = applyMoodEvent(run.RunID, MoodEventPlayerDeath) _ = abandonZoneRun(userID) _ = retireAllRegionRuns(exp) + _, _, _ = forcedExtractExpedition(exp.ID, "interrupt death") markAdventureDead(userID, "expedition", zone.Display) if line := flavor.Pick(flavor.PlayerDeath); line != "" { b.WriteString(line) @@ -513,6 +514,7 @@ func (p *AdventurePlugin) tryPatrolEncounter( _, _ = applyMoodEvent(run.RunID, MoodEventPlayerDeath) _ = abandonZoneRun(userID) _ = retireAllRegionRuns(exp) + _, _, _ = forcedExtractExpedition(exp.ID, "patrol death") markAdventureDead(userID, "patrol", zone.Display) if line := flavor.Pick(flavor.PlayerDeath); line != "" { ob.WriteString(line) diff --git a/internal/plugin/dnd_expedition_extract.go b/internal/plugin/dnd_expedition_extract.go index 06587ff..401f988 100644 --- a/internal/plugin/dnd_expedition_extract.go +++ b/internal/plugin/dnd_expedition_extract.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "log/slog" "strings" "time" @@ -92,6 +93,23 @@ func forcedExtractExpedition(expID, reason string) (*Expedition, int, error) { return e, tax, nil } +// forceExtractExpeditionForRunLoss bridges run-loss call sites (turn-based +// elite/boss death or flee, exploration combat death, patrol-interrupt +// death) into the forced-extract flow. Those sites already abandon the +// zone run, but without flipping the wrapping expedition the ambient +// ticker keeps DMing about a dungeon the player walked away from. No-op +// when there is no active expedition for this user. +func forceExtractExpeditionForRunLoss(userID id.UserID, reason string) { + exp, err := getActiveExpedition(userID) + if err != nil || exp == nil { + return + } + if _, _, err := forcedExtractExpedition(exp.ID, reason); err != nil { + slog.Warn("expedition: force-extract on run loss", + "user", userID, "expedition", exp.ID, "reason", reason, "err", err) + } +} + // getResumableExpedition returns the most recent 'extracting' row for the // user, regardless of age (caller checks the 7-day window). func getResumableExpedition(userID id.UserID) (*Expedition, error) { diff --git a/internal/plugin/dnd_zone_cmd.go b/internal/plugin/dnd_zone_cmd.go index 598000a..2d1a480 100644 --- a/internal/plugin/dnd_zone_cmd.go +++ b/internal/plugin/dnd_zone_cmd.go @@ -861,6 +861,9 @@ func (p *AdventurePlugin) resolveCombatRoom(userID id.UserID, run *DungeonRun, z // respawn timer for what is mechanically "ran out the clock". if !result.TimedOut { markAdventureDead(userID, "zone", zone.Display) + forceExtractExpeditionForRunLoss(userID, "combat death") + } else { + forceExtractExpeditionForRunLoss(userID, "combat retreat") } if line := twinBeeLine(zone.ID, DMPlayerDeath, run.RunID, narrationCadence(run)); line != "" { ob.WriteString(line) diff --git a/internal/plugin/expedition_ambient.go b/internal/plugin/expedition_ambient.go index 97d486f..185e78c 100644 --- a/internal/plugin/expedition_ambient.go +++ b/internal/plugin/expedition_ambient.go @@ -84,6 +84,15 @@ func (p *AdventurePlugin) fireExpeditionAmbients(now time.Time) { if e.Status != ExpeditionStatusActive { continue } + // Safety net: if the wrapping zone run is abandoned or completed, + // the player has functionally left the dungeon even though the + // expedition row hasn't been flipped. Skip silently rather than + // DM about a place they're no longer in. + if e.RunID != "" { + if run, _ := getZoneRun(e.RunID); run != nil && !run.IsActive() { + continue + } + } uid := id.UserID(e.UserID) if hasActiveCombatSession(uid) { continue