Adv 2.0 D&D Phase R R1: Legacy activity-loop deprecation

Retires the standalone !adventure dungeon/mine/forage/fish daily loop in
favor of !expedition. Gate intercepts legacy 1/2/3/4 + word-form input
and DMs a deprecation notice; town services (shop/blacksmith/rest/thom)
stay on !adventure.

- Delete dead resolveActivity (269 lines) + parseActivityLocation; add
  isLegacyActivityInput + renderLegacyActivityDeprecation.
- Morning-DM menu rewritten to point at !expedition and the !forage etc.
  harvest commands; writeAdvLocationLines removed (sole caller).
- New dnd_r1_migration.go runs archiveOrphanZoneRuns at Init: archives
  any active dnd_zone_run not linked to an active expedition (via
  exp.run_id or region_state.region_runs). Idempotent.
- Pre-existing flake in TestPickEveningRecap_BossOverridesAll fixed
  (E6c pool added entries without literal "boss"; widen substring set).
- Internal helpers (resolveAdvAction, advEligibleLocations, AdvLocation
  tier data, consumable drops) preserved — babysit/scheduler still use
  them passively.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
prosolis
2026-05-08 18:58:05 -07:00
parent c3083637ac
commit 8a1d9a16ce
5 changed files with 355 additions and 372 deletions

View File

@@ -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 <category>`" + ` — View a category (weapon, armor, helmet, boots, tool)
` + "`!adventure buy <item>`" + ` — 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 <zone>` — 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 {

View File

@@ -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 <zone>` — 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()
}

View File

@@ -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)
}
}

View File

@@ -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, &regionJSON); 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()
}

View File

@@ -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)
}
}
}