Restore expedition completion on zone clear (lost in merge)

finalizeExpeditionOnZoneClear and its wiring into the run-complete seam
were introduced in 73b7809 but that commit never made it into the
current history — it's not an ancestor of HEAD, so the function, its
call site in advanceOnceWithOpts, and its tests were all absent.

Symptom: clearing a zone (boss down, no outgoing edges) set the run's
boss_defeated flag but never flipped the wrapping expedition to
'complete' or retired the run. The expedition sat 'active' pointing at a
boss_defeated run, so getActiveZoneRun (filters boss_defeated=0) returned
nil and the next '!expedition run' errored with 'No active zone run'. The
ambient ticker also kept DMing about a finished dungeon.

Re-apply the bridge verbatim against current HEAD (deps verified present:
IsMultiRegionZone / CurrentRegion / MarkRegionBossDefeated /
completeExpedition / retireAllRegionRuns / AwardCompletionMilestones) and
restore the test.
This commit is contained in:
prosolis
2026-05-21 23:40:54 -07:00
parent 978dc5e25f
commit 513cf32e42
3 changed files with 186 additions and 0 deletions

View File

@@ -110,6 +110,66 @@ func forceExtractExpeditionForRunLoss(userID id.UserID, reason string) {
}
}
// finalizeExpeditionOnZoneClear bridges the run-complete seam (boss down,
// no outgoing edges) into expedition completion — the success-path twin of
// forceExtractExpeditionForRunLoss. When the cleared run is the active
// expedition's current run AND the clear finishes the whole zone (a
// single-region zone, or the zone-boss region of a multi-region zone), it
// flips the expedition to 'complete', records boss_defeated, and awards
// completion milestones. Returns the rendered milestone lines for the caller
// to append to the run-complete message.
//
// No-op (nil) for standalone runs and for mid-zone region clears, which
// leave the expedition active so inter-region travel can continue. Without
// this, a cleared zone leaves the expedition 'active' forever and the
// ambient ticker keeps DMing about a dungeon the player already finished.
func (p *AdventurePlugin) finalizeExpeditionOnZoneClear(userID id.UserID, runID string) []string {
exp, err := getActiveExpedition(userID)
if err != nil || exp == nil {
return nil
}
if exp.RunID != runID {
return nil // the completed run isn't this expedition's current run
}
if IsMultiRegionZone(exp.ZoneID) {
region, ok := CurrentRegion(exp)
if !ok {
return nil
}
if _, err := MarkRegionBossDefeated(exp, region.ID); err != nil {
slog.Warn("expedition: mark region boss defeated",
"user", userID, "expedition", exp.ID, "region", region.ID, "err", err)
}
if !region.IsZoneBoss {
return nil // region cleared; expedition continues to the next region
}
} else {
// Single-region zone: there's no region registry to flip through
// MarkRegionBossDefeated, so set the zone-level flag directly.
exp.BossDefeated = true
if _, err := db.Get().Exec(`
UPDATE dnd_expedition
SET boss_defeated = 1,
last_activity = CURRENT_TIMESTAMP
WHERE expedition_id = ?`, exp.ID); err != nil {
slog.Warn("expedition: set boss defeated on zone clear",
"user", userID, "expedition", exp.ID, "err", err)
}
}
// completeExpedition must run before AwardCompletionMilestones — the
// latter gates on status == 'complete'.
if err := completeExpedition(exp.ID, ExpeditionStatusComplete); err != nil {
slog.Warn("expedition: complete on zone clear",
"user", userID, "expedition", exp.ID, "err", err)
return nil
}
exp.Status = ExpeditionStatusComplete
_ = retireAllRegionRuns(exp)
return p.AwardCompletionMilestones(exp, false)
}
// 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) {

View File

