mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +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 {
|
||||
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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
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