mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-16 08:52:42 +00:00
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:
@@ -117,7 +117,7 @@ func (p *AdventurePlugin) SetAchievements(ach *AchievementsPlugin) {
|
|||||||
|
|
||||||
func (p *AdventurePlugin) Commands() []CommandDef {
|
func (p *AdventurePlugin) Commands() []CommandDef {
|
||||||
return []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: "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: "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"},
|
{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 {
|
if err := lockCoopCombatActions(); err != nil {
|
||||||
slog.Error("adventure: startup coop combat lock failed", "err", err)
|
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
|
// Revive any characters whose DeadUntil has expired
|
||||||
p.catchUpRespawns(chars)
|
p.catchUpRespawns(chars)
|
||||||
|
|
||||||
@@ -418,8 +426,9 @@ func (p *AdventurePlugin) dispatchCommand(ctx MessageContext) error {
|
|||||||
|
|
||||||
const advHelpText = `**Adventure Commands**
|
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
|
` + "`!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`" + ` — Browse equipment categories
|
||||||
` + "`!adventure shop <category>`" + ` — View a category (weapon, armor, helmet, boots, tool)
|
` + "`!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`" + `)
|
` + "`!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, "")
|
return p.handleShopCmd(ctx, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse activity + location
|
// Phase R1 — legacy activity loop (1/dungeon, 2/mine, 3/forage, 4/fish + tier-or-name) is
|
||||||
activity, loc := p.parseActivityLocation(lower, char)
|
// deprecated. The new path is the expedition system. Anything that parses as a legacy
|
||||||
if loc == nil {
|
// activity is intercepted here and routed to a deprecation notice; non-matches still
|
||||||
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.")
|
// 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)
|
parts := strings.SplitN(input, " ", 2)
|
||||||
if len(parts) == 0 {
|
|
||||||
return "", nil
|
|
||||||
}
|
|
||||||
|
|
||||||
first := parts[0]
|
first := parts[0]
|
||||||
rest := ""
|
|
||||||
if len(parts) > 1 {
|
|
||||||
rest = strings.TrimSpace(parts[1])
|
|
||||||
}
|
|
||||||
|
|
||||||
var activity AdvActivityType
|
|
||||||
|
|
||||||
// Parse activity from number or word
|
|
||||||
switch first {
|
switch first {
|
||||||
case "1", "dungeon", "d":
|
case "1", "2", "3", "4",
|
||||||
activity = AdvActivityDungeon
|
"d", "m", "f",
|
||||||
case "2", "mine", "m":
|
"dungeon", "mine", "forage", "forest", "fish", "fishing":
|
||||||
activity = AdvActivityMining
|
return true
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
for _, act := range []AdvActivityType{AdvActivityDungeon, AdvActivityMining, AdvActivityForaging, AdvActivityFishing} {
|
||||||
// If no location specified, pick first eligible
|
if findAdvLocation(act, input) != nil {
|
||||||
if rest == "" {
|
return true
|
||||||
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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return false
|
||||||
// Fuzzy match location name
|
|
||||||
loc := findAdvLocation(activity, rest)
|
|
||||||
return activity, loc
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Activity Resolution ──────────────────────────────────────────────────────
|
// renderLegacyActivityDeprecation is the Phase R1 deprecation DM. The standalone
|
||||||
|
// daily activity loop is gone; players now run expeditions and harvest inside
|
||||||
func (p *AdventurePlugin) resolveActivity(ctx MessageContext, char *AdventureCharacter, activity AdvActivityType, loc *AdvLocation) error {
|
// rooms instead.
|
||||||
isHol, _ := isHolidayToday()
|
func renderLegacyActivityDeprecation(char *AdventureCharacter) string {
|
||||||
if isCombatActivity(activity) && !char.CanDoCombat(isHol) {
|
var sb strings.Builder
|
||||||
return p.SendDM(ctx.Sender, "You've used your combat action for the day. Try a harvest activity (mining, fishing, foraging) or rest.")
|
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")
|
||||||
if isHarvestActivity(activity) && !char.CanDoHarvest(isHol) {
|
sb.WriteString("Get started:\n")
|
||||||
return p.SendDM(ctx.Sender, "You've used all your harvest actions for the day. Try combat (dungeon) or rest.")
|
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")
|
||||||
equip, err := loadAdvEquipment(char.UserID)
|
sb.WriteString("• `!resources` — list nodes in your current room\n\n")
|
||||||
if err != nil {
|
sb.WriteString("Shop, blacksmith, rest, and Thom Krooke are still here on `!adventure`.")
|
||||||
return p.SendDM(ctx.Sender, "Failed to load your equipment.")
|
return sb.String()
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *AdventurePlugin) resolveRest(ctx MessageContext, char *AdventureCharacter) error {
|
func (p *AdventurePlugin) resolveRest(ctx MessageContext, char *AdventureCharacter) error {
|
||||||
|
|||||||
@@ -353,20 +353,6 @@ func renderRivalNudge(char *AdventureCharacter) string {
|
|||||||
rivalName, c.Round, c.Stake, hours)
|
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 {
|
func renderAdvMorningDM(char *AdventureCharacter, equip map[EquipmentSlot]*AdvEquipment, balance float64, bonuses *AdvBonusSummary, holidayName string) string {
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
|
|
||||||
@@ -456,38 +442,26 @@ func renderAdvMorningDM(char *AdventureCharacter, equip map[EquipmentSlot]*AdvEq
|
|||||||
sb.WriteString("\n")
|
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 {
|
if inCoop {
|
||||||
sb.WriteString("**1️⃣ Dungeon:** _(off in the Co-op, no solo combat — `!coop status` for run state)_\n")
|
sb.WriteString(fmt.Sprintf("_(combat is locked while you're in Co-op #%d — `!coop status` for run state)_\n", coopRun.ID))
|
||||||
} 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("\n")
|
||||||
|
|
||||||
if char.CanDoHarvest(isHol) {
|
sb.WriteString("**🏘️ In town:**\n")
|
||||||
sb.WriteString("**2️⃣ Mine:**\n")
|
sb.WriteString("• `5` / `shop` — buy/sell gear and loot\n")
|
||||||
writeAdvLocationLines(&sb, advEligibleLocations(char, equip, AdvActivityMining, bonuses))
|
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")
|
sb.WriteString("Reply with a number for in-town services, or run an expedition command directly.\n")
|
||||||
writeAdvLocationLines(&sb, advEligibleLocations(char, equip, AdvActivityForaging, bonuses))
|
sb.WriteString("You have until midnight UTC to rest.")
|
||||||
|
|
||||||
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.")
|
|
||||||
|
|
||||||
return sb.String()
|
return sb.String()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -199,7 +199,18 @@ func TestPickEveningRecap_BossOverridesAll(t *testing.T) {
|
|||||||
if got == "" {
|
if got == "" {
|
||||||
t.Fatal("expected boss-killed flavor")
|
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)
|
t.Errorf("expected boss-killed pool, got %q", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
108
internal/plugin/dnd_r1_migration.go
Normal file
108
internal/plugin/dnd_r1_migration.go
Normal 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, ®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()
|
||||||
|
}
|
||||||
171
internal/plugin/dnd_r1_migration_test.go
Normal file
171
internal/plugin/dnd_r1_migration_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user