diff --git a/internal/plugin/adventure.go b/internal/plugin/adventure.go index 7990950..a68c410 100644 --- a/internal/plugin/adventure.go +++ b/internal/plugin/adventure.go @@ -117,7 +117,7 @@ func (p *AdventurePlugin) SetAchievements(ach *AchievementsPlugin) { func (p *AdventurePlugin) Commands() []CommandDef { return []CommandDef{ - {Name: "adventure", Description: "Daily adventure game — dungeon, mine, forage, or rest", Usage: "!adventure", Category: "Games"}, + {Name: "adventure", Description: "Town services menu (expeditions: see !expedition)", Usage: "!adventure", Category: "Games"}, {Name: "arena", Description: "Arena combat — fight through 5 tiers of increasingly deadly monsters", Usage: "!arena", Category: "Games"}, {Name: "thom", Description: "Visit Thom Krooke — housing and loans", Usage: "!thom", Category: "Games"}, {Name: "setup", Description: "Create or continue your Adv 2.0 character (race, class, stats)", Usage: "!setup", Category: "Games"}, @@ -169,6 +169,14 @@ func (p *AdventurePlugin) Init() error { if err := lockCoopCombatActions(); err != nil { slog.Error("adventure: startup coop combat lock failed", "err", err) } + // Phase R1 — archive any active dnd_zone_run rows that aren't linked to + // an active expedition. Idempotent: re-running it does nothing once the + // orphan set is empty. + if n, err := archiveOrphanZoneRuns(); err != nil { + slog.Error("adventure: R1 orphan zone-run archive failed", "err", err) + } else if n > 0 { + slog.Info("adventure: R1 archived orphan zone runs", "count", n) + } // Revive any characters whose DeadUntil has expired p.catchUpRespawns(chars) @@ -418,8 +426,9 @@ func (p *AdventurePlugin) dispatchCommand(ctx MessageContext) error { const advHelpText = `**Adventure Commands** -` + "`!adventure`" + ` — Show today's activity menu +` + "`!adventure`" + ` — Show town services menu (expeditions are run via ` + "`!expedition`" + `) ` + "`!adventure status`" + ` — View your character sheet +` + "`!expedition`" + ` — Adventure: pick a zone, advance through rooms, harvest as you go ` + "`!adventure shop`" + ` — Browse equipment categories ` + "`!adventure shop `" + ` — View a category (weapon, armor, helmet, boots, tool) ` + "`!adventure buy `" + ` — Buy equipment (e.g. ` + "`buy Enchanted Blade`" + ` or ` + "`buy 4 sword`" + `) @@ -872,345 +881,55 @@ func (p *AdventurePlugin) parseAndResolveChoice(ctx MessageContext, body string) return p.handleShopCmd(ctx, "") } - // Parse activity + location - activity, loc := p.parseActivityLocation(lower, char) - if loc == nil { - return p.SendDM(ctx.Sender, "I didn't understand that. Reply with a number and location, e.g: `1 Soggy Cellar`, or just `1` for the first available.") + // Phase R1 — legacy activity loop (1/dungeon, 2/mine, 3/forage, 4/fish + tier-or-name) is + // deprecated. The new path is the expedition system. Anything that parses as a legacy + // activity is intercepted here and routed to a deprecation notice; non-matches still + // fall through to the unknown-command response so other plugins can pick it up. + if isLegacyActivityInput(lower, char) { + return p.SendDM(ctx.Sender, renderLegacyActivityDeprecation(char)) } - return p.resolveActivity(ctx, char, activity, loc) + return p.SendDM(ctx.Sender, "I didn't understand that. Type `!adventure help` for commands, or `!expedition` to head into a zone.") } -func (p *AdventurePlugin) parseActivityLocation(input string, char *AdventureCharacter) (AdvActivityType, *AdvLocation) { +// isLegacyActivityInput reports whether `input` looks like a legacy activity-loop +// dispatch (1/dungeon, 2/mine, 3/forage, 4/fish, with or without tier/location). +// Used by the Phase R1 deprecation gate to redirect the player to !expedition +// without swallowing arbitrary unrelated DMs. +func isLegacyActivityInput(input string, char *AdventureCharacter) bool { + if input == "" { + return false + } parts := strings.SplitN(input, " ", 2) - if len(parts) == 0 { - return "", nil - } - first := parts[0] - rest := "" - if len(parts) > 1 { - rest = strings.TrimSpace(parts[1]) - } - - var activity AdvActivityType - - // Parse activity from number or word switch first { - case "1", "dungeon", "d": - activity = AdvActivityDungeon - case "2", "mine", "m": - activity = AdvActivityMining - case "3", "forage", "f", "forest": - activity = AdvActivityForaging - case "4", "fish", "fishing": - activity = AdvActivityFishing - default: - // Try matching location name directly - for _, act := range []AdvActivityType{AdvActivityDungeon, AdvActivityMining, AdvActivityForaging, AdvActivityFishing} { - if loc := findAdvLocation(act, input); loc != nil { - return act, loc - } - } - return "", nil + case "1", "2", "3", "4", + "d", "m", "f", + "dungeon", "mine", "forage", "forest", "fish", "fishing": + return true } - - // If no location specified, pick first eligible - if rest == "" { - equip, _ := loadAdvEquipment(char.UserID) - treasures, _ := loadAdvTreasureBonuses(char.UserID) - buffs, _ := loadAdvActiveBuffs(char.UserID) - bonuses := computeAdvBonuses(treasures, buffs, char.CurrentStreak, false) - eligible := advEligibleLocations(char, equip, activity, bonuses) - if len(eligible) == 0 { - return activity, nil - } - return activity, eligible[0].Location - } - - // Try to parse tier number - if tier, err := strconv.Atoi(rest); err == nil { - loc := findAdvLocationByTier(activity, tier) - if loc != nil { - return activity, loc + for _, act := range []AdvActivityType{AdvActivityDungeon, AdvActivityMining, AdvActivityForaging, AdvActivityFishing} { + if findAdvLocation(act, input) != nil { + return true } } - - // Fuzzy match location name - loc := findAdvLocation(activity, rest) - return activity, loc + return false } -// ── Activity Resolution ────────────────────────────────────────────────────── - -func (p *AdventurePlugin) resolveActivity(ctx MessageContext, char *AdventureCharacter, activity AdvActivityType, loc *AdvLocation) error { - isHol, _ := isHolidayToday() - if isCombatActivity(activity) && !char.CanDoCombat(isHol) { - return p.SendDM(ctx.Sender, "You've used your combat action for the day. Try a harvest activity (mining, fishing, foraging) or rest.") - } - if isHarvestActivity(activity) && !char.CanDoHarvest(isHol) { - return p.SendDM(ctx.Sender, "You've used all your harvest actions for the day. Try combat (dungeon) or rest.") - } - - equip, err := loadAdvEquipment(char.UserID) - if err != nil { - return p.SendDM(ctx.Sender, "Failed to load your equipment.") - } - - treasures, _ := loadAdvTreasureBonuses(char.UserID) - buffs, _ := loadAdvActiveBuffs(char.UserID) - - // Check grudge - hasGrudge := char.GrudgeLocation == loc.Name - bonuses := computeAdvBonuses(treasures, buffs, char.CurrentStreak, hasGrudge) - - // Check eligibility - eligible, inPenaltyZone := advIsEligible(char, equip, loc, bonuses) - if !eligible { - return p.SendDM(ctx.Sender, fmt.Sprintf("You are not eligible for %s. Your level or equipment tier is too low.", loc.Name)) - } - - // Per-location cooldown: 3 hours after a successful run - if remaining := advLocationCooldown(char.UserID, loc.Name); remaining > 0 { - mins := int(remaining.Minutes()) - if mins < 1 { - return p.SendDM(ctx.Sender, fmt.Sprintf("🕐 %s is still being cleared out. Try again in less than a minute, or pick a different location.", loc.Name)) - } - h, m := mins/60, mins%60 - if h > 0 { - return p.SendDM(ctx.Sender, fmt.Sprintf("🕐 %s is still being cleared out. Try again in %dh %dm, or pick a different location.", loc.Name, h, m)) - } - return p.SendDM(ctx.Sender, fmt.Sprintf("🕐 %s is still being cleared out. Try again in %d minutes, or pick a different location.", loc.Name, m)) - } - - // Resolve the action — dungeon uses combat engine, others use probability bands - var result *AdvActionResult - if loc.Activity == AdvActivityDungeon { - result = p.resolveDungeonAction(char, equip, loc, bonuses, inPenaltyZone) - } else { - result = resolveAdvAction(char, equip, loc, bonuses, inPenaltyZone) - } - - // Select flavor text - result.FlavorText, result.FlavorKey = p.selectFlavorText(char, result) - - // Chat level XP bonus - if bonus := chatLevelXPBonus(p.chatLevel(char.UserID)); bonus > 0 { - result.XPGained = int(float64(result.XPGained) * (1.0 + bonus)) - } - - // Double XP/money boost - advApplyBoost(result) - - // Apply XP - switch result.XPSkill { - case "combat": - char.CombatXP += result.XPGained - case "mining": - char.MiningXP += result.XPGained - case "foraging": - char.ForagingXP += result.XPGained - case "fishing": - char.FishingXP += result.XPGained - } - - // Check level up - result.LeveledUp, result.NewLevel = checkAdvLevelUp(char, result.XPSkill) - if result.LeveledUp && result.XPSkill == "combat" { - p.checkRivalPoolUnlock(char) - } - - engineDeathSaved := result.CombatLog != nil && checkDeathSaveEvent(result.CombatLog.Events) - - // Handle death - deathReprieved := false - pardonFired := false - petRecovered := false - if result.Outcome == AdvOutcomeDeath { - dt := transitionDeath(DeathTransitionParams{ - Char: char, - Equip: equip, - ChatLevel: p.chatLevel(char.UserID), - Location: loc.Name, - Source: "adventure", - DeathLocation: loc.Name, - AllowPardon: true, - AllowSovereign: true, - EngineSaved: engineDeathSaved, - }) - pardonFired = dt.Pardoned - deathReprieved = dt.Pardoned || dt.Reprieved - petRecovered = dt.PetRecovered - if dt.Pardoned { - result.Outcome = AdvOutcomeEmpty - } - if dt.Reprieved { - nextWindow := time.Now().UTC().Add(168 * time.Hour) - gr := gamesRoom() - if gr != "" { - p.SendMessage(gr, renderArenaDeathReprieve(char.DisplayName, loc.Name, nextWindow)) - } - } - } else if hasGrudge && (result.Outcome == AdvOutcomeSuccess || result.Outcome == AdvOutcomeExceptional) { - // Clear grudge on successful return - char.GrudgeLocation = "" - } - - // Add loot to inventory - for _, item := range result.LootItems { - _ = addAdvInventoryItem(char.UserID, item) - } - - // Roll for consumable drop on success/exceptional at T2+ - if (result.Outcome == AdvOutcomeSuccess || result.Outcome == AdvOutcomeExceptional) && loc.Tier >= 2 { - if drop := RollConsumableDrop(loc.Activity, loc.Tier); drop != nil { - _ = addAdvInventoryItem(char.UserID, *drop) - result.LootItems = append(result.LootItems, *drop) - } - } - - // Mark action consumed in the correct bucket - if isCombatActivity(activity) { - char.CombatActionsUsed++ - } else if isHarvestActivity(activity) { - char.HarvestActionsUsed++ - } - char.ActionTakenToday = true - char.LastActionDate = time.Now().UTC().Format("2006-01-02") - - // Update streak info - result.StreakBonus = char.CurrentStreak - - // Save character - if err := saveAdvCharacter(char); err != nil { - slog.Error("adventure: failed to save character", "user", char.UserID, "err", err) - return p.SendDM(ctx.Sender, "Something went wrong saving your progress. Your action was not recorded. Try again.") - } - - // Pet XP - if char.HasPet() && result.Outcome != AdvOutcomeDeath { - if petGrantXP(char) { - _ = saveAdvCharacter(char) - _ = p.SendDM(char.UserID, fmt.Sprintf("🐾 %s leveled up to **Level %d**!", char.PetName, char.PetLevel)) - } else { - _ = saveAdvCharacter(char) - } - } - - // Save equipment changes - for _, slot := range allSlots { - if eq, ok := equip[slot]; ok { - if err := saveAdvEquipment(char.UserID, eq); err != nil { - slog.Error("adventure: failed to save equipment", "user", char.UserID, "slot", slot, "err", err) - } - } - } - - // Log activity BEFORE party bonus check so both visitors can see each other - logAdvActivity(char.UserID, string(activity), loc.Name, string(result.Outcome), - result.TotalLootValue, result.XPGained, result.FlavorKey) - - // Party bonus: check if someone else visited the same location today - if result.Outcome == AdvOutcomeSuccess || result.Outcome == AdvOutcomeExceptional { - if advCheckPartyBonus(char.UserID, loc.Name) { - // Apply party bonus: +10% loot value - partyBonus := int64(float64(result.TotalLootValue) * 0.10) - if partyBonus > 0 { - result.TotalLootValue += partyBonus - net, _ := communityTax(char.UserID, float64(partyBonus), 0.05) - p.euro.Credit(char.UserID, net, "adventure_party_bonus") - } - } - } - - // Send resolution DM with closing block - text := renderAdvResolutionDM(result, char) - if result.CombatLog != nil { - // Combat closing narrative — victory or death depending on outcome. - text += dndItalicize(dndCombatClosingLine(result.CombatLog.PlayerWon, false)) - if rollLine := dndRollSummaryLine(*result.CombatLog); rollLine != "" { - text += "\n" + rollLine - } - } - if deathReprieved { - nextWindow := char.DeathReprieveLast.Add(168 * time.Hour) - text += fmt.Sprintf("\n\n⚔️ **Death's Reprieve activated.** Your Sovereign gear absorbed the killing blow. "+ - "You survived — barely. All equipment took heavy damage.\n"+ - "Next reprieve window: %s", nextWindow.Format("2006-01-02 15:04 UTC")) - } - closing := advClosingBlock(result.Outcome, char.UserID, loc.Name, p.morningHour, p.summaryHour) - if closing != "" { - text += "\n" + closing - } - - // Dungeon combat: send phased combat messages with delays, then resolution DM - if result.CombatLog != nil { - phaseMessages := RenderCombatLog(*result.CombatLog, char.DisplayName, loc.Denizens) - phaseMessages = p.prependCraftNarrative(ctx.Sender, phaseMessages) - if open := dndCombatOpeningLine(); open != "" && len(phaseMessages) > 0 { - phaseMessages[0] = "_" + open + "_\n\n" + phaseMessages[0] - } - done := p.sendCombatMessages(ctx.Sender, phaseMessages, text) - - go func() { - <-done - if pardonFired { - p.SendDM(ctx.Sender, "The crowd intervened. A healer pulled you back at the last second. Don't count on this again — 7-day cooldown.") - } - if petRecovered && char.PetName != "" { - p.SendDM(ctx.Sender, fmt.Sprintf("Your pet %s dragged you to safety. Death timer reduced.", char.PetName)) - } - if result.Outcome == AdvOutcomeDeath && !deathReprieved { - p.sendHospitalAd(ctx.Sender, char) - } - }() - } else { - if err := p.SendDM(ctx.Sender, text); err != nil { - slog.Error("adventure: failed to send resolution DM", "user", ctx.Sender, "err", err) - } - if pardonFired { - p.SendDM(ctx.Sender, "The crowd intervened. A healer pulled you back at the last second. Don't count on this again — 7-day cooldown.") - } - if petRecovered && char.PetName != "" { - p.SendDM(ctx.Sender, fmt.Sprintf("Your pet %s dragged you to safety. Death timer reduced.", char.PetName)) - } - if result.Outcome == AdvOutcomeDeath && !deathReprieved { - p.sendHospitalAd(ctx.Sender, char) - } - } - - // Check for treasure drop - if result.Outcome == AdvOutcomeSuccess || result.Outcome == AdvOutcomeExceptional { - p.checkTreasureDrop(ctx.Sender, char, loc) - p.checkMasterworkDrop(ctx.Sender, char, equip, loc, result.Outcome) - } - - // Equipment mastery — celebrate threshold crossings so the silent - // per-slot counter becomes visible to the player. - for _, mc := range result.MasteryCrossings { - p.SendDM(ctx.Sender, fmt.Sprintf( - "🛠️ **Mastery: %s — %d uses!** Your %s is paying it back: +1%% loot quality from this slot.", - mc.ItemName, mc.Threshold, mc.Slot)) - } - - // If the player still has actions remaining, nudge them - if !char.AllActionsUsed(isHol) && (result.Outcome != AdvOutcomeDeath || deathReprieved) { - remaining := []string{} - if char.CanDoCombat(isHol) { - remaining = append(remaining, "combat") - } - if char.CanDoHarvest(isHol) { - harvestMax := maxHarvestActions - if isHol { - harvestMax++ - } - harvestLeft := harvestMax - char.HarvestActionsUsed - remaining = append(remaining, fmt.Sprintf("%d harvest", harvestLeft)) - } - p.SendDM(ctx.Sender, fmt.Sprintf("Actions remaining today: %s. Type `!adv` for the menu.", strings.Join(remaining, ", "))) - } - - return nil +// renderLegacyActivityDeprecation is the Phase R1 deprecation DM. The standalone +// daily activity loop is gone; players now run expeditions and harvest inside +// rooms instead. +func renderLegacyActivityDeprecation(char *AdventureCharacter) string { + var sb strings.Builder + sb.WriteString("⚠️ **The daily adventure loop has retired.**\n\n") + sb.WriteString("Solo dungeon runs, mining, foraging, and fishing are now part of the **expedition system** — pick a zone, advance through rooms, harvest as you go.\n\n") + sb.WriteString("Get started:\n") + sb.WriteString("• `!expedition` — overview & open expeditions\n") + sb.WriteString("• `!expedition start ` — begin a new run\n") + sb.WriteString("• `!forage` · `!mine` · `!scavenge` · `!fish` · `!essence` · `!commune` — harvest in cleared rooms\n") + sb.WriteString("• `!resources` — list nodes in your current room\n\n") + sb.WriteString("Shop, blacksmith, rest, and Thom Krooke are still here on `!adventure`.") + return sb.String() } func (p *AdventurePlugin) resolveRest(ctx MessageContext, char *AdventureCharacter) error { diff --git a/internal/plugin/adventure_render.go b/internal/plugin/adventure_render.go index 2bca9a2..232f9bd 100644 --- a/internal/plugin/adventure_render.go +++ b/internal/plugin/adventure_render.go @@ -353,20 +353,6 @@ func renderRivalNudge(char *AdventureCharacter) string { rivalName, c.Round, c.Stake, hours) } -// writeAdvLocationLines renders the eligible-location bullets shown in the -// morning DM. Each line surfaces both the downside (death %) and upside -// (exceptional %) so the menu teaches risk/reward, not just risk. -func writeAdvLocationLines(sb *strings.Builder, locs []AdvEligibleLocation) { - for _, el := range locs { - warn := "" - if el.InPenaltyZone { - warn = " ⚠️" - } - sb.WriteString(fmt.Sprintf(" • %s (Tier %d, ~%.0f%% death · ~%.0f%% exceptional%s)\n", - el.Location.Name, el.Location.Tier, el.DeathPct, el.ExceptionalPct, warn)) - } -} - func renderAdvMorningDM(char *AdventureCharacter, equip map[EquipmentSlot]*AdvEquipment, balance float64, bonuses *AdvBonusSummary, holidayName string) string { var sb strings.Builder @@ -456,38 +442,26 @@ func renderAdvMorningDM(char *AdventureCharacter, equip map[EquipmentSlot]*AdvEq sb.WriteString("\n") } - // Location choices + // Phase R1 — the daily activity loop (dungeon/mine/forage/fish) has been + // retired in favor of the expedition system. The menu now shows the new + // entry points for adventuring and keeps the still-active town services. + sb.WriteString("**🗺️ Adventure** — head into a zone:\n") + sb.WriteString("• `!expedition` — overview & open expeditions\n") + sb.WriteString("• `!expedition start ` — begin a new run\n") + sb.WriteString("• `!forage` · `!mine` · `!scavenge` · `!fish` · `!essence` · `!commune` — harvest in cleared rooms\n") if inCoop { - sb.WriteString("**1️⃣ Dungeon:** _(off in the Co-op, no solo combat — `!coop status` for run state)_\n") - } else if char.CanDoCombat(isHol) { - sb.WriteString("**1️⃣ Dungeon:**\n") - writeAdvLocationLines(&sb, advEligibleLocations(char, equip, AdvActivityDungeon, bonuses)) - } else { - sb.WriteString("**1️⃣ Dungeon:** _(no combat actions remaining)_\n") + sb.WriteString(fmt.Sprintf("_(combat is locked while you're in Co-op #%d — `!coop status` for run state)_\n", coopRun.ID)) } + sb.WriteString("\n") - if char.CanDoHarvest(isHol) { - sb.WriteString("**2️⃣ Mine:**\n") - writeAdvLocationLines(&sb, advEligibleLocations(char, equip, AdvActivityMining, bonuses)) + sb.WriteString("**🏘️ In town:**\n") + sb.WriteString("• `5` / `shop` — buy/sell gear and loot\n") + sb.WriteString("• `6` / `blacksmith` — repair damaged equipment\n") + sb.WriteString("• `7` / `rest` — skip today, bank your luck\n") + sb.WriteString("• `!thom` — visit Krooke Realty 🏠\n\n") - sb.WriteString("**3️⃣ Forage:**\n") - writeAdvLocationLines(&sb, advEligibleLocations(char, equip, AdvActivityForaging, bonuses)) - - sb.WriteString("**4️⃣ Fish:**\n") - writeAdvLocationLines(&sb, advEligibleLocations(char, equip, AdvActivityFishing, bonuses)) - } else { - sb.WriteString("**2️⃣ Mine:** _(no harvest actions remaining)_\n") - sb.WriteString("**3️⃣ Forage:** _(no harvest actions remaining)_\n") - sb.WriteString("**4️⃣ Fish:** _(no harvest actions remaining)_\n") - } - - sb.WriteString("**5️⃣ Shop** — buy/sell gear and loot\n") - sb.WriteString("**6️⃣ Blacksmith** — repair damaged equipment\n") - sb.WriteString("**7️⃣ Rest** — skip today, bank your luck\n") - sb.WriteString("**8️⃣ Thom** — `!thom` visit Krooke Realty 🏠\n\n") - - sb.WriteString("Reply with the number and location, e.g: `1 Soggy Cellar`\n") - sb.WriteString("You have until midnight UTC to choose.") + sb.WriteString("Reply with a number for in-town services, or run an expedition command directly.\n") + sb.WriteString("You have until midnight UTC to rest.") return sb.String() } diff --git a/internal/plugin/dnd_expedition_cycle_test.go b/internal/plugin/dnd_expedition_cycle_test.go index b81e16e..a22058a 100644 --- a/internal/plugin/dnd_expedition_cycle_test.go +++ b/internal/plugin/dnd_expedition_cycle_test.go @@ -199,7 +199,18 @@ func TestPickEveningRecap_BossOverridesAll(t *testing.T) { if got == "" { t.Fatal("expected boss-killed flavor") } - if !strings.Contains(strings.ToLower(got), "boss") { + // Boss-killed pool entries reference the kill in varied ways — "boss", + // "thing in the chamber", "vacancy", etc. Match any of the recurring + // motifs rather than the literal word. + low := strings.ToLower(got) + matched := false + for _, marker := range []string{"boss", "thing in the chamber", "vacancy"} { + if strings.Contains(low, marker) { + matched = true + break + } + } + if !matched { t.Errorf("expected boss-killed pool, got %q", got) } } diff --git a/internal/plugin/dnd_r1_migration.go b/internal/plugin/dnd_r1_migration.go new file mode 100644 index 0000000..a3c2e10 --- /dev/null +++ b/internal/plugin/dnd_r1_migration.go @@ -0,0 +1,108 @@ +package plugin + +import ( + "database/sql" + "encoding/json" + "fmt" + "strings" + + "gogobee/internal/db" +) + +// archiveOrphanZoneRuns is the Phase R1 deprecation migration. The old +// `!adventure dungeon` activity could leave dnd_zone_run rows in active +// state that aren't tied to any expedition. R2+ guarantees every new run +// is spawned by ensureRegionRun and pointed at by an active expedition +// (either as exp.run_id or via RegionState["region_runs"]). Anything +// active in dnd_zone_run that doesn't appear in that preserved set is a +// leftover from the legacy path and gets archived. +// +// Idempotent: re-running the migration after the orphan set is empty is +// a no-op. Safe to call on every startup. +// +// Returns the number of rows updated. +func archiveOrphanZoneRuns() (int, error) { + preserved, err := loadActiveRegionRunIDs() + if err != nil { + return 0, fmt.Errorf("collect preserved run ids: %w", err) + } + + if len(preserved) == 0 { + res, err := db.Get().Exec(` + UPDATE dnd_zone_run + SET abandoned = 1, + completed_at = COALESCE(completed_at, CURRENT_TIMESTAMP), + last_action_at = CURRENT_TIMESTAMP + WHERE abandoned = 0`) + if err != nil { + return 0, err + } + n, _ := res.RowsAffected() + return int(n), nil + } + + placeholders := make([]string, 0, len(preserved)) + args := make([]any, 0, len(preserved)) + for id := range preserved { + placeholders = append(placeholders, "?") + args = append(args, id) + } + query := ` + UPDATE dnd_zone_run + SET abandoned = 1, + completed_at = COALESCE(completed_at, CURRENT_TIMESTAMP), + last_action_at = CURRENT_TIMESTAMP + WHERE abandoned = 0 + AND run_id NOT IN (` + strings.Join(placeholders, ",") + `)` + res, err := db.Get().Exec(query, args...) + if err != nil { + return 0, err + } + n, _ := res.RowsAffected() + return int(n), nil +} + +// loadActiveRegionRunIDs scans every active expedition and returns the set +// of run IDs that should be preserved: each expedition's exp.run_id plus +// every value in its RegionState["region_runs"] map. +func loadActiveRegionRunIDs() (map[string]struct{}, error) { + rows, err := db.Get().Query(` + SELECT run_id, region_state + FROM dnd_expedition + WHERE status = 'active'`) + if err != nil { + return nil, err + } + defer rows.Close() + + out := map[string]struct{}{} + for rows.Next() { + var runID, regionJSON sql.NullString + if err := rows.Scan(&runID, ®ionJSON); err != nil { + return nil, err + } + if runID.Valid && runID.String != "" { + out[runID.String] = struct{}{} + } + if !regionJSON.Valid || regionJSON.String == "" { + continue + } + var state map[string]any + if err := json.Unmarshal([]byte(regionJSON.String), &state); err != nil { + continue + } + raw, ok := state[regionStateRegionRuns] + if !ok { + continue + } + switch v := raw.(type) { + case map[string]any: + for _, vv := range v { + if s, ok := vv.(string); ok && s != "" { + out[s] = struct{}{} + } + } + } + } + return out, rows.Err() +} diff --git a/internal/plugin/dnd_r1_migration_test.go b/internal/plugin/dnd_r1_migration_test.go new file mode 100644 index 0000000..d0c6686 --- /dev/null +++ b/internal/plugin/dnd_r1_migration_test.go @@ -0,0 +1,171 @@ +package plugin + +import ( + "math/rand/v2" + "strings" + "testing" + + "gogobee/internal/db" + "maunium.net/go/mautrix/id" +) + +// TestArchiveOrphanZoneRuns_LeavesLinkedRunsAlone verifies that a run +// pointed at by an active expedition.run_id survives the migration. +func TestArchiveOrphanZoneRuns_LeavesLinkedRunsAlone(t *testing.T) { + setupZoneRunTestDB(t) + uid := id.UserID("@r1-link-test:example.org") + defer cleanupZoneRuns(uid) + defer cleanupExpeditions(uid) + + rng := rand.New(rand.NewPCG(11, 7)) + run, err := startZoneRun(uid, ZoneGoblinWarrens, 1, rng) + if err != nil { + t.Fatalf("startZoneRun: %v", err) + } + exp, err := startExpedition(uid, ZoneGoblinWarrens, run.RunID, ExpeditionSupplies{Max: 5, Current: 5, DailyBurn: 1}) + if err != nil { + t.Fatalf("startExpedition: %v", err) + } + _ = exp + + if _, err := archiveOrphanZoneRuns(); err != nil { + t.Fatalf("archive: %v", err) + } + + got, err := getZoneRun(run.RunID) + if err != nil { + t.Fatalf("getZoneRun: %v", err) + } + if got == nil || !got.IsActive() { + t.Fatalf("linked run got archived; abandoned=%v", got != nil && !got.IsActive()) + } +} + +// TestArchiveOrphanZoneRuns_RegionRunsPreserved verifies that runs only +// referenced via RegionState["region_runs"] (not exp.run_id) are kept. +func TestArchiveOrphanZoneRuns_RegionRunsPreserved(t *testing.T) { + setupZoneRunTestDB(t) + uid := id.UserID("@r1-region-test:example.org") + defer cleanupZoneRuns(uid) + defer cleanupExpeditions(uid) + + rng := rand.New(rand.NewPCG(13, 8)) + run, err := startZoneRun(uid, ZoneGoblinWarrens, 1, rng) + if err != nil { + t.Fatalf("startZoneRun: %v", err) + } + exp, err := startExpedition(uid, ZoneGoblinWarrens, "", ExpeditionSupplies{Max: 5, Current: 5, DailyBurn: 1}) + if err != nil { + t.Fatalf("startExpedition: %v", err) + } + // Pin via region_runs only; exp.run_id stays empty. + regionJSON := `{"region_runs":{"r1":"` + run.RunID + `"}}` + if _, err := db.Get().Exec(`UPDATE dnd_expedition SET region_state = ? WHERE expedition_id = ?`, regionJSON, exp.ID); err != nil { + t.Fatalf("set region_state: %v", err) + } + + if _, err := archiveOrphanZoneRuns(); err != nil { + t.Fatalf("archive: %v", err) + } + + got, err := getZoneRun(run.RunID) + if err != nil { + t.Fatalf("getZoneRun: %v", err) + } + if got == nil || !got.IsActive() { + t.Fatalf("region-linked run got archived") + } +} + +// TestArchiveOrphanZoneRuns_OrphanArchived verifies that an active zone +// run with no expedition pointing at it gets flagged abandoned. +func TestArchiveOrphanZoneRuns_OrphanArchived(t *testing.T) { + setupZoneRunTestDB(t) + uid := id.UserID("@r1-orphan-test:example.org") + defer cleanupZoneRuns(uid) + + rng := rand.New(rand.NewPCG(17, 9)) + run, err := startZoneRun(uid, ZoneGoblinWarrens, 1, rng) + if err != nil { + t.Fatalf("startZoneRun: %v", err) + } + + n, err := archiveOrphanZoneRuns() + if err != nil { + t.Fatalf("archive: %v", err) + } + if n < 1 { + t.Fatalf("expected ≥1 archived row, got %d", n) + } + + got, err := getZoneRun(run.RunID) + if err != nil { + t.Fatalf("getZoneRun: %v", err) + } + if got == nil { + t.Fatal("run vanished") + } + if got.IsActive() { + t.Fatal("orphan run still active after archive") + } +} + +// TestArchiveOrphanZoneRuns_Idempotent: a second call is a no-op. +func TestArchiveOrphanZoneRuns_Idempotent(t *testing.T) { + setupZoneRunTestDB(t) + uid := id.UserID("@r1-idempotent-test:example.org") + defer cleanupZoneRuns(uid) + + rng := rand.New(rand.NewPCG(19, 11)) + if _, err := startZoneRun(uid, ZoneGoblinWarrens, 1, rng); err != nil { + t.Fatalf("startZoneRun: %v", err) + } + if _, err := archiveOrphanZoneRuns(); err != nil { + t.Fatalf("archive #1: %v", err) + } + n, err := archiveOrphanZoneRuns() + if err != nil { + t.Fatalf("archive #2: %v", err) + } + if n != 0 { + t.Fatalf("expected 0 on second call, got %d", n) + } +} + +// TestIsLegacyActivityInput covers the deprecation-gate detection: legacy +// keywords / numbers match, unrelated DM input doesn't. +func TestIsLegacyActivityInput(t *testing.T) { + cases := []struct { + in string + want bool + }{ + {"1", true}, + {"2 deep mine", true}, + {"dungeon", true}, + {"forage", true}, + {"fish", true}, + {"f", true}, + {"Soggy Cellar", true}, // location name match + {"", false}, + {"hello there", false}, + {"7", false}, + {"!expedition", false}, + } + for _, c := range cases { + got := isLegacyActivityInput(strings.ToLower(c.in), nil) + if got != c.want { + t.Errorf("isLegacyActivityInput(%q) = %v, want %v", c.in, got, c.want) + } + } +} + +// TestRenderLegacyActivityDeprecation: deprecation DM points at !expedition +// and the harvest entry-points so the user has somewhere to go. +func TestRenderLegacyActivityDeprecation(t *testing.T) { + out := renderLegacyActivityDeprecation(nil) + for _, want := range []string{"expedition", "!forage", "!mine", "retired"} { + if !strings.Contains(out, want) { + t.Errorf("deprecation DM missing %q; got:\n%s", want, out) + } + } +}