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) <noreply@anthropic.com>
This commit is contained in:
prosolis
2026-05-16 13:48:52 -07:00
parent 98ba416359
commit dcc039b7d5
5 changed files with 34 additions and 0 deletions

View File

@@ -267,6 +267,7 @@ func (p *AdventurePlugin) finishCombatSession(userID id.UserID, sess *CombatSess
_, _ = applyMoodEvent(sess.RunID, MoodEventPlayerDeath) _, _ = applyMoodEvent(sess.RunID, MoodEventPlayerDeath)
} }
_ = abandonZoneRun(userID) _ = abandonZoneRun(userID)
forceExtractExpeditionForRunLoss(userID, "combat death")
markAdventureDead(userID, "zone", zone.Display) markAdventureDead(userID, "zone", zone.Display)
if line := twinBeeLine(zone.ID, DMPlayerDeath, sess.RunID, cadence); line != "" { if line := twinBeeLine(zone.ID, DMPlayerDeath, sess.RunID, cadence); line != "" {
b.WriteString(line + "\n") 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 // but no death timer. Chosen candidate from the migration plan's
// open question on flee outcome. // open question on flee outcome.
_ = abandonZoneRun(userID) _ = 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)) b.WriteString(fmt.Sprintf("🏃 You broke off from **%s** and slipped away. Run ended — you keep your wounds, but you live.", enemy.Name))
default: default:

View File

@@ -188,6 +188,7 @@ func (p *AdventurePlugin) runHarvestInterrupt(
_, _ = applyMoodEvent(run.RunID, MoodEventPlayerDeath) _, _ = applyMoodEvent(run.RunID, MoodEventPlayerDeath)
_ = abandonZoneRun(userID) _ = abandonZoneRun(userID)
_ = retireAllRegionRuns(exp) _ = retireAllRegionRuns(exp)
_, _, _ = forcedExtractExpedition(exp.ID, "interrupt death")
markAdventureDead(userID, "expedition", zone.Display) markAdventureDead(userID, "expedition", zone.Display)
if line := flavor.Pick(flavor.PlayerDeath); line != "" { if line := flavor.Pick(flavor.PlayerDeath); line != "" {
b.WriteString(line) b.WriteString(line)
@@ -513,6 +514,7 @@ func (p *AdventurePlugin) tryPatrolEncounter(
_, _ = applyMoodEvent(run.RunID, MoodEventPlayerDeath) _, _ = applyMoodEvent(run.RunID, MoodEventPlayerDeath)
_ = abandonZoneRun(userID) _ = abandonZoneRun(userID)
_ = retireAllRegionRuns(exp) _ = retireAllRegionRuns(exp)
_, _, _ = forcedExtractExpedition(exp.ID, "patrol death")
markAdventureDead(userID, "patrol", zone.Display) markAdventureDead(userID, "patrol", zone.Display)
if line := flavor.Pick(flavor.PlayerDeath); line != "" { if line := flavor.Pick(flavor.PlayerDeath); line != "" {
ob.WriteString(line) ob.WriteString(line)

View File

@@ -5,6 +5,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"log/slog"
"strings" "strings"
"time" "time"
@@ -92,6 +93,23 @@ func forcedExtractExpedition(expID, reason string) (*Expedition, int, error) {
return e, tax, nil 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 // getResumableExpedition returns the most recent 'extracting' row for the
// user, regardless of age (caller checks the 7-day window). // user, regardless of age (caller checks the 7-day window).
func getResumableExpedition(userID id.UserID) (*Expedition, error) { func getResumableExpedition(userID id.UserID) (*Expedition, error) {

View File

@@ -861,6 +861,9 @@ func (p *AdventurePlugin) resolveCombatRoom(userID id.UserID, run *DungeonRun, z
// respawn timer for what is mechanically "ran out the clock". // respawn timer for what is mechanically "ran out the clock".
if !result.TimedOut { if !result.TimedOut {
markAdventureDead(userID, "zone", zone.Display) 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 != "" { if line := twinBeeLine(zone.ID, DMPlayerDeath, run.RunID, narrationCadence(run)); line != "" {
ob.WriteString(line) ob.WriteString(line)

View File

@@ -84,6 +84,15 @@ func (p *AdventurePlugin) fireExpeditionAmbients(now time.Time) {
if e.Status != ExpeditionStatusActive { if e.Status != ExpeditionStatusActive {
continue 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) uid := id.UserID(e.UserID)
if hasActiveCombatSession(uid) { if hasActiveCombatSession(uid) {
continue continue