diff --git a/internal/plugin/dnd_expedition_extract.go b/internal/plugin/dnd_expedition_extract.go index 899059e..6cd971d 100644 --- a/internal/plugin/dnd_expedition_extract.go +++ b/internal/plugin/dnd_expedition_extract.go @@ -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) { diff --git a/internal/plugin/dnd_expedition_zone_clear_test.go b/internal/plugin/dnd_expedition_zone_clear_test.go new file mode 100644 index 0000000..0a052ef --- /dev/null +++ b/internal/plugin/dnd_expedition_zone_clear_test.go @@ -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") + } +} diff --git a/internal/plugin/dnd_zone_cmd.go b/internal/plugin/dnd_zone_cmd.go index f5514f7..41fa98f 100644 --- a/internal/plugin/dnd_zone_cmd.go +++ b/internal/plugin/dnd_zone_cmd.go @@ -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,