Add hospital revival, Robbie bandit, MW/shop fixes, float64 scoring

- St. Guildmore's Memorial Hospital: !hospital command with Nurse Joy,
  procedural itemized billing, same-day revival, 6-hour dead timer,
  hospital ad after death, 2-hour nudge follow-up
- Robbie the Friendly Bandit: automated inventory cleaner, random daily
  visits (8-21 UTC, 40% chance), collects sub-tier gear at €50/item,
  donates to community pot, drops Get Out of Medical Debt Free card
- Fix MW auto-equip: T1 MW no longer replaces better equipped gear
- Fix shop MW block: allow purchasing upgrades over lower-tier MW
- Float64 equipment scoring: advEffectiveTier and advEquipmentScore
  return float64, no mid-calculation truncation (MW 1.25x, Arena 1.5x)
- Fix markdown rendering: convert *asterisk* to _underscore_ italics
  across all flavor text files (shop, blacksmith, rival, hospital)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
prosolis
2026-04-05 17:04:03 -07:00
parent 6c6d74fb1b
commit 05cf6657f5
24 changed files with 2179 additions and 225 deletions

View File

@@ -27,6 +27,7 @@ type AdventurePlugin struct {
dmMenuSentAt sync.Map // userID string -> time.Time (last time actionable menu was DM'd)
arenaDeadlines sync.Map // userID string -> time.Time (auto-cashout deadline)
arenaPending sync.Map // userID string -> int (pending tier number awaiting confirmation)
shopSessions sync.Map // userID string -> *advShopSession
morningHour int
summaryHour int
}
@@ -118,6 +119,7 @@ func (p *AdventurePlugin) Init() error {
go p.eventTicker()
go p.arenaAutoCashoutTicker()
go p.rivalChallengeTicker()
go p.robbieTicker()
// Auto-cashout any arena runs left in 'awaiting' from a prior restart
p.arenaCleanupStaleRuns()
@@ -154,6 +156,11 @@ func (p *AdventurePlugin) OnMessage(ctx MessageContext) error {
return p.dispatchArenaCommand(ctx)
}
// 1b. Hospital commands (work in rooms and DMs)
if p.IsCommand(ctx.Body, "hospital") {
return p.handleHospitalCmd(ctx)
}
// 2. Check if this is a DM reply from a registered player
p.mu.Lock()
playerID, isDM := p.dmToPlayer[ctx.RoomID]
@@ -232,6 +239,7 @@ const advHelpText = `**Adventure Commands**
` + "`!adventure blacksmith`" + ` — Visit the blacksmith (view repair costs)
` + "`!adventure repair all`" + ` — Repair all damaged equipment
` + "`!adventure repair <slot>`" + ` — Repair a specific slot
` + "`!hospital`" + ` — Visit St. Guildmore's Memorial Hospital (same-day revival when dead)
` + "`!adventure help`" + ` — This message
**Arena:**
@@ -343,16 +351,28 @@ func (p *AdventurePlugin) handleStatus(ctx MessageContext) error {
return p.SendDM(ctx.Sender, text)
}
func (p *AdventurePlugin) handleShopCmd(ctx MessageContext, category string) error {
func (p *AdventurePlugin) handleShopCmd(ctx MessageContext, args string) error {
_, equip, err := p.ensureCharacter(ctx.Sender)
if err != nil {
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load your character.")
}
balance := p.euro.GetBalance(ctx.Sender)
showAll := strings.Contains(strings.ToLower(args), "show all")
category := strings.TrimSpace(strings.Replace(strings.ToLower(args), "show all", "", 1))
p.shopSessionStart(ctx.Sender)
if category == "" {
return p.SendDM(ctx.Sender, advShopOverview(equip, balance))
text := luigiShopGreeting(ctx.Sender, equip, balance, showAll)
p.pending.Store(string(ctx.Sender), &advPendingInteraction{
Type: "shop_category",
Data: &advPendingShopCategory{ShowAll: showAll},
ExpiresAt: time.Now().Add(advDMResponseWindow),
})
p.advMarkMenuSent(ctx.Sender)
p.shopScheduleBrowseNudge(ctx.Sender)
return p.SendDM(ctx.Sender, text)
}
slot := advParseShopCategory(category)
@@ -360,7 +380,15 @@ func (p *AdventurePlugin) handleShopCmd(ctx MessageContext, category string) err
return p.SendDM(ctx.Sender, fmt.Sprintf("Unknown category '%s'. Try: weapon, armor, helmet, boots, or tool.", category))
}
return p.SendDM(ctx.Sender, advShopCategory(slot, equip, balance))
text := luigiCategoryView(ctx.Sender, slot, equip, balance, showAll)
p.pending.Store(string(ctx.Sender), &advPendingInteraction{
Type: "shop_item",
Data: &advPendingShopItem{Slot: slot, ShowAll: showAll},
ExpiresAt: time.Now().Add(advDMResponseWindow),
})
p.advMarkMenuSent(ctx.Sender)
p.shopScheduleBrowseNudge(ctx.Sender)
return p.SendDM(ctx.Sender, text)
}
func (p *AdventurePlugin) handleBuyCmd(ctx MessageContext, itemName string) error {
@@ -479,6 +507,7 @@ func (p *AdventurePlugin) handleDMReply(ctx MessageContext) error {
return p.resolvePendingInteraction(ctx, interaction)
}
p.pending.Delete(string(ctx.Sender))
p.shopSessionEnd(ctx.Sender)
p.SendDM(ctx.Sender, "Your previous prompt expired. Moving on.")
}
@@ -509,6 +538,14 @@ func (p *AdventurePlugin) resolvePendingInteraction(ctx MessageContext, interact
return p.resolveBlacksmithSlotChoice(ctx, interaction)
case "blacksmith_confirm":
return p.resolveBlacksmithConfirm(ctx, interaction)
case "shop_category":
return p.resolveShopCategoryChoice(ctx, interaction)
case "shop_item":
return p.resolveShopItemChoice(ctx, interaction)
case "shop_confirm":
return p.resolveShopConfirm(ctx, interaction)
case "hospital_pay":
return p.resolveHospitalPay(ctx, interaction)
}
return nil
}
@@ -610,9 +647,7 @@ func (p *AdventurePlugin) parseAndResolveChoice(ctx MessageContext, body string)
// Parse "5" or "shop"
if lower == "5" || lower == "shop" {
equip, _ := loadAdvEquipment(ctx.Sender)
balance := p.euro.GetBalance(ctx.Sender)
return p.SendDM(ctx.Sender, advShopOverview(equip, balance))
return p.handleShopCmd(ctx, "")
}
// Parse activity + location
@@ -845,6 +880,11 @@ func (p *AdventurePlugin) resolveActivity(ctx MessageContext, char *AdventureCha
slog.Error("adventure: failed to send resolution DM", "user", ctx.Sender, "err", err)
}
// Send hospital ad on death (delayed, arrives after resolution DM)
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)