@@ -0,0 +1,116 @@
package plugin
import (
"testing"
"maunium.net/go/mautrix/id"
)
// Single-region zone: clearing the run completes the wrapping expedition
// and flips BossDefeated.
func TestFinalizeExpeditionOnZoneClear_SingleRegionCompletes(t *testing.T) {
setupZoneRunTestDB(t)
uid := id.UserID("@zclear-single:example.org")
defer cleanupExpeditions(uid)
exp, err := startExpedition(uid, ZoneSunkenTemple, "run-1",
ExpeditionSupplies{Max: 10, Current: 10, DailyBurn: 1})
if err != nil {
t.Fatal(err)
}
if exp.RunID != "run-1" {
t.Fatalf("setup: RunID = %q", exp.RunID)
}
p := &AdventurePlugin{}
p.finalizeExpeditionOnZoneClear(uid, "run-1")
if act, _ := getActiveExpedition(uid); act != nil {
t.Fatalf("expedition still active after zone clear: status=%s", act.Status)
}
loaded, _ := getExpedition(exp.ID)
if loaded.Status != ExpeditionStatusComplete {
t.Errorf("status = %q, want %q", loaded.Status, ExpeditionStatusComplete)
}
if !loaded.BossDefeated {
t.Error("BossDefeated not set after single-region zone clear")
}
}
// A run that isn't the expedition's current run must not complete it.
func TestFinalizeExpeditionOnZoneClear_RunMismatchNoop(t *testing.T) {
setupZoneRunTestDB(t)
uid := id.UserID("@zclear-mismatch:example.org")
defer cleanupExpeditions(uid)
exp, err := startExpedition(uid, ZoneSunkenTemple, "run-1",
ExpeditionSupplies{Max: 10, Current: 10, DailyBurn: 1})
if err != nil {
t.Fatal(err)
}
p := &AdventurePlugin{}
p.finalizeExpeditionOnZoneClear(uid, "some-other-run")
loaded, _ := getExpedition(exp.ID)
if loaded.Status != ExpeditionStatusActive {
t.Errorf("status = %q, want active (mismatched run should be no-op)", loaded.Status)
}
}
// Multi-region zone: clearing a non-boss region marks it cleared but leaves
// the expedition active so inter-region travel can continue.
func TestFinalizeExpeditionOnZoneClear_MidZoneRegionStaysActive(t *testing.T) {
setupZoneRunTestDB(t)
uid := id.UserID("@zclear-midzone:example.org")
defer cleanupExpeditions(uid)
// CurrentRegion defaults to the first region (underdark_surface_tunnels,
// not the zone boss).
exp, err := startExpedition(uid, ZoneUnderdark, "run-1",
ExpeditionSupplies{Max: 10, Current: 10, DailyBurn: 1})
if err != nil {
t.Fatal(err)
}
p := &AdventurePlugin{}
p.finalizeExpeditionOnZoneClear(uid, "run-1")
loaded, _ := getExpedition(exp.ID)
if loaded.Status != ExpeditionStatusActive {
t.Errorf("status = %q, want active after non-boss region clear", loaded.Status)
}
if !IsRegionCleared(loaded, "underdark_surface_tunnels") {
t.Error("first region not marked cleared")
}
if loaded.BossDefeated {
t.Error("BossDefeated set on a non-boss region clear")
}
}
// Multi-region zone: clearing the zone-boss region completes the expedition.
func TestFinalizeExpeditionOnZoneClear_ZoneBossRegionCompletes(t *testing.T) {
setupZoneRunTestDB(t)
uid := id.UserID("@zclear-zoneboss:example.org")
defer cleanupExpeditions(uid)
exp, err := startExpedition(uid, ZoneUnderdark, "run-1",
ExpeditionSupplies{Max: 10, Current: 10, DailyBurn: 1})
if err != nil {
t.Fatal(err)
}
if err := setCurrentRegion(exp, "underdark_deep_throne"); err != nil {
t.Fatal(err)
}
p := &AdventurePlugin{}
p.finalizeExpeditionOnZoneClear(uid, "run-1")
loaded, _ := getExpedition(exp.ID)
if loaded.Status != ExpeditionStatusComplete {
t.Errorf("status = %q, want complete after zone-boss region clear", loaded.Status)
}
if !loaded.BossDefeated {
t.Error("BossDefeated not set after zone-boss region clear")
}
}

View File

@@ -604,6 +604,16 @@ func (p *AdventurePlugin) advanceOnceWithOpts(ctx MessageContext, compact bool)
b.WriteString("• " + id + "\n")
}
}
// Success-path expedition close-out: flip the wrapping expedition to
// 'complete' (when this clear finishes the whole zone) and surface any
// completion milestones. No-op for standalone runs / mid-zone region
// clears.
if lines := p.finalizeExpeditionOnZoneClear(ctx.Sender, run.RunID); len(lines) > 0 {
b.WriteString("\n")
for _, line := range lines {
b.WriteString(line)
}
}
return advanceResult{
preStream: preStream,
intro: intro,