mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
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:
@@ -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)
|
||||
|
||||
@@ -426,7 +426,7 @@ type advProbabilities struct {
|
||||
}
|
||||
|
||||
func calculateAdvProbabilities(char *AdventureCharacter, equip map[EquipmentSlot]*AdvEquipment, loc *AdvLocation, bonuses *AdvBonusSummary, inPenaltyZone bool) advProbabilities {
|
||||
eqScore := float64(advEquipmentScore(equip))
|
||||
eqScore := advEquipmentScore(equip)
|
||||
skillLevel := float64(advEffectiveSkill(char, loc.Activity, bonuses))
|
||||
|
||||
deathPct := loc.BaseDeathPct - (eqScore * 0.8) - (skillLevel * 0.5) + bonuses.DeathModifier
|
||||
|
||||
@@ -572,7 +572,14 @@ func (p *AdventurePlugin) resolveArenaDeath(ctx MessageContext, run *ArenaRun, c
|
||||
text := renderArenaCombatLog(combatLog, monster, false, 0, arenaParticipationXP, closer)
|
||||
text += fmt.Sprintf("\nLost earnings: €%d\n", lostEarnings)
|
||||
|
||||
return p.SendDM(ctx.Sender, text)
|
||||
if err := p.SendDM(ctx.Sender, text); err != nil {
|
||||
slog.Error("arena: failed to send death DM", "user", ctx.Sender, "err", err)
|
||||
}
|
||||
|
||||
// Send hospital ad (delayed)
|
||||
p.sendHospitalAd(ctx.Sender, char)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *AdventurePlugin) arenaCompleteTier5(ctx MessageContext, run *ArenaRun, char *AdventureCharacter, tier *ArenaTier, prefixText string) error {
|
||||
|
||||
@@ -57,7 +57,7 @@ func (p *AdventurePlugin) handleBlacksmithCmd(ctx MessageContext) error {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load your character.")
|
||||
}
|
||||
|
||||
text := renderBlacksmithShop(equip)
|
||||
text := renderBlacksmithShop(ctx.Sender, equip)
|
||||
|
||||
// Store pending interaction for slot selection.
|
||||
p.pending.Store(string(ctx.Sender), &advPendingInteraction{
|
||||
@@ -162,7 +162,8 @@ func (p *AdventurePlugin) buildRepairAllConfirm(userID id.UserID, equip map[Equi
|
||||
}
|
||||
|
||||
if len(damaged) == 0 {
|
||||
return p.SendDM(userID, "⚒️ All your equipment is at full condition. "+pickBlacksmithFlavor(blacksmithFullCondition))
|
||||
flavor, _ := advPickFlavor(blacksmithFullCondition, userID, "bs_full")
|
||||
return p.SendDM(userID, "⚒️ All your equipment is at full condition. "+flavor)
|
||||
}
|
||||
|
||||
balance := p.euro.GetBalance(userID)
|
||||
@@ -213,7 +214,8 @@ func (p *AdventurePlugin) buildRepairSlotConfirm(userID id.UserID, equip map[Equ
|
||||
}
|
||||
|
||||
if eq.Condition >= 100 {
|
||||
return p.SendDM(userID, fmt.Sprintf("⚒️ %s %s — %s", slotEmoji(slot), eq.Name, pickBlacksmithFlavor(blacksmithFullCondition)))
|
||||
flavor, _ := advPickFlavor(blacksmithFullCondition, userID, "bs_full")
|
||||
return p.SendDM(userID, fmt.Sprintf("⚒️ %s %s — %s", slotEmoji(slot), eq.Name, flavor))
|
||||
}
|
||||
|
||||
cost := blacksmithRepairCost(eq)
|
||||
@@ -230,13 +232,17 @@ func (p *AdventurePlugin) buildRepairSlotConfirm(userID id.UserID, equip map[Equ
|
||||
|
||||
// Special flavor for masterwork, arena, or broken.
|
||||
if eq.Condition == 0 {
|
||||
sb.WriteString(fmt.Sprintf("*%s*\n\n", pickBlacksmithFlavor(blacksmithBrokenCondition)))
|
||||
f, _ := advPickFlavor(blacksmithBrokenCondition, userID, "bs_broken")
|
||||
sb.WriteString(fmt.Sprintf("*%s*\n\n", f))
|
||||
} else if eq.Masterwork {
|
||||
sb.WriteString(fmt.Sprintf("*%s*\n\n", pickBlacksmithFlavor(blacksmithMasterwork)))
|
||||
f, _ := advPickFlavor(blacksmithMasterwork, userID, "bs_master")
|
||||
sb.WriteString(fmt.Sprintf("*%s*\n\n", f))
|
||||
} else if eq.ArenaTier > 0 {
|
||||
sb.WriteString(fmt.Sprintf("*%s*\n\n", pickBlacksmithFlavor(blacksmithArena)))
|
||||
f, _ := advPickFlavor(blacksmithArena, userID, "bs_arena")
|
||||
sb.WriteString(fmt.Sprintf("*%s*\n\n", f))
|
||||
} else {
|
||||
sb.WriteString(fmt.Sprintf("*%s*\n\n", pickBlacksmithFlavor(blacksmithInspection)))
|
||||
f, _ := advPickFlavor(blacksmithInspection, userID, "bs_inspect")
|
||||
sb.WriteString(fmt.Sprintf("*%s*\n\n", f))
|
||||
}
|
||||
|
||||
sb.WriteString(fmt.Sprintf("Repair to 100: **€%d**\n\nReply **yes** to confirm or **no** to cancel.", cost))
|
||||
@@ -320,19 +326,22 @@ func (p *AdventurePlugin) executeRepair(userID id.UserID, data *advPendingBlacks
|
||||
for _, item := range repaired {
|
||||
sb.WriteString(fmt.Sprintf(" %s → [100/100]\n", item))
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("\n*%s*\n\n", pickBlacksmithFlavor(blacksmithPayment)))
|
||||
sb.WriteString(fmt.Sprintf("*%s*", pickBlacksmithFlavor(blacksmithCompletion)))
|
||||
payFlavor, _ := advPickFlavor(blacksmithPayment, userID, "bs_pay")
|
||||
doneFlavor, _ := advPickFlavor(blacksmithCompletion, userID, "bs_done")
|
||||
sb.WriteString(fmt.Sprintf("\n*%s*\n\n", payFlavor))
|
||||
sb.WriteString(fmt.Sprintf("*%s*", doneFlavor))
|
||||
|
||||
return p.SendDM(userID, sb.String())
|
||||
}
|
||||
|
||||
// ── Shop Display ────────────────────────────────────────────────────────────
|
||||
|
||||
func renderBlacksmithShop(equip map[EquipmentSlot]*AdvEquipment) string {
|
||||
func renderBlacksmithShop(userID id.UserID, equip map[EquipmentSlot]*AdvEquipment) string {
|
||||
var sb strings.Builder
|
||||
|
||||
greet, _ := advPickFlavor(blacksmithGreetings, userID, "bs_greet")
|
||||
sb.WriteString("⚒️ **The Blacksmith**\n\n")
|
||||
sb.WriteString(fmt.Sprintf("*%s*\n\n", pickBlacksmithFlavor(blacksmithGreetings)))
|
||||
sb.WriteString(fmt.Sprintf("*%s*\n\n", greet))
|
||||
sb.WriteString("Your equipment:\n")
|
||||
|
||||
hasDamaged := false
|
||||
@@ -361,7 +370,8 @@ func renderBlacksmithShop(equip map[EquipmentSlot]*AdvEquipment) string {
|
||||
}
|
||||
|
||||
if !hasDamaged {
|
||||
sb.WriteString(fmt.Sprintf("\n*%s*", pickBlacksmithFlavor(blacksmithFullCondition)))
|
||||
fullFlavor, _ := advPickFlavor(blacksmithFullCondition, userID, "bs_full")
|
||||
sb.WriteString(fmt.Sprintf("\n*%s*", fullFlavor))
|
||||
} else {
|
||||
sb.WriteString("\nReply with the slot name to repair (weapon / armor / helmet / boots / tool) or \"all\" to repair everything.")
|
||||
}
|
||||
|
||||
@@ -56,6 +56,8 @@ type AdventureCharacter struct {
|
||||
BabysitActive bool
|
||||
BabysitExpiresAt *time.Time
|
||||
BabysitSkillFocus string
|
||||
HospitalVisits int
|
||||
RobbieVisitCount int
|
||||
}
|
||||
|
||||
type AdvEquipment struct {
|
||||
@@ -200,47 +202,55 @@ func (c *AdventureCharacter) DeathReprieveAvailable() bool {
|
||||
return time.Since(*c.DeathReprieveLast) >= 168*time.Hour
|
||||
}
|
||||
|
||||
// Kill marks the character as dead with a 2-hour respawn timer.
|
||||
// Kill marks the character as dead with a 6-hour respawn timer.
|
||||
func (c *AdventureCharacter) Kill() {
|
||||
c.Alive = false
|
||||
deadUntil := time.Now().UTC().Add(2 * time.Hour)
|
||||
deadUntil := time.Now().UTC().Add(6 * time.Hour)
|
||||
c.DeadUntil = &deadUntil
|
||||
}
|
||||
|
||||
// ── Equipment Score ──────────────────────────────────────────────────────────
|
||||
|
||||
func advEquipmentScore(equip map[EquipmentSlot]*AdvEquipment) int {
|
||||
score := 0
|
||||
func advEquipmentScore(equip map[EquipmentSlot]*AdvEquipment) float64 {
|
||||
score := 0.0
|
||||
arenaSets := advEquippedArenaSets(equip)
|
||||
for _, slot := range allSlots {
|
||||
eq, ok := equip[slot]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
tierContrib := eq.Tier
|
||||
// Arena gear: 1.5x effectiveness
|
||||
if eq.ArenaTier > 0 {
|
||||
tierContrib = int(float64(tierContrib) * 1.5)
|
||||
} else if eq.Masterwork {
|
||||
// Masterwork: 1.25x effectiveness
|
||||
tierContrib = int(float64(tierContrib) * 1.25)
|
||||
}
|
||||
contrib := advEffectiveTier(eq)
|
||||
if slot == SlotWeapon {
|
||||
tierContrib *= 2
|
||||
contrib *= 2
|
||||
}
|
||||
// Condition modifier: below 50 halves contribution
|
||||
if eq.Condition < 50 {
|
||||
tierContrib /= 2
|
||||
contrib /= 2
|
||||
}
|
||||
score += tierContrib
|
||||
score += contrib
|
||||
}
|
||||
// Champion's set: Commanding Presence — +10% equipment score
|
||||
if arenaSets["champions"] {
|
||||
score = int(float64(score) * 1.10)
|
||||
score *= 1.10
|
||||
}
|
||||
return score
|
||||
}
|
||||
|
||||
// advEffectiveTier returns the effective tier of a piece of equipment,
|
||||
// accounting for Masterwork (1.25x) and Arena (1.5x) bonuses.
|
||||
func advEffectiveTier(eq *AdvEquipment) float64 {
|
||||
if eq == nil {
|
||||
return 0
|
||||
}
|
||||
if eq.ArenaTier > 0 {
|
||||
return float64(eq.Tier) * 1.5
|
||||
}
|
||||
if eq.Masterwork {
|
||||
return float64(eq.Tier) * 1.25
|
||||
}
|
||||
return float64(eq.Tier)
|
||||
}
|
||||
|
||||
// ── XP & Level-Up ────────────────────────────────────────────────────────────
|
||||
|
||||
const maxAdvLevel = 50
|
||||
@@ -307,7 +317,8 @@ func loadAdvCharacter(userID id.UserID) (*AdventureCharacter, error) {
|
||||
created_at, last_active_at, death_reprieve_last,
|
||||
masterwork_drops_received,
|
||||
rival_pool, rival_unlocked_notified,
|
||||
babysit_active, babysit_expires_at, babysit_skill_focus
|
||||
babysit_active, babysit_expires_at, babysit_skill_focus,
|
||||
hospital_visits, robbie_visit_count
|
||||
FROM adventure_characters WHERE user_id = ?`, string(userID)).Scan(
|
||||
&c.UserID, &c.DisplayName,
|
||||
&c.CombatLevel, &c.MiningSkill, &c.ForagingSkill, &c.FishingSkill,
|
||||
@@ -319,6 +330,7 @@ func loadAdvCharacter(userID id.UserID) (*AdventureCharacter, error) {
|
||||
&c.MasterworkDropsReceived,
|
||||
&c.RivalPool, &rivalUnlocked,
|
||||
&babysitAct, &babysitExp, &c.BabysitSkillFocus,
|
||||
&c.HospitalVisits, &c.RobbieVisitCount,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -402,7 +414,8 @@ func saveAdvCharacter(char *AdventureCharacter) error {
|
||||
last_active_at = CURRENT_TIMESTAMP, death_reprieve_last = ?,
|
||||
masterwork_drops_received = ?,
|
||||
rival_pool = ?, rival_unlocked_notified = ?,
|
||||
babysit_active = ?, babysit_expires_at = ?, babysit_skill_focus = ?
|
||||
babysit_active = ?, babysit_expires_at = ?, babysit_skill_focus = ?,
|
||||
hospital_visits = ?, robbie_visit_count = ?
|
||||
WHERE user_id = ?`,
|
||||
char.DisplayName, char.CombatLevel, char.MiningSkill, char.ForagingSkill, char.FishingSkill,
|
||||
char.CombatXP, char.MiningXP, char.ForagingXP, char.FishingXP,
|
||||
@@ -412,6 +425,7 @@ func saveAdvCharacter(char *AdventureCharacter) error {
|
||||
char.DeathReprieveLast, char.MasterworkDropsReceived,
|
||||
char.RivalPool, rivalUnlocked,
|
||||
babysitAct, char.BabysitExpiresAt, char.BabysitSkillFocus,
|
||||
char.HospitalVisits, char.RobbieVisitCount,
|
||||
string(char.UserID),
|
||||
)
|
||||
return err
|
||||
|
||||
@@ -1,74 +1,66 @@
|
||||
package plugin
|
||||
|
||||
import "math/rand/v2"
|
||||
|
||||
// ── Blacksmith Flavor Text Pools ────────────────────────────────────────────
|
||||
|
||||
var blacksmithGreetings = []string{
|
||||
"*wipes hands on apron and looks you over slowly* You've seen some action. I can tell. Come inside. Let me experience what you've been working with.",
|
||||
"*sets down hammer* Don't be shy. I've seen worse. Bring it here. I'll get it back to its full size in no time.",
|
||||
"I don't judge the condition things arrive in but I *always* know how to make them feel... oh so much better. Weapons and armor too.",
|
||||
"*gestures broadly at the forge* Everything in here gets my full and undivided attention. You have my word on that. Unless a ball gag is involved. \u2764\ufe0f",
|
||||
"_wipes hands on apron and looks you over slowly_ You've seen some action. I can tell. Come inside. Let me experience what you've been working with.",
|
||||
"_sets down hammer_ Don't be shy. I've seen worse. Bring it here. I'll get it back to its full size in no time.",
|
||||
"I don't judge the condition things arrive in but I _always_ know how to make them feel... oh so much better. Weapons and armor too.",
|
||||
"_gestures broadly at the forge_ Everything in here gets my full and undivided attention. You have my word on that. Unless a ball gag is involved. \u2764\ufe0f",
|
||||
"You look like someone who's been putting their equipment through a lot. That's what I'm here for. Show me everything.",
|
||||
}
|
||||
|
||||
var blacksmithInspection = []string{
|
||||
"Oh. Oh that's seen some use. *runs thumb along the edge* Don't worry. I know exactly what this needs.",
|
||||
"This one's been neglected. I can tell. People don't appreciate what they have until it's been beaten down until it can't take any more. Then it's my job to *slowly* build you... it... back up completely from bottom to the top.",
|
||||
"*whistles low* That's a lot of damage. I'm not complaining. I'm just saying I'm going to need some time with this one.",
|
||||
"Oh. Oh that's seen some use. _runs thumb along the edge_ Don't worry. I know exactly what this needs.",
|
||||
"This one's been neglected. I can tell. People don't appreciate what they have until it's been beaten down until it can't take any more. Then it's my job to _slowly_ build you... it... back up completely from bottom to the top.",
|
||||
"_whistles low_ That's a lot of damage. I'm not complaining. I'm just saying I'm going to need some time with this one.",
|
||||
"Good bones. Good bones. A little beaten up but my skilled fingers have brought worse back from the brink to highs rarely seen nor experienced on Earth! ...Oh I can fix your item too.",
|
||||
"*holds it up to the light* I've had my hands on a lot of these. This one needs work. The good kind of work. *looks you deeply in the eyes* The only kind of work I know how to do.",
|
||||
"_holds it up to the light_ I've had my hands on a lot of these. This one needs work. The good kind of work. _looks you deeply in the eyes_ The only kind of work I know how to do.",
|
||||
}
|
||||
|
||||
var blacksmithPayment = []string{
|
||||
"*pockets the gold without looking at it* Money is fine. The work is the reward.",
|
||||
"_pockets the gold without looking at it_ Money is fine. The work is the reward.",
|
||||
"Generous. I'd have done it for less. Don't tell anyone.",
|
||||
"*nods slowly* Fair. Now let me get to it. I work better when I'm not being watched. Come back in a bit.",
|
||||
"You're paying for the expertise. Anyone can hit metal. Not everyone knows where to hit it. Or how to hit it to get it quivering after each strike until it's begging for more! *there's an awkward silence* Anyway. Time to get to work.",
|
||||
"The gold is appreciated. Now. Unless you have need for my services from the *ahem* hidden menu, come back later.",
|
||||
"_nods slowly_ Fair. Now let me get to it. I work better when I'm not being watched. Come back in a bit.",
|
||||
"You're paying for the expertise. Anyone can hit metal. Not everyone knows where to hit it. Or how to hit it to get it quivering after each strike until it's begging for more! _there's an awkward silence_ Anyway. Time to get to work.",
|
||||
"The gold is appreciated. Now. Unless you have need for my services from the _ahem_ hidden menu, come back later.",
|
||||
}
|
||||
|
||||
var blacksmithCompletion = []string{
|
||||
"There. *slides it across the counter* Good as new. Better, actually. I know what I'm doing.",
|
||||
"*breathes heavily* Done. That one took something out of me. Worth it though. Always worth it.",
|
||||
"There. _slides it across the counter_ Good as new. Better, actually. I know what I'm doing.",
|
||||
"_breathes heavily_ Done. That one took something out of me. Worth it though. Always worth it.",
|
||||
"You feel that? That's what it's supposed to feel like. Full. Firm. Ready for anything at any time. Just like me.",
|
||||
"I worked it until it couldn't take any more. Then I worked it a little more. I'm more than happy to give you a demonstration sometime.",
|
||||
"*wipes forehead* The difficult ones are always the most satisfying. Take it. Come back when it needs attention again. And it will need attention again.",
|
||||
"_wipes forehead_ The difficult ones are always the most satisfying. Take it. Come back when it needs attention again. And it will need attention again.",
|
||||
"It's hot right now. Give it a moment before you put it on. Trust me on that.",
|
||||
"*stares at the finished piece for a moment longer than necessary* Beautiful. Okay. Take it. Go.",
|
||||
"_stares at the finished piece for a moment longer than necessary_ Beautiful. Okay. Take it. Go.",
|
||||
}
|
||||
|
||||
var blacksmithMasterwork = []string{
|
||||
"*pauses* This is Masterwork. *sets everything else down* This gets my complete focus. Nothing else exists right now.",
|
||||
"_pauses_ This is Masterwork. _sets everything else down_ This gets my complete focus. Nothing else exists right now.",
|
||||
"I don't rush Masterwork. I don't care how long it takes. Some things deserve to be done properly.",
|
||||
"*closes eyes briefly* I've been waiting for one of these to come through here. You have no idea how long I've been waiting.",
|
||||
"_closes eyes briefly_ I've been waiting for one of these to come through here. You have no idea how long I've been waiting.",
|
||||
"Extra charge for Masterwork. Not because it's harder. Because it deserves more of me. It demands more of me. And oh god it will get it, don't you worry at all.",
|
||||
}
|
||||
|
||||
var blacksmithArena = []string{
|
||||
"*goes very still* Is that... Arena gear? *looks up at you* Where did you get this. Never mind. Don't tell me. Just leave it.",
|
||||
"I've heard about this. I've never had one on my table before. *voice drops* Close the door.",
|
||||
"_goes very still_ Is that... Arena gear? _looks up at you_ Where did you get this. Never mind. Don't tell me. Just leave it.",
|
||||
"I've heard about this. I've never had one on my table before. _voice drops_ Close the door.",
|
||||
"The Arena gear gets the good tools. I don't use these for everything. Just for things that matter.",
|
||||
}
|
||||
|
||||
var blacksmithFullCondition = []string{
|
||||
"*looks it over* There's nothing to do here. It doesn't need me. *sounds slightly disappointed* Come back when it does.",
|
||||
"_looks it over_ There's nothing to do here. It doesn't need me. _sounds slightly disappointed_ Come back when it does.",
|
||||
"Full condition. You've been taking care of it. Good. I appreciate that in a person.",
|
||||
"Nothing to fix. Come back when you've broken something. Or you need me to break you. Special offer. Just for you.",
|
||||
}
|
||||
|
||||
var blacksmithBrokenCondition = []string{
|
||||
"*long pause* ...How. *another pause* You know what. It doesn't matter. I've seen things. Sit down.",
|
||||
"This is what happens when people don't come see me regularly. *not accusatory. somehow worse than accusatory.*",
|
||||
"_long pause_ ...How. _another pause_ You know what. It doesn't matter. I've seen things. Sit down.",
|
||||
"This is what happens when people don't come see me regularly. _not accusatory. somehow worse than accusatory._",
|
||||
"I can fix this. It'll take longer. And cost more. And I'm going to need you to just... not talk while I work. Can you do that.",
|
||||
"*picks it up gingerly* It's not dead. It just needs someone who knows what they're doing. Lucky for you I always know what to do in *every* situation. \u2764\ufe0f",
|
||||
"*looks at the condition, looks at you, looks back at the condition* You know it costs more when you let it get like this. Of course you know. You just didn't care. That's fine. I care enough for both of us. It'll cost you.",
|
||||
"This could have been avoided with regular visits. *slides the cost estimate across the counter without breaking eye contact*",
|
||||
"_picks it up gingerly_ It's not dead. It just needs someone who knows what they're doing. Lucky for you I always know what to do in _every_ situation. \u2764\ufe0f",
|
||||
"_looks at the condition, looks at you, looks back at the condition_ You know it costs more when you let it get like this. Of course you know. You just didn't care. That's fine. I care enough for both of us. It'll cost you.",
|
||||
"This could have been avoided with regular visits. _slides the cost estimate across the counter without breaking eye contact_",
|
||||
}
|
||||
|
||||
func pickBlacksmithFlavor(pool []string) string {
|
||||
if len(pool) == 0 {
|
||||
return ""
|
||||
}
|
||||
return pool[rand.IntN(len(pool))]
|
||||
}
|
||||
|
||||
@@ -191,14 +191,11 @@ var ClosingFailure = []string{
|
||||
var ClosingDeath = []string{
|
||||
"─────────────────────────────\n" +
|
||||
"That's your day. Healthcare has the rest.\n\n" +
|
||||
"A separate DM is inbound with the details.\n" +
|
||||
"The details are not good. You already know the details.\n\n" +
|
||||
"Next action: after recovery\n" +
|
||||
"Morning DM: {morning_time} UTC — subject to medical clearance.",
|
||||
"🏥 A DM from St. Guildmore's is on its way.\n" +
|
||||
"Type `!hospital` for same-day revival.",
|
||||
|
||||
"─────────────────────────────\n" +
|
||||
"Day over. Healthcare is involved.\n" +
|
||||
"Expect a DM shortly with the full situation.\n\n" +
|
||||
"The full situation: {location} won today.\n" +
|
||||
"Tomorrow is pending insurance confirmation.",
|
||||
"🏥 St. Guildmore's has been notified.\n\n" +
|
||||
"Check your DMs, or type `!hospital`.",
|
||||
}
|
||||
|
||||
262
internal/plugin/adventure_flavor_hospital.go
Normal file
262
internal/plugin/adventure_flavor_hospital.go
Normal file
@@ -0,0 +1,262 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math/rand/v2"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ── St. Guildmore's Memorial Hospital — Nurse Joy Flavor Text ──────────────
|
||||
|
||||
// nurseJoyFirstVisit is shown once on the player's first hospital visit.
|
||||
var nurseJoyFirstVisit = "🏥 **Welcome to St. Guildmore's Memorial Hospital!**\n\n" +
|
||||
"We're the official healthcare provider for members of the Adventurer's Guild. " +
|
||||
"Even better, we've modeled our pricing after the United States of America, " +
|
||||
"which means we offer only the quickest and best care. _whispers_ ...for those who can afford it.\n\n" +
|
||||
"Your Guild insurance is on file. Nurse Joy will be right with you.\n\n"
|
||||
|
||||
var nurseJoyAdmission = []string{
|
||||
"_looks up from clipboard with a bright smile_ Hi! Name? Guild card? Oh you're covered under the Collective, that's wonderful! Let me get Nurse Joy to come take a look at you. _is Nurse Joy_",
|
||||
"Oh you're with the Guild? That's great, we love Guild members! Very comprehensive plan. Someone will be right with you! _gestures at chairs_ Can I get you anything while you wait? We have water. It's complimentary. The water is complimentary.",
|
||||
"_already typing, still cheerful_ Cause of death? Temporary obviously, just for the record! And your Guild card number? Perfect! We'll get you all sorted out. _sorted out does not mean quickly but she means it warmly_",
|
||||
"Welcome to St. Guildmore's! You're in such good hands here. _has said this ten thousand times and means it every single time_ Insurance on file? Wonderful. Take a number and we'll be right with you!",
|
||||
"_looks up, immediately sympathetic in a clinical way_ Oh you don't look great! That's okay, that's what we're here for. Guild member? Amazing. Nurse Joy will be right out. _is Nurse Joy. will be right out._",
|
||||
}
|
||||
|
||||
var nurseJoyBillDelivery = []string{
|
||||
"_slides it across with a smile_ So that's the total before insurance! Your plan is really good though -- the Collective is wonderful. We're going to call them right now. _already reaching for the phone, still smiling_",
|
||||
"Here's the full rate! _sets it down cheerfully_ Don't worry about that number, insurance is going to take care of so much of it. So much. Give us just a sec! _hums while dialing_",
|
||||
"That's what we call the pre-insurance figure! It's a big number but honestly that's just how we price things here. The Collective will bring it way down. Way down. _wanders off still visibly pleased with how this is going_",
|
||||
"_hands it over like she's giving you a gift_ Your Guild plan is really comprehensive -- one of the better ones we see honestly. Let me just get them on the line! _does not register your expression at all_",
|
||||
}
|
||||
|
||||
// nurseJoyAfterInsurance — format with afterInsurance cost.
|
||||
// The last entry has two %d placeholders (intentional — she says the number twice).
|
||||
var nurseJoyAfterInsurance = []string{
|
||||
"_practically skips back in_ Great news! The Collective was amazing -- they covered so much of it. Your portion is only **€%d**! _says \"only\" like she's reporting a minor inconvenience_",
|
||||
"_beaming_ Okay so the insurance came through and honestly? Really good result today. You're looking at **€%d** out of pocket! That's after everything they covered. _genuinely proud of this outcome_",
|
||||
"_sits across from you, clasps hands_ I have such good news. The Collective took care of the bulk of it. Your share is just **€%d**! I know it sounds like a lot but you should see what it was before. _you have seen what it was before_ Actually you did see it. But still -- this is so much better!",
|
||||
"_slides over a new, smaller, still alarming piece of paper_ So they covered everything above your threshold! You just owe **€%d**. _taps it encouragingly_ And then you're all set! Good as new! _has never once considered that **€%d** might be a problem_",
|
||||
}
|
||||
|
||||
var nurseJoyCantAfford = []string{
|
||||
"Oh! It looks like your balance isn't quite there today! _signals to the orderlies_ That's okay! We'll get you back to where we found you and you can rest up overnight! _waves as the stretcher is wheeled back out_",
|
||||
"Hmm! So the balance isn't quite covering it! No worries at all -- _already nodding to someone behind you_ -- the natural recovery process works just as well! We'll get you back to your ditch! Have a great night!",
|
||||
"Oh that's a little short! That's okay! _cheerfully_ We're going to pop you back where you were and you'll be right as rain tomorrow morning! _you are deposited back in the ditch_",
|
||||
}
|
||||
|
||||
var nurseJoyNudge = []string{
|
||||
"Hi! This is Nurse Joy at St. Guildmore's Memorial! We noticed you haven't come in yet.\n\n" +
|
||||
"We have a bed ready and your insurance is all on file! We even have Derek standing by but that's only relevant if there's a billing issue and I'm sure there won't be!\n\n" +
|
||||
"Come in whenever you're ready! We're open! _we are always open_\n\n" +
|
||||
"Type `!hospital` to check in.",
|
||||
}
|
||||
|
||||
var nurseJoyAlive = []string{
|
||||
"_looks up from clipboard_ You don't appear to be dead! That's great news! We love when people aren't dead. Come back if that changes! _cheerfully_",
|
||||
"_tilts head_ Our records show you're... alive? That's wonderful! We don't actually treat alive people here. It's a whole different department. _waves vaguely_",
|
||||
}
|
||||
|
||||
var nurseJoyAlreadyRevived = "You seem to have recovered on your own! " +
|
||||
"That's the body's natural healing process at work. Beautiful thing. " +
|
||||
"No charge! _beams_"
|
||||
|
||||
// ── Room Announcements ───────────────────────────────────────────────────<E29480><E29480>─
|
||||
|
||||
var hospitalDischargeAnnounce = "🏥 %s has been discharged from St. Guildmore's Memorial Hospital. " +
|
||||
"Recovered. Back in action. The bill has been described as \"a lot.\""
|
||||
|
||||
var hospitalDitchAnnounce = "🏥 %s was brought into St. Guildmore's on a stretcher. " +
|
||||
"They have been returned to the ditch. They'll be fine tomorrow."
|
||||
|
||||
// ── Itemized Bill Generation ───────────────────────────────────────────────
|
||||
|
||||
type hospitalBillItem struct {
|
||||
Name string
|
||||
Note string // optional italicized sub-line
|
||||
Min float64 // min fraction of total
|
||||
Max float64 // max fraction of total
|
||||
}
|
||||
|
||||
// hospitalBillPool is the pool of possible line items. Each bill picks 8-12.
|
||||
var hospitalBillPool = []hospitalBillItem{
|
||||
{"Resuscitation services (standard)", "", 0.04, 0.10},
|
||||
{"Resuscitation services (premium)", "(performed simultaneously with standard, non-optional)", 0.03, 0.08},
|
||||
{"Facility fee", "", 0.02, 0.05},
|
||||
{"Facility fee (after hours)", "(you died at 2pm. this is contested. we will not contest it.)", 0.02, 0.04},
|
||||
{"Physician consultation", "", 0.03, 0.06},
|
||||
{"Physician consultation (second opinion)", "(same physician. different hat.)", 0.03, 0.06},
|
||||
{"Gauze", "", 0.01, 0.02},
|
||||
{"Gauze (application fee)", "", 0.01, 0.02},
|
||||
{"Gauze (removal, future, pre-billed)", "", 0.01, 0.03},
|
||||
{"Convenience fee", "(thank you for allowing us to exist)", 0.02, 0.05},
|
||||
{"Inconvenience fee", "(deterrent to prevent emergency room abuse or usage in general)", 0.02, 0.05},
|
||||
{"Guild membership verification fee", "(your guild card was checked. this takes resources.)", 0.01, 0.03},
|
||||
{"Administrative processing", "", 0.01, 0.03},
|
||||
{"Spiritual realignment surcharge", "(your soul was briefly elsewhere. retrieval is billable.)", 0.02, 0.04},
|
||||
{"Bed occupancy charge", "(per minute, retroactive to estimated time of death)", 0.02, 0.05},
|
||||
{"Oxygen (ambient)", "(you breathed hospital air. the air is not free.)", 0.01, 0.03},
|
||||
{"Emotional support services", "(Nurse Joy smiled at you. that's a service.)", 0.01, 0.02},
|
||||
{"Post-mortem orientation fee", "(the pamphlet you did not read.)", 0.01, 0.02},
|
||||
{"Equipment sterilization", "(your gear was near our equipment. proximity counts.)", 0.01, 0.03},
|
||||
{"Discharge planning", "(we are already planning your discharge. this costs money.)", 0.01, 0.03},
|
||||
}
|
||||
|
||||
// hospitalHolidaySurcharge is included only on holidays.
|
||||
var hospitalHolidaySurcharge = hospitalBillItem{
|
||||
"Holiday surcharge", "(applied if death occurs on or adjacent to a recognized holiday)", 0.02, 0.02,
|
||||
}
|
||||
|
||||
// hospitalFrequentCustomer is included when hospitalVisits > 0.
|
||||
// It's a "discount" that is actually a positive charge. Because healthcare.
|
||||
var hospitalFrequentCustomer = hospitalBillItem{
|
||||
"Frequent customer discount", "(thank you for your continued patronage.)", 0.01, 0.02,
|
||||
}
|
||||
|
||||
// generateItemizedBill builds a procedural hospital bill that sums to the given total.
|
||||
func generateItemizedBill(total int64, hospitalVisits int, isHoliday bool) string {
|
||||
// Shuffle and pick 8-12 items from the pool
|
||||
pool := make([]hospitalBillItem, len(hospitalBillPool))
|
||||
copy(pool, hospitalBillPool)
|
||||
rand.Shuffle(len(pool), func(i, j int) { pool[i], pool[j] = pool[j], pool[i] })
|
||||
|
||||
count := 8 + rand.IntN(5) // 8-12
|
||||
if count > len(pool) {
|
||||
count = len(pool)
|
||||
}
|
||||
items := pool[:count]
|
||||
|
||||
// Add conditional items
|
||||
if isHoliday {
|
||||
items = append(items, hospitalHolidaySurcharge)
|
||||
}
|
||||
if hospitalVisits > 0 {
|
||||
items = append(items, hospitalFrequentCustomer)
|
||||
}
|
||||
|
||||
// Assign amounts
|
||||
type billLine struct {
|
||||
Name string
|
||||
Note string
|
||||
Amount int64
|
||||
}
|
||||
|
||||
var lines []billLine
|
||||
var assigned int64
|
||||
|
||||
for _, item := range items {
|
||||
frac := item.Min + rand.Float64()*(item.Max-item.Min)
|
||||
amount := int64(float64(total) * frac)
|
||||
if amount < 1 {
|
||||
amount = 1
|
||||
}
|
||||
lines = append(lines, billLine{Name: item.Name, Note: item.Note, Amount: amount})
|
||||
assigned += amount
|
||||
}
|
||||
|
||||
// Miscellaneous absorbs the remainder
|
||||
misc := total - assigned
|
||||
if misc < 0 {
|
||||
// Over-assigned — scale everything down proportionally
|
||||
scale := float64(total) / float64(assigned)
|
||||
assigned = 0
|
||||
for i := range lines {
|
||||
lines[i].Amount = int64(float64(lines[i].Amount) * scale)
|
||||
if lines[i].Amount < 1 {
|
||||
lines[i].Amount = 1
|
||||
}
|
||||
assigned += lines[i].Amount
|
||||
}
|
||||
misc = total - assigned
|
||||
}
|
||||
if misc > 0 {
|
||||
lines = append(lines, billLine{
|
||||
Name: "Miscellaneous",
|
||||
Note: "",
|
||||
Amount: misc,
|
||||
})
|
||||
}
|
||||
|
||||
// Find max name width for alignment
|
||||
maxName := 0
|
||||
for _, l := range lines {
|
||||
if len(l.Name) > maxName {
|
||||
maxName = len(l.Name)
|
||||
}
|
||||
}
|
||||
if maxName < 35 {
|
||||
maxName = 35
|
||||
}
|
||||
|
||||
// Format
|
||||
var sb strings.Builder
|
||||
sb.WriteString("```\n")
|
||||
sb.WriteString("ST. GUILDMORE'S MEMORIAL HOSPITAL\n")
|
||||
sb.WriteString("─────────────────────────────────\n")
|
||||
|
||||
for _, l := range lines {
|
||||
padding := maxName - len(l.Name) + 2
|
||||
if padding < 2 {
|
||||
padding = 2
|
||||
}
|
||||
sb.WriteString(l.Name)
|
||||
sb.WriteString(strings.Repeat(" ", padding))
|
||||
sb.WriteString(formatBillAmount(l.Amount))
|
||||
sb.WriteByte('\n')
|
||||
if l.Note != "" {
|
||||
sb.WriteString(" " + l.Note + "\n")
|
||||
}
|
||||
}
|
||||
|
||||
sb.WriteString("─────────────────────────────────\n")
|
||||
totalLabel := "TOTAL (before insurance)"
|
||||
padding := maxName - len(totalLabel) + 2
|
||||
if padding < 2 {
|
||||
padding = 2
|
||||
}
|
||||
sb.WriteString(totalLabel)
|
||||
sb.WriteString(strings.Repeat(" ", padding))
|
||||
sb.WriteString(formatBillAmount(total))
|
||||
sb.WriteByte('\n')
|
||||
sb.WriteString("```")
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// formatBillAmount formats an int64 as €X,XXX with comma separators.
|
||||
func formatBillAmount(n int64) string {
|
||||
if n == 0 {
|
||||
return "€0"
|
||||
}
|
||||
neg := n < 0
|
||||
if neg {
|
||||
n = -n
|
||||
}
|
||||
|
||||
// Format with commas
|
||||
s := fmt.Sprintf("%d", n)
|
||||
if len(s) <= 3 {
|
||||
if neg {
|
||||
return "-€" + s
|
||||
}
|
||||
return "€" + s
|
||||
}
|
||||
|
||||
// Insert commas from right
|
||||
var result strings.Builder
|
||||
remainder := len(s) % 3
|
||||
if remainder > 0 {
|
||||
result.WriteString(s[:remainder])
|
||||
}
|
||||
for i := remainder; i < len(s); i += 3 {
|
||||
if result.Len() > 0 {
|
||||
result.WriteByte(',')
|
||||
}
|
||||
result.WriteString(s[i : i+3])
|
||||
}
|
||||
|
||||
if neg {
|
||||
return "-€" + result.String()
|
||||
}
|
||||
return "€" + result.String()
|
||||
}
|
||||
@@ -22,18 +22,18 @@ var rivalRoundWon = []string{
|
||||
"Did that hurt? The losing, I mean. Or is this familiar enough to be comfortable?",
|
||||
"I knew what you were going to throw before you did. I know what you're going to throw next. I won't tell you.",
|
||||
"You should have thrown something else. You know that now. I knew it before you threw.",
|
||||
"*takes a moment to write something down* Sorry. Just keeping notes. Continue.",
|
||||
"_takes a moment to write something down_ Sorry. Just keeping notes. Continue.",
|
||||
"The expression on your face right now is doing a lot of work. Most of it sad.",
|
||||
}
|
||||
|
||||
// rivalRoundLost are said by the rival after they lose a round.
|
||||
var rivalRoundLost = []string{
|
||||
"Even a battered, dirty, *sniffs* smelly, and broken beyond any hope of repair clock is right twice a day.",
|
||||
"Even a battered, dirty, _sniffs_ smelly, and broken beyond any hope of repair clock is right twice a day.",
|
||||
"Fine. You got one. Don't read into it. Actually -- you won't know what to read into. Never mind.",
|
||||
"I want you to enjoy this moment. Really sit in it. It may be all you have to take home today.",
|
||||
"I respect the throw. I don't respect the thrower. These are two separate things.",
|
||||
"Lucky. I've seen luck before and that's what that was. Make peace with it.",
|
||||
"*pauses* Okay. *pauses again* I wasn't expecting that. That changes nothing. Next round.",
|
||||
"_pauses_ Okay. _pauses again_ I wasn't expecting that. That changes nothing. Next round.",
|
||||
"You threw the right thing at the right time. The stopped clock principle applies. Ask someone to explain it to you later.",
|
||||
}
|
||||
|
||||
@@ -42,28 +42,28 @@ var rivalTied = []string{
|
||||
"Same throw. Neither of us embarrassed ourselves. One of us is about to rectify that.",
|
||||
"We're going again. That's fine. I have nowhere to be. Do you have somewhere to be? It doesn't matter.",
|
||||
"You matched me. I want to be clear that this is the ceiling of your performance and we both know it.",
|
||||
"*narrows eyes* Interesting. Going again. Don't get comfortable.",
|
||||
"_narrows eyes_ Interesting. Going again. Don't get comfortable.",
|
||||
}
|
||||
|
||||
// rivalClosingWin are said by the rival when they win the match.
|
||||
var rivalClosingWin = []string{
|
||||
"I told you. I always tell people. People don't listen. Then it's over and they know I was right. Every time.",
|
||||
"I hope the walk home gives you some time to think. Not about what you could have done differently. Just in general. Thinking is good for people.",
|
||||
"Take care of yourself out there. Eat something. You look like you haven't in a while. *folds hands* Actually that's your business. Good day.",
|
||||
"Take care of yourself out there. Eat something. You look like you haven't in a while. _folds hands_ Actually that's your business. Good day.",
|
||||
"The gold is mine. The record is updated. You may go.",
|
||||
"I've done my good deed. The world is slightly more balanced now. *nods slowly and walks away*",
|
||||
"You gave it everything. *looks you over* Everything wasn't enough. But still. Points for showing up.",
|
||||
"I've done my good deed. The world is slightly more balanced now. _nods slowly and walks away_",
|
||||
"You gave it everything. _looks you over_ Everything wasn't enough. But still. Points for showing up.",
|
||||
}
|
||||
|
||||
// rivalClosingLoss are said by the rival when they lose the match.
|
||||
var rivalClosingLoss = []string{
|
||||
"You look like you've won the damn lottery. The sheer excitement on your face from winning a handful of gold is genuinely depressing. *sighs* I suppose I've done my good deed for today. *checks off an item on a list titled 'Feed a waif today'*",
|
||||
"You look like you've won the damn lottery. The sheer excitement on your face from winning a handful of gold is genuinely depressing. _sighs_ I suppose I've done my good deed for today. _checks off an item on a list titled 'Feed a waif today'_",
|
||||
"Fine. Take it. You've clearly never had this much at one time and I'm not going to be the one to take that from you. Today.",
|
||||
"I'll be back. Enjoy the gold. Buy yourself something warm to eat. You look cold.",
|
||||
"*stares at you for a long moment* No. *turns and walks away*",
|
||||
"You won. I lost. The sun is back out. These things are unrelated. *looks up at the sky anyway*",
|
||||
"_stares at you for a long moment_ No. _turns and walks away_",
|
||||
"You won. I lost. The sun is back out. These things are unrelated. _looks up at the sky anyway_",
|
||||
"I'm not angry. I'm not even surprised. I'm something else entirely and I don't have the energy to name it right now. Congratulations.",
|
||||
"Keep it. Consider it a loan. *has not indicated when or if repayment is expected* You chuckle to yourself, convinced this was said in jest. You notice them scribbling a collection date.",
|
||||
"Keep it. Consider it a loan. _has not indicated when or if repayment is expected_ You chuckle to yourself, convinced this was said in jest. You notice them scribbling a collection date.",
|
||||
}
|
||||
|
||||
// rivalRoundOutcomeWin are the player-wins-round outcome lines.
|
||||
|
||||
54
internal/plugin/adventure_flavor_robbie.go
Normal file
54
internal/plugin/adventure_flavor_robbie.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package plugin
|
||||
|
||||
// ── Robbie the Friendly Bandit — Flavor Text ─────────────────────────────────
|
||||
|
||||
var robbieOpenings = []string{
|
||||
"Ello there! Robbie here. I had a little look at your inventory and -- well. " +
|
||||
"I'm just going to say it. You've been holdin' onto some gear that's well below your station " +
|
||||
"and I can't in good conscience let that stand. So I've gone ahead and sorted it for ya. You're welcome.",
|
||||
|
||||
"_tips hat_ Robbie, at your service. I noticed you had some items in there that were frankly " +
|
||||
"bringing down the average and I've taken the liberty of relieving you of them. " +
|
||||
"Left ya somethin' for the trouble. Fair's fair.",
|
||||
|
||||
"Right so. I was havin' a look -- as I do -- and I couldn't help but notice the state of your inventory. " +
|
||||
"No judgment! Well. Some judgment. I've handled it. Check your gold.",
|
||||
|
||||
"Hiya! It's Robbie. You had some things you didn't need. I took 'em. " +
|
||||
"I left ya €%d for the lot. I also donated everything to a good cause " +
|
||||
"so technically you're a philanthropist now. You're welcome for that too.",
|
||||
}
|
||||
|
||||
var robbieClosings = []string{
|
||||
"Right then. I'll be off. Don't go hoarding gear again now -- I'll know. _taps nose_",
|
||||
|
||||
"Pleasure doing business. As always, Robbie leaves things better than he found them. " +
|
||||
"Debatable, but that's his position and he's sticking to it.",
|
||||
|
||||
"You're all sorted. Don't thank me. Actually -- do thank me. I worked hard today.",
|
||||
|
||||
"_already gone by the time you read this_",
|
||||
}
|
||||
|
||||
var robbieMasterworkGotCard = "Now THIS -- _holds it up_ -- this is a lovely piece. " +
|
||||
"I'm not going to pretend otherwise. I've left you something special for this one. " +
|
||||
"Consider it a bonus from Robbie. Don't spend it all at once. _winks and disappears_"
|
||||
|
||||
var robbieMasterworkAlreadyHas = "Oh. I see you already have one of these. Excellent. " +
|
||||
"I'll keep this baby for myself then -- in case one of yous wakes up again and gives me a wallopping. o_o"
|
||||
|
||||
var robbieAllShopGear = "Nothing fancy today but that's alright. Clean inventory is its own reward. " +
|
||||
"Well. The €%d is the reward. The clean inventory is a bonus. Cheerio!"
|
||||
|
||||
// ── Room Announcements ───────────────────────────────────────────────────────
|
||||
|
||||
var robbieRoomStandard = "🎩 Robbie paid %s a visit and collected %d item(s) from their inventory. " +
|
||||
"€%d left behind. Everything donated to the community pot. " +
|
||||
"Robbie considers this a net positive for all parties."
|
||||
|
||||
var robbieRoomMasterworkCard = "🎩 Robbie paid %s a visit and helped himself to a Masterwork piece. " +
|
||||
"He left a Get Out of Medical Debt Free card and €%d. Robbie's words: \"Fair's fair.\""
|
||||
|
||||
var robbieRoomMasterworkAlreadyHas = "🎩 Robbie paid %s a visit, found they already had a " +
|
||||
"Get Out of Medical Debt Free card, and pocketed the Masterwork's card for himself. " +
|
||||
"He still left €%d. Robbie is if nothing else consistent."
|
||||
200
internal/plugin/adventure_flavor_shop.go
Normal file
200
internal/plugin/adventure_flavor_shop.go
Normal file
@@ -0,0 +1,200 @@
|
||||
package plugin
|
||||
|
||||
// ── Luigi's Shop Flavor Text ────────────────────────────────────────────────
|
||||
|
||||
var luigiGreetings = []string{
|
||||
"_leans on the counter_ Whadda y'all have? Menu is right above me. Everything's in stock, made fresh daily. Mostly.",
|
||||
"Welcome to Luigi's! Everything here is fresh out the oven. What can I get started for you today?",
|
||||
"_slides menu across the counter_ We've got some great stuff today. The Tier 3 armor just came in. Very fresh. Very fresh.",
|
||||
"ORDER'S UP for the last customer! _turns to you_ Hi! Welcome! What are we doing today, quick service or are you browsing?",
|
||||
"You look like someone who needs an upgrade. Good news -- Luigi's has got you covered. Fresh stock, hot and ready.",
|
||||
"_straightens apron_ We're fully staffed today and the inventory is looking real good. What are you in the market for?",
|
||||
"Luigi's! Home of the freshest gear in the region. _genuinely means this_ What can I get you?",
|
||||
}
|
||||
|
||||
var luigiWeaponIntros = []string{
|
||||
"Okay so the weapons -- everything here is made with quality ingredients. The higher the tier, the fresher the materials. You can taste the difference. _pause_ Metaphorically. Don't taste the sword.",
|
||||
"Weapons are our most popular item. People come in for the sword, stay for the full combo. Can I interest you in the armor to go with that?",
|
||||
"Fresh blades today. The Tier 4 came in this morning. I'd move on that before the rush.",
|
||||
}
|
||||
|
||||
var luigiArmorIntros = []string{
|
||||
"Armor is our specialty. Every piece is crafted to order. Well. It was crafted to order at some point. It's here now. Still fresh.",
|
||||
"We've got full chest protection from Tier 1 to Tier 5. The Tier 5 is premium. Made with only the finest materials. You can tell by the weight of it.",
|
||||
"The armor today is looking really good. Really fresh. I had the Tier 3 out front for the morning crowd and it moved fast.",
|
||||
}
|
||||
|
||||
var luigiHelmetIntros = []string{
|
||||
"Helmets. Very important. People forget about the helmet and then they're in here asking why things went wrong. Get the helmet.",
|
||||
"We've got a full helmet lineup. Everything from the Dented Iron Helm -- which is a great value, honestly, a great value -- up to the Crown of the Fallen. Very premium item. Very fresh.",
|
||||
}
|
||||
|
||||
var luigiBootsIntros = []string{
|
||||
"Boots are underrated. I always say that. People come in for the sword and walk out in the same boots they came in with and then wonder why their feet hurt. The boots matter.",
|
||||
"We've got boots across all tiers. The Tier 3 Swift Boots just came in. Fresh. Very fresh.",
|
||||
}
|
||||
|
||||
var luigiToolIntros = []string{
|
||||
"The tools. Yes. Very important for the miners. We've got everything from the Copper Pickaxe -- which, listen, it does the job, I'm not going to oversell it -- all the way up to the Diamond Pickaxe. Premium tool. Made with the best materials we have.",
|
||||
}
|
||||
|
||||
var luigiCategoryIntros = map[EquipmentSlot][]string{
|
||||
SlotWeapon: luigiWeaponIntros,
|
||||
SlotArmor: luigiArmorIntros,
|
||||
SlotHelmet: luigiHelmetIntros,
|
||||
SlotBoots: luigiBootsIntros,
|
||||
SlotTool: luigiToolIntros,
|
||||
}
|
||||
|
||||
// ── Purchase Confirmations ──────────────────────────────────────────────────
|
||||
|
||||
var luigiPurchaseConfirm = []string{
|
||||
"_punches it in_\nORDER UP! %s, one count! _slides it across the counter_\n\n€%d deducted. Good choice. I mean that -- genuinely good choice. Come back and see us.",
|
||||
"_punches it in_\nORDER UP! %s! _slides it across the counter with both hands_\n\n€%d deducted. You made the right call today. Luigi's guarantee.",
|
||||
"_punches it in_\nORDER UP! One %s, hot and ready! _nods approvingly_\n\n€%d deducted. That's going to serve you well. I stand behind every item on this floor.",
|
||||
}
|
||||
|
||||
var luigiTier5Confirm = []string{
|
||||
"_stops what he's doing_ Okay. This is -- I want to put this together myself. Not because the staff can't handle it, they absolutely can, I just -- a Tier 5 order deserves the personal touch. Give me one second.\nORDER UP! _voice cracks slightly_ You made a great call today. A genuinely great call.\n\n€%d deducted for **%s**.",
|
||||
"_takes off his apron, puts it back on, adjusts it_ This is a Tier 5 order. I need -- I need a moment. _assembles the order with visible care_\nORDER UP! _barely containing himself_ This is what I'm here for. This right here.\n\n€%d deducted for **%s**.",
|
||||
}
|
||||
|
||||
var luigiComboConfirm = []string{
|
||||
"Okay so we've got the full spread -- that's a combo right there and I love seeing a combo. Let me get all of that together for you.\nORDER UP! Full combo! _beams like he always does on a full combo_\n\n€%d deducted for **%s**.",
|
||||
"_looks at the order_ That's a combo. A real combo. People don't do combos enough and I've never understood why. This is how you shop.\nORDER UP! Full combo! _genuinely emotional_\n\n€%d deducted for **%s**.",
|
||||
}
|
||||
|
||||
// ── Insufficient Funds ──────────────────────────────────────────────────────
|
||||
|
||||
var luigiInsufficientFunds = []string{
|
||||
"I hear you and I wish I could help but I can't move product at a loss -- that's not how this works and honestly it wouldn't be fair to you either. Come back when you've got the funds and I'll make sure it's here for you. Fresh.",
|
||||
}
|
||||
|
||||
// ── Browsing Without Buying ─────────────────────────────────────────────────
|
||||
|
||||
var luigiBrowseTimeout = []string{
|
||||
"No rush. _glances at the door_ We've got people coming in so whenever you're ready. No pressure at all. Just -- whenever you're ready.",
|
||||
"_reorganizes the display case_ Take your time. I'm not going anywhere. The stock isn't going anywhere. We're all just... here. Waiting. Ready when you are.",
|
||||
"I'm going to need you to make a decision, we've got people behind you. _there are no people behind you_",
|
||||
}
|
||||
|
||||
// ── Maxed Out / Fully Kitted ────────────────────────────────────────────────
|
||||
|
||||
var luigiMaxedOut = []string{
|
||||
"_looks you over_ You're fully kitted out. I respect that. I really do. _pause_ Is there anything I can -- no. You've got everything. _nods once_ Come back if anything needs replacing. Or just come by. The door's always open.",
|
||||
}
|
||||
|
||||
// ── Masterwork / Arena Acknowledgement ──────────────────────────────────────
|
||||
|
||||
var luigiMasterworkAck = []string{
|
||||
"I see you've got a Masterwork piece in that slot. I can't beat that. But if you ever need a backup...",
|
||||
"That's a Masterwork item you've got there. _respectful nod_ I'm not going to pretend I carry anything that competes with that. But the rest of the menu is still worth a look.",
|
||||
"_notices the Arena gear_ You earned that. I know what that takes. I'm not here to replace it -- just here if you need anything else.",
|
||||
}
|
||||
|
||||
// ── Show All Comment ────────────────────────────────────────────────────────
|
||||
|
||||
var luigiShowAllComment = []string{
|
||||
"Sure, I'll show you everything. No judgment.",
|
||||
"Full menu? You got it. I respect the thoroughness.",
|
||||
}
|
||||
|
||||
// ── Unprompted Commentary ───────────────────────────────────────────────────
|
||||
|
||||
var luigiCommentary = []string{
|
||||
"_gestures at the Vorpal Sword_ That one's been sitting there for a while but I want to be clear -- it's still fresh. We rotate stock.",
|
||||
"The Dragonscale armor -- I'll be honest with you, I had a hard time sourcing that one. Supply chain issues. But it's here now and it is fresh.",
|
||||
"_taps the Dented Iron Helm_ This is a value item. I'm not going to pretend it's premium. But for the price? You're not going to beat it. I stand behind the value items.",
|
||||
"The Diamond Pickaxe just came in. Very fresh. Miners know. They always know when the fresh stock arrives.",
|
||||
"_quietly, about the Knobby Boots_ We still carry the Tier 0 boots. For... continuity. I don't recommend them. But they're there.",
|
||||
}
|
||||
|
||||
// ── Cancellation ────────────────────────────────────────────────────────────
|
||||
|
||||
var luigiCancellation = []string{
|
||||
"Understood. No hard feelings. The door's always open and the stock is always fresh.",
|
||||
"That's fine. Totally fine. I'm not going to pressure you. _moves the item back behind the counter_ Come back anytime.",
|
||||
}
|
||||
|
||||
// ── Item Descriptions ───────────────────────────────────────────────────────
|
||||
|
||||
type luigiItemKey struct {
|
||||
Slot EquipmentSlot
|
||||
Tier int
|
||||
}
|
||||
|
||||
// luigiItemDescriptions — full multi-sentence descriptions for the confirm view.
|
||||
var luigiItemDescriptions = map[luigiItemKey]string{
|
||||
// Weapons
|
||||
{SlotWeapon, 1}: "I'm not going to dress it up -- it's an entry level blade. Iron. Holds an edge if you treat it right. Good for someone just getting started and I mean that genuinely. Everyone starts somewhere and this is a solid somewhere.",
|
||||
{SlotWeapon, 2}: "Step up from the iron. You can feel it in the grip -- the steel is fresher, better balance. Not going to win any awards but I've seen people clear serious dungeons with this and come back smiling. Honest work. I stand behind it.",
|
||||
{SlotWeapon, 3}: "Now we're talking. Silver edge, proper weight, made with quality materials. This is the one people come back for and I'll tell you why -- it performs. Every time. If you're going to make one investment in your kit today, this is the one I'd point you to.",
|
||||
{SlotWeapon, 4}: "Premium item. Enchanted, fresh, made with the finest materials we source. I'm not going to tell you it's a limited time offer because it isn't -- we keep it stocked. What I will tell you is it moves fast because people who try it don't go back. That's not a sales pitch. That's just what happens.",
|
||||
{SlotWeapon, 5}: "_lowers voice_ The Vorpal Sword. I want to be straight with you -- I don't know everything that goes into this. The supplier doesn't share the full recipe and I've learned not to push on it. What I know is the quality. I've held one. You feel it immediately. This is the item. If you're here for this, you already know.",
|
||||
|
||||
// Armor
|
||||
{SlotArmor, 1}: "The name. I know. It's the supplier's name, not mine, and I'll be upfront -- there's some wear on it. It's not damage, it's use history, and it's been inspected. For the price it's the best value protection I carry and I wouldn't sell it if I didn't mean that.",
|
||||
{SlotArmor, 2}: "Okay so -- the name. I hear it every day and I'll tell you what I tell everyone: I questioned it too when it first came in. Had my guy look at every link. Solid. The name came from the original batch which had issues. This isn't that batch. This batch passed everything. I wouldn't have it on the floor otherwise.",
|
||||
{SlotArmor, 3}: "Full plate. Heavy -- and that's a good thing, that weight is quality steel, you don't get that from cheap materials. This is the real thing and if you're at the point in your adventure where you need real protection, this is what real looks like.",
|
||||
{SlotArmor, 4}: "My personal recommendation for anyone who's serious about staying alive out there. Enchanted, properly made, and I've had customers come back three times for this. Not because it broke -- because they wanted a second one. That tells you everything.",
|
||||
{SlotArmor, 5}: "_takes a breath_ I'm going to be straight with you about the Dragonscale. I know what it's made of. I know where it comes from. I just -- I don't ask the details because some things you don't need to know and the quality makes the conversation unnecessary. What I can tell you is it's the finest piece of armor in this shop and I've never had a complaint. Not one. In this business that's the only thing that matters.",
|
||||
|
||||
// Helmets
|
||||
{SlotHelmet, 1}: "It's an iron pot. I know what it looks like. I know the history. But listen -- it stops things from hitting your head and that's what a helmet does. For the price you are not going to find better head protection. I guarantee that personally.",
|
||||
{SlotHelmet, 2}: "The provenance is questionable. I'll give you that. The scratches were there when we got it and the previous owner didn't leave a forwarding address. But the steel is sound, the fit is decent, and it'll keep your skull in one piece. Nobody will compliment this helmet. Nobody needs to.",
|
||||
{SlotHelmet, 3}: "Reinforced, fitted properly -- or close enough. This is the helmet you buy when you're serious about not dying from the top down. Doesn't make you look competent but competence is what you do with the gear, not what the gear does to your face.",
|
||||
{SlotHelmet, 4}: "Guardian-grade. This helm has seen real battles, kept real heads intact, and carries itself with the quiet dignity you are only just beginning to deserve. I don't say that to be harsh. I say it because the helm is that good.",
|
||||
{SlotHelmet, 5}: "Crown of the Fallen. Every previous owner died in it. None of them died because of it. It will outlast you too. _pauses_ I want to be clear -- that's a selling point. This is the most premium headwear I carry and it's not even close.",
|
||||
|
||||
// Boots
|
||||
{SlotBoots, 1}: "Taken off a corpse. I'm not going to sugarcoat it. The corpse didn't need them anymore and honestly these boots have more life in them than some of the Tier 2 stock. For the price? It's a no-brainer. Don't think about the previous owner.",
|
||||
{SlotBoots, 2}: "They've been places. Bad places. Places that did things to the leather you'd rather not examine. But they've held together through all of it and that tells you something about the construction. Mild discomfort is a feature. It means they're working.",
|
||||
{SlotBoots, 3}: "Light enough to move in, grip decent enough to trust. These boots are built for someone who moves with purpose, which -- and I'm being honest with you -- you are in the process of becoming. The boots believe in you. Let them.",
|
||||
{SlotBoots, 4}: "Ranger's boots. You move quieter. Faster. Longer. The ground cooperates with these in a way I can't fully explain. The forest notices. Something shifts. I've had rangers come in and refuse to take them off to try a different pair. That's how good they are.",
|
||||
{SlotBoots, 5}: "The wind doesn't slow you. Terrain offers suggestions you are free to decline. These boots are an affront to the concept of obstacles. I had a pair behind the counter once. I don't anymore because someone bought them within an hour. That's the demand we're talking about here.",
|
||||
|
||||
// Tools
|
||||
{SlotTool, 1}: "Copper. Soft. It gets the job done if you hit very hard and the ore is feeling cooperative. I'm not going to oversell it. But for someone who's just getting into mining, it's the right starting point and I mean that.",
|
||||
{SlotTool, 2}: "Iron. Chipped to hell but bites the rock with something approaching intention. This is a pickaxe that exists and functions. The chip is cosmetic. Mostly. It won't affect performance in any way you'd notice if you weren't looking for it.",
|
||||
{SlotTool, 3}: "Steel, properly weighted, properly edged. The mountain will acknowledge this pickaxe. Not respect it -- that takes time -- but acknowledge it. This is the tool for someone who's ready to be taken seriously by the ore.",
|
||||
{SlotTool, 4}: "Mithril. Weighs nothing. Hits like consequence. The ores don't resist this so much as rearrange themselves out of respect. I've had miners come in after using one of these and I can see it in their eyes. Everything changed.",
|
||||
{SlotTool, 5}: "Diamond. Breaks anything short of fate and occasionally that too. I'm going to be honest -- this is the finest mining tool I have ever carried and I've been doing this long enough to know the difference. The only limits left are your arm, your nerve, and the number of hours in a day.",
|
||||
}
|
||||
|
||||
// luigiItemOneLiners — short quotes for the category listing view.
|
||||
var luigiItemOneLiners = map[luigiItemKey]string{
|
||||
// Weapons
|
||||
{SlotWeapon, 1}: "Entry level. Honest work.",
|
||||
{SlotWeapon, 2}: "Fresher steel. Better balance. Honest work.",
|
||||
{SlotWeapon, 3}: "The one people come back for. Every time.",
|
||||
{SlotWeapon, 4}: "Moves fast. People who try it don't go back.",
|
||||
{SlotWeapon, 5}: "This is the item. You already know.",
|
||||
|
||||
// Armor
|
||||
{SlotArmor, 1}: "Best value protection I carry. I mean that.",
|
||||
{SlotArmor, 2}: "The name came from the old batch. This batch is solid.",
|
||||
{SlotArmor, 3}: "This is what real protection looks like.",
|
||||
{SlotArmor, 4}: "My personal recommendation. Customers come back three times.",
|
||||
{SlotArmor, 5}: "The finest piece of armor in this shop. Not one complaint.",
|
||||
|
||||
// Helmets
|
||||
{SlotHelmet, 1}: "Stops things from hitting your head. Great value.",
|
||||
{SlotHelmet, 2}: "The steel is sound. Nobody will compliment it. Nobody needs to.",
|
||||
{SlotHelmet, 3}: "For when you're serious about not dying from the top down.",
|
||||
{SlotHelmet, 4}: "Guardian-grade. Quiet dignity. The real deal.",
|
||||
{SlotHelmet, 5}: "Every previous owner died in it. None because of it.",
|
||||
|
||||
// Boots
|
||||
{SlotBoots, 1}: "Don't think about the previous owner.",
|
||||
{SlotBoots, 2}: "Mild discomfort is a feature. Means they're working.",
|
||||
{SlotBoots, 3}: "Built for someone who moves with purpose.",
|
||||
{SlotBoots, 4}: "Rangers refuse to take them off. That's how good they are.",
|
||||
{SlotBoots, 5}: "An affront to the concept of obstacles.",
|
||||
|
||||
// Tools
|
||||
{SlotTool, 1}: "Gets the job done if the ore cooperates.",
|
||||
{SlotTool, 2}: "Exists and functions. The chip is cosmetic. Mostly.",
|
||||
{SlotTool, 3}: "The mountain will acknowledge this pickaxe.",
|
||||
{SlotTool, 4}: "Weighs nothing. Hits like consequence.",
|
||||
{SlotTool, 5}: "Breaks anything short of fate.",
|
||||
}
|
||||
259
internal/plugin/adventure_hospital.go
Normal file
259
internal/plugin/adventure_hospital.go
Normal file
@@ -0,0 +1,259 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// ── Pending Interaction Types ──────────────────────────────────────────────
|
||||
|
||||
type advPendingHospitalPay struct {
|
||||
Cost int64 // after-insurance amount (recomputed at confirm time for TOCTOU safety)
|
||||
}
|
||||
|
||||
// ── Hospital Command ───────────────────────────────────────────────────────
|
||||
|
||||
func (p *AdventurePlugin) handleHospitalCmd(ctx MessageContext) error {
|
||||
char, err := loadAdvCharacter(ctx.Sender)
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender, "You're not registered for Adventure yet. Type `!adventure` to begin.")
|
||||
}
|
||||
|
||||
// If alive, reject
|
||||
if char.Alive {
|
||||
text, _ := advPickFlavor(nurseJoyAlive, ctx.Sender, "hospital_alive")
|
||||
return p.SendDM(ctx.Sender, text)
|
||||
}
|
||||
|
||||
// On-demand revive if death timer already expired
|
||||
if char.DeadUntil != nil && time.Now().UTC().After(*char.DeadUntil) {
|
||||
char.Alive = true
|
||||
char.DeadUntil = nil
|
||||
if err := saveAdvCharacter(char); err != nil {
|
||||
slog.Error("hospital: on-demand revive failed", "user", char.UserID, "err", err)
|
||||
} else {
|
||||
p.SendDM(ctx.Sender, nurseJoyAlreadyRevived)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Compute costs
|
||||
beforeInsurance := int64(char.CombatLevel) * 125_000
|
||||
afterInsurance := int64(char.CombatLevel) * 25_000
|
||||
|
||||
// Check balance
|
||||
balance := p.euro.GetBalance(ctx.Sender)
|
||||
if balance < float64(afterInsurance) {
|
||||
// Can't afford — back to the ditch
|
||||
text, _ := advPickFlavor(nurseJoyCantAfford, ctx.Sender, "hospital_broke")
|
||||
p.SendDM(ctx.Sender, text)
|
||||
|
||||
// Room announcement
|
||||
gr := gamesRoom()
|
||||
if gr != "" {
|
||||
p.SendMessage(gr, fmt.Sprintf(hospitalDitchAnnounce, char.DisplayName))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if holiday for bill surcharge
|
||||
isHol, _ := isHolidayToday()
|
||||
|
||||
// Build the full multi-act DM
|
||||
var sb strings.Builder
|
||||
|
||||
// Act 1 — Admission
|
||||
if char.HospitalVisits == 0 {
|
||||
sb.WriteString(nurseJoyFirstVisit)
|
||||
}
|
||||
|
||||
admission, _ := advPickFlavor(nurseJoyAdmission, ctx.Sender, "hospital_admit")
|
||||
sb.WriteString(admission)
|
||||
sb.WriteString("\n\n")
|
||||
|
||||
// Act 2 — The Bill (before insurance)
|
||||
sb.WriteString(generateItemizedBill(beforeInsurance, char.HospitalVisits, isHol))
|
||||
sb.WriteString("\n\n")
|
||||
|
||||
delivery, _ := advPickFlavor(nurseJoyBillDelivery, ctx.Sender, "hospital_delivery")
|
||||
sb.WriteString(delivery)
|
||||
sb.WriteString("\n\n───\n\n")
|
||||
|
||||
// Act 3 — After Insurance
|
||||
afterText, _ := advPickFlavor(nurseJoyAfterInsurance, ctx.Sender, "hospital_after")
|
||||
// Some entries have two %d placeholders (she says the number twice)
|
||||
count := strings.Count(afterText, "%d")
|
||||
switch count {
|
||||
case 1:
|
||||
afterText = fmt.Sprintf(afterText, afterInsurance)
|
||||
case 2:
|
||||
afterText = fmt.Sprintf(afterText, afterInsurance, afterInsurance)
|
||||
}
|
||||
sb.WriteString(afterText)
|
||||
sb.WriteString("\n\n")
|
||||
|
||||
// Payment prompt
|
||||
sb.WriteString(fmt.Sprintf("**St. Guildmore's Memorial Hospital**\nAmount due: **€%d**\n\n", afterInsurance))
|
||||
sb.WriteString("Pay now? (yes / no)\n\n")
|
||||
sb.WriteString("*Note: Declining payment will result in discharge to the natural respawn queue. " +
|
||||
"You'll be back tomorrow. The chair in the waiting room is available in the meantime.*")
|
||||
|
||||
// Store pending interaction
|
||||
p.pending.Store(string(ctx.Sender), &advPendingInteraction{
|
||||
Type: "hospital_pay",
|
||||
Data: &advPendingHospitalPay{Cost: afterInsurance},
|
||||
ExpiresAt: time.Now().Add(advDMResponseWindow),
|
||||
})
|
||||
p.advMarkMenuSent(ctx.Sender)
|
||||
|
||||
return p.SendDM(ctx.Sender, sb.String())
|
||||
}
|
||||
|
||||
// ── Payment Resolution ─────────────────────────────────────────────────────
|
||||
|
||||
func (p *AdventurePlugin) resolveHospitalPay(ctx MessageContext, interaction *advPendingInteraction) error {
|
||||
reply := strings.TrimSpace(strings.ToLower(ctx.Body))
|
||||
|
||||
if reply == "yes" || reply == "y" {
|
||||
// Acquire lock
|
||||
mu := p.advUserLock(ctx.Sender)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
// Reload fresh (TOCTOU protection)
|
||||
char, err := loadAdvCharacter(ctx.Sender)
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender, "Something went wrong loading your character.")
|
||||
}
|
||||
|
||||
// Already alive (or timer expired while they were deciding)?
|
||||
if char.Alive {
|
||||
return p.SendDM(ctx.Sender, nurseJoyAlreadyRevived)
|
||||
}
|
||||
if char.DeadUntil != nil && time.Now().UTC().After(*char.DeadUntil) {
|
||||
char.Alive = true
|
||||
char.DeadUntil = nil
|
||||
_ = saveAdvCharacter(char)
|
||||
return p.SendDM(ctx.Sender, nurseJoyAlreadyRevived)
|
||||
}
|
||||
|
||||
// Recompute cost from current combat level (don't trust cached value)
|
||||
cost := int64(char.CombatLevel) * 25_000
|
||||
|
||||
// Check balance
|
||||
balance := p.euro.GetBalance(ctx.Sender)
|
||||
if balance < float64(cost) {
|
||||
text, _ := advPickFlavor(nurseJoyCantAfford, ctx.Sender, "hospital_broke")
|
||||
p.SendDM(ctx.Sender, text)
|
||||
|
||||
gr := gamesRoom()
|
||||
if gr != "" {
|
||||
p.SendMessage(gr, fmt.Sprintf(hospitalDitchAnnounce, char.DisplayName))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Debit
|
||||
if !p.euro.Debit(ctx.Sender, float64(cost), "hospital_revival") {
|
||||
text, _ := advPickFlavor(nurseJoyCantAfford, ctx.Sender, "hospital_broke")
|
||||
return p.SendDM(ctx.Sender, text)
|
||||
}
|
||||
|
||||
// Revive
|
||||
char.Alive = true
|
||||
char.DeadUntil = nil
|
||||
char.HospitalVisits++
|
||||
if err := saveAdvCharacter(char); err != nil {
|
||||
slog.Error("hospital: failed to save character after revival", "user", char.UserID, "err", err)
|
||||
// Refund on save failure
|
||||
p.euro.Credit(ctx.Sender, float64(cost), "hospital_revival_refund")
|
||||
return p.SendDM(ctx.Sender, "Something went wrong during recovery. Your payment has been refunded.")
|
||||
}
|
||||
|
||||
// Discharge DM
|
||||
p.SendDM(ctx.Sender, fmt.Sprintf(
|
||||
"✅ **Discharged — you're alive!**\n\n"+
|
||||
"€%d deducted. Nurse Joy wishes you the best. "+
|
||||
"She means it. She always means it.\n\n"+
|
||||
"Go get 'em. Type `!adventure` when you're ready.",
|
||||
cost))
|
||||
|
||||
// Room announcement
|
||||
gr := gamesRoom()
|
||||
if gr != "" {
|
||||
p.SendMessage(gr, fmt.Sprintf(hospitalDischargeAnnounce, char.DisplayName))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Declined or anything else — back to the ditch
|
||||
p.SendDM(ctx.Sender, "Understood. Nurse Joy nods cheerfully and signals the orderlies. "+
|
||||
"You're wheeled back to where they found you.\n\n"+
|
||||
"Natural respawn will occur in due time. Rest up.")
|
||||
|
||||
char, err := loadAdvCharacter(ctx.Sender)
|
||||
if err == nil {
|
||||
gr := gamesRoom()
|
||||
if gr != "" {
|
||||
p.SendMessage(gr, fmt.Sprintf(hospitalDitchAnnounce, char.DisplayName))
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ── Hospital Ad (sent after death) ─────────────────────────────────────────
|
||||
|
||||
func (p *AdventurePlugin) sendHospitalAd(userID id.UserID, char *AdventureCharacter) {
|
||||
cost := int64(char.CombatLevel) * 25_000
|
||||
respawnTime := "unknown"
|
||||
if char.DeadUntil != nil {
|
||||
respawnTime = char.DeadUntil.Format("15:04")
|
||||
}
|
||||
|
||||
text := fmt.Sprintf(
|
||||
"🏥 **St. Guildmore's Memorial Hospital**\n\n"+
|
||||
"Nurse Joy has been notified. A bed is being prepared.\n\n"+
|
||||
"Type `!hospital` to check in for same-day revival.\n"+
|
||||
"Cost estimate: €%d (after Guild insurance)\n\n"+
|
||||
"Or rest up — natural respawn at %s UTC.",
|
||||
cost, respawnTime)
|
||||
|
||||
go func() {
|
||||
time.Sleep(3 * time.Second)
|
||||
if err := p.SendDM(userID, text); err != nil {
|
||||
slog.Error("hospital: failed to send hospital ad", "user", userID, "err", err)
|
||||
}
|
||||
p.scheduleHospitalNudge(userID)
|
||||
}()
|
||||
}
|
||||
|
||||
// ── Hospital Nudge (2-hour follow-up) ──────────────────────────────────────
|
||||
|
||||
func (p *AdventurePlugin) scheduleHospitalNudge(userID id.UserID) {
|
||||
go func() {
|
||||
time.Sleep(2 * time.Hour)
|
||||
|
||||
char, err := loadAdvCharacter(userID)
|
||||
if err != nil || char.Alive {
|
||||
return // already revived or error
|
||||
}
|
||||
|
||||
// Don't nudge if already in hospital flow
|
||||
if val, ok := p.pending.Load(string(userID)); ok {
|
||||
if pi, ok := val.(*advPendingInteraction); ok && pi.Type == "hospital_pay" {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
text, _ := advPickFlavor(nurseJoyNudge, userID, "hospital_nudge")
|
||||
if err := p.SendDM(userID, text); err != nil {
|
||||
slog.Error("hospital: failed to send nudge", "user", userID, "err", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -177,18 +177,19 @@ func (p *AdventurePlugin) checkMasterworkDrop(userID id.UserID, char *AdventureC
|
||||
// Check current equipment in target slot
|
||||
existing := equip[def.Slot]
|
||||
|
||||
// Determine: auto-equip, inventory, or silent discard
|
||||
// Determine: auto-equip, inventory, or silent discard.
|
||||
// Compare effective tiers: Masterwork = tier*1.25, Arena = tier*1.5, Shop = tier.
|
||||
// Only auto-equip if the new Masterwork drop is strictly better.
|
||||
newEffective := float64(def.Tier) * 1.25 // incoming masterwork
|
||||
existingEffective := advEffectiveTier(existing)
|
||||
|
||||
autoEquip := false
|
||||
if existing == nil {
|
||||
autoEquip = true
|
||||
} else if existing.Masterwork {
|
||||
if def.Tier > existing.Tier {
|
||||
autoEquip = true // upgrade over lower-tier masterwork
|
||||
} else {
|
||||
return // silent discard: same or higher tier masterwork already equipped
|
||||
}
|
||||
} else {
|
||||
autoEquip = true // any masterwork > shop gear
|
||||
} else if existing.Masterwork && def.Tier <= existing.Tier {
|
||||
return // silent discard: same or lower tier masterwork already equipped
|
||||
} else if newEffective > existingEffective {
|
||||
autoEquip = true // genuinely better than what they have
|
||||
}
|
||||
|
||||
// First-drop detection
|
||||
|
||||
@@ -140,7 +140,7 @@ func renderAdvCharacterSheet(char *AdventureCharacter, equip map[EquipmentSlot]*
|
||||
slotEmoji(slot), slotTitle(slot), eq.Name, marker, eq.Tier, eq.Condition, mastery))
|
||||
}
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf(" Equipment Score: %d\n", eqScore))
|
||||
sb.WriteString(fmt.Sprintf(" Equipment Score: %.1f\n", eqScore))
|
||||
|
||||
// Treasures
|
||||
if len(treasures) > 0 {
|
||||
@@ -474,20 +474,15 @@ func advClosingBlock(outcome AdvOutcomeType, userID id.UserID, location string,
|
||||
// ── Death Status DM ──────────────────────────────────────────────────────────
|
||||
|
||||
func renderAdvDeathStatusDM(char *AdventureCharacter) string {
|
||||
text, _ := advPickFlavor(DeathDM, char.UserID, "death_dm")
|
||||
remaining := ""
|
||||
cost := int64(char.CombatLevel) * 25_000
|
||||
remaining := "unknown"
|
||||
if char.DeadUntil != nil {
|
||||
remaining = char.DeadUntil.Format("15:04")
|
||||
}
|
||||
location := char.GrudgeLocation
|
||||
if location == "" {
|
||||
location = "an unknown location"
|
||||
}
|
||||
return advSubstituteFlavor(text, map[string]string{
|
||||
"{name}": char.DisplayName,
|
||||
"{time}": remaining,
|
||||
"{location}": location,
|
||||
})
|
||||
return fmt.Sprintf("💀 You're still dead.\n\n"+
|
||||
"🏥 Type `!hospital` for same-day revival (€%d after insurance)\n"+
|
||||
"⏳ Or wait — natural respawn at %s UTC",
|
||||
cost, remaining)
|
||||
}
|
||||
|
||||
// ── Respawn DM ───────────────────────────────────────────────────────────────
|
||||
|
||||
290
internal/plugin/adventure_robbie.go
Normal file
290
internal/plugin/adventure_robbie.go
Normal file
@@ -0,0 +1,290 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math/rand/v2"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// ── Robbie the Friendly Bandit ───────────────────────────────────────────────
|
||||
//
|
||||
// Robbie is an automated NPC who visits players at a random hour each day,
|
||||
// takes sub-tier inventory items, leaves €50 per item, donates everything
|
||||
// to the community pot, and occasionally drops a Get Out of Medical Debt
|
||||
// Free card when collecting Masterwork gear.
|
||||
|
||||
// In-memory target hour — picked fresh each day, regenerated on restart.
|
||||
var (
|
||||
robbieTargetHour int = -1
|
||||
robbieTargetDay string // "2006-01-02"
|
||||
)
|
||||
|
||||
// ── Ticker ───────────────────────────────────────────────────────────────────
|
||||
|
||||
func (p *AdventurePlugin) robbieTicker() {
|
||||
ticker := time.NewTicker(1 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
now := time.Now().UTC()
|
||||
dateKey := now.Format("2006-01-02")
|
||||
|
||||
// At midnight (or first tick of the day), pick today's target hour.
|
||||
if robbieTargetDay != dateKey {
|
||||
robbieTargetHour = 8 + rand.IntN(14) // 8–21 inclusive
|
||||
robbieTargetDay = dateKey
|
||||
slog.Info("adventure: robbie target hour set", "hour", robbieTargetHour, "date", dateKey)
|
||||
}
|
||||
|
||||
if now.Hour() != robbieTargetHour || now.Minute() != 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
jobName := "adventure_robbie"
|
||||
if db.JobCompleted(jobName, dateKey) {
|
||||
continue
|
||||
}
|
||||
|
||||
slog.Info("adventure: robbie sweep starting")
|
||||
p.robbieVisitAll()
|
||||
db.MarkJobCompleted(jobName, dateKey)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Visit All Players ────────────────────────────────────────────────────────
|
||||
|
||||
func (p *AdventurePlugin) robbieVisitAll() {
|
||||
chars, err := loadAllAdvCharacters()
|
||||
if err != nil {
|
||||
slog.Error("adventure: robbie: failed to load characters", "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
rand.Shuffle(len(chars), func(i, j int) { chars[i], chars[j] = chars[j], chars[i] })
|
||||
|
||||
for i, char := range chars {
|
||||
if !char.Alive {
|
||||
continue
|
||||
}
|
||||
|
||||
// Jitter between players to avoid Matrix rate limits.
|
||||
if i > 0 {
|
||||
time.Sleep(time.Duration(1000+rand.IntN(2000)) * time.Millisecond)
|
||||
}
|
||||
|
||||
p.robbieVisitPlayer(char.UserID, char.DisplayName)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Single Player Visit ──────────────────────────────────────────────────────
|
||||
|
||||
func (p *AdventurePlugin) robbieVisitPlayer(userID id.UserID, displayName string) {
|
||||
mu := p.advUserLock(userID)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
// Load inventory + equipped gear
|
||||
inv, err := loadAdvInventory(userID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
equip, err := loadAdvEquipment(userID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Find qualifying items
|
||||
qualifying := robbieQualifyingItems(inv, equip)
|
||||
if len(qualifying) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// 40% chance of visiting
|
||||
if rand.Float64() >= 0.40 {
|
||||
return
|
||||
}
|
||||
|
||||
// Execute the visit — collect items
|
||||
var totalPayout int64
|
||||
var communityTotal int64
|
||||
var masterworkTaken bool
|
||||
var takenItems []AdvItem
|
||||
|
||||
for _, item := range qualifying {
|
||||
if err := removeAdvInventoryItem(item.ID); err != nil {
|
||||
slog.Error("adventure: robbie: failed to remove item", "item_id", item.ID, "err", err)
|
||||
continue
|
||||
}
|
||||
takenItems = append(takenItems, item)
|
||||
totalPayout += 50
|
||||
communityTotal += item.Value
|
||||
if item.Type == "MasterworkGear" {
|
||||
masterworkTaken = true
|
||||
}
|
||||
}
|
||||
|
||||
if len(takenItems) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// Credit player
|
||||
p.euro.Credit(userID, float64(totalPayout), "robbie_handling_fee")
|
||||
|
||||
// Donate to community pot
|
||||
if communityTotal > 0 {
|
||||
communityPotAdd(int(communityTotal))
|
||||
}
|
||||
|
||||
// Handle Get Out of Medical Debt Free card
|
||||
hasCard := robbiePlayerHasCard(userID)
|
||||
gaveCard := false
|
||||
if masterworkTaken && !hasCard {
|
||||
_ = addAdvInventoryItem(userID, AdvItem{
|
||||
Name: "Get Out of Medical Debt Free",
|
||||
Type: "card",
|
||||
Tier: 0,
|
||||
Value: 0,
|
||||
})
|
||||
gaveCard = true
|
||||
}
|
||||
|
||||
// Update visit count
|
||||
char, err := loadAdvCharacter(userID)
|
||||
if err == nil {
|
||||
char.RobbieVisitCount++
|
||||
_ = saveAdvCharacter(char)
|
||||
}
|
||||
|
||||
// Send DM
|
||||
dm := renderRobbieDM(userID, takenItems, totalPayout, masterworkTaken, gaveCard)
|
||||
if err := p.SendDM(userID, dm); err != nil {
|
||||
slog.Error("adventure: robbie: failed to send DM", "user", userID, "err", err)
|
||||
}
|
||||
|
||||
// Room announcement
|
||||
gr := gamesRoom()
|
||||
if gr != "" {
|
||||
announcement := renderRobbieRoomAnnouncement(displayName, len(takenItems), totalPayout, masterworkTaken, gaveCard)
|
||||
p.SendMessage(gr, announcement)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Qualifying Items ─────────────────────────────────────────────────────────
|
||||
|
||||
func robbieQualifyingItems(inv []AdvItem, equip map[EquipmentSlot]*AdvEquipment) []AdvItem {
|
||||
var result []AdvItem
|
||||
for _, item := range inv {
|
||||
// Only gear with a slot (skip ores, wood, fruit, gems, fish, etc.)
|
||||
if item.Slot == "" {
|
||||
continue
|
||||
}
|
||||
// Never touch Arena gear or cards
|
||||
if item.Type == "ArenaGear" || item.Type == "card" {
|
||||
continue
|
||||
}
|
||||
|
||||
eq, hasSlot := equip[item.Slot]
|
||||
if !hasSlot {
|
||||
continue
|
||||
}
|
||||
|
||||
if item.Type == "MasterworkGear" {
|
||||
// Take MW items only if equipped piece in same slot is also MW
|
||||
// and has effective tier >= this item's effective tier.
|
||||
if eq.Masterwork && advEffectiveTier(eq) >= float64(item.Tier)*1.25 {
|
||||
result = append(result, item)
|
||||
}
|
||||
} else {
|
||||
// Regular shop gear: take if item tier < equipped tier
|
||||
if item.Tier < eq.Tier {
|
||||
result = append(result, item)
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ── Card Check ───────────────────────────────────────────────────────────────
|
||||
|
||||
func robbiePlayerHasCard(userID id.UserID) bool {
|
||||
inv, err := loadAdvInventory(userID)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for _, item := range inv {
|
||||
if item.Type == "card" && item.Name == "Get Out of Medical Debt Free" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ── DM Rendering ─────────────────────────────────────────────────────────────
|
||||
|
||||
func renderRobbieDM(userID id.UserID, items []AdvItem, total int64, mwTaken, gaveCard bool) string {
|
||||
var sb strings.Builder
|
||||
|
||||
// Opening
|
||||
opening, _ := advPickFlavor(robbieOpenings, userID, "robbie_opening")
|
||||
if strings.Contains(opening, "%d") {
|
||||
opening = fmt.Sprintf(opening, total)
|
||||
}
|
||||
sb.WriteString(opening)
|
||||
sb.WriteString("\n\n")
|
||||
|
||||
// Itemized list
|
||||
sb.WriteString("Items collected:\n\n")
|
||||
cardShownOnLine := false
|
||||
for _, item := range items {
|
||||
emoji := slotEmoji(item.Slot)
|
||||
if item.Type == "MasterworkGear" {
|
||||
sb.WriteString(fmt.Sprintf(" %s %s (Masterwork T%d) → €50", emoji, item.Name, item.Tier))
|
||||
if gaveCard && !cardShownOnLine {
|
||||
sb.WriteString(" + 🃏 Get Out of Medical Debt Free card")
|
||||
cardShownOnLine = true
|
||||
}
|
||||
} else {
|
||||
sb.WriteString(fmt.Sprintf(" %s %s (T%d) → €50", emoji, item.Name, item.Tier))
|
||||
}
|
||||
sb.WriteByte('\n')
|
||||
}
|
||||
|
||||
sb.WriteString(fmt.Sprintf("\nTotal left for you: €%d\n", total))
|
||||
sb.WriteString("Everything else donated to the community pot. Good on ya.\n\n")
|
||||
|
||||
// Context line
|
||||
if mwTaken {
|
||||
if gaveCard {
|
||||
sb.WriteString(robbieMasterworkGotCard)
|
||||
} else {
|
||||
sb.WriteString(robbieMasterworkAlreadyHas)
|
||||
}
|
||||
} else {
|
||||
sb.WriteString(fmt.Sprintf(robbieAllShopGear, total))
|
||||
}
|
||||
sb.WriteString("\n\n")
|
||||
|
||||
// Closing
|
||||
closing, _ := advPickFlavor(robbieClosings, userID, "robbie_closing")
|
||||
sb.WriteString(closing)
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// ── Room Announcement ────────────────────────────────────────────────────────
|
||||
|
||||
func renderRobbieRoomAnnouncement(name string, count int, total int64, mwTaken, gaveCard bool) string {
|
||||
if mwTaken && gaveCard {
|
||||
return fmt.Sprintf(robbieRoomMasterworkCard, name, total)
|
||||
}
|
||||
if mwTaken && !gaveCard {
|
||||
return fmt.Sprintf(robbieRoomMasterworkAlreadyHas, name, total)
|
||||
}
|
||||
return fmt.Sprintf(robbieRoomStandard, name, count, total)
|
||||
}
|
||||
@@ -2,7 +2,10 @@ package plugin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math/rand/v2"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
@@ -36,66 +39,237 @@ func slotTitle(slot EquipmentSlot) string {
|
||||
return strings.ToUpper(s[:1]) + s[1:]
|
||||
}
|
||||
|
||||
// advShopOverview shows a compact category menu with current equipment and next upgrade.
|
||||
func advShopOverview(equip map[EquipmentSlot]*AdvEquipment, balance float64) string {
|
||||
// ── Pending Interaction Types ───────────────────────────────────────────────
|
||||
|
||||
type advPendingShopCategory struct {
|
||||
ShowAll bool
|
||||
}
|
||||
|
||||
type advPendingShopItem struct {
|
||||
Slot EquipmentSlot
|
||||
ShowAll bool
|
||||
}
|
||||
|
||||
type advPendingShopConfirm struct {
|
||||
Slot EquipmentSlot
|
||||
Tier int
|
||||
}
|
||||
|
||||
// ── Session Tracking ────────────────────────────────────────────────────────
|
||||
|
||||
type advShopSession struct {
|
||||
StartedAt time.Time
|
||||
ItemsBought int
|
||||
}
|
||||
|
||||
func (p *AdventurePlugin) shopSessionGet(userID id.UserID) *advShopSession {
|
||||
if val, ok := p.shopSessions.Load(string(userID)); ok {
|
||||
return val.(*advShopSession)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *AdventurePlugin) shopSessionStart(userID id.UserID) {
|
||||
if p.shopSessionGet(userID) == nil {
|
||||
p.shopSessions.Store(string(userID), &advShopSession{
|
||||
StartedAt: time.Now(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (p *AdventurePlugin) shopSessionBump(userID id.UserID) {
|
||||
sess := p.shopSessionGet(userID)
|
||||
if sess != nil {
|
||||
sess.ItemsBought++
|
||||
}
|
||||
}
|
||||
|
||||
func (p *AdventurePlugin) shopSessionEnd(userID id.UserID) {
|
||||
p.shopSessions.Delete(string(userID))
|
||||
}
|
||||
|
||||
// ── Browse Timeout ──────────────────────────────────────────────────────────
|
||||
|
||||
// shopNudgeGen tracks a generation counter per user so only the latest nudge fires.
|
||||
// Key: userID string, Value: *int64 (atomic counter)
|
||||
func (p *AdventurePlugin) shopScheduleBrowseNudge(userID id.UserID) {
|
||||
// Bump generation so any prior goroutine becomes stale.
|
||||
var gen int64
|
||||
if val, ok := p.shopSessions.Load(string(userID) + ":nudge_gen"); ok {
|
||||
gen = val.(int64) + 1
|
||||
}
|
||||
p.shopSessions.Store(string(userID)+":nudge_gen", gen)
|
||||
|
||||
capturedGen := gen
|
||||
go func() {
|
||||
time.Sleep(2 * time.Minute)
|
||||
// Check if this goroutine is still the latest.
|
||||
if val, ok := p.shopSessions.Load(string(userID) + ":nudge_gen"); !ok || val.(int64) != capturedGen {
|
||||
return
|
||||
}
|
||||
val, ok := p.pending.Load(string(userID))
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
pi, ok := val.(*advPendingInteraction)
|
||||
if !ok || !strings.HasPrefix(pi.Type, "shop_") {
|
||||
return
|
||||
}
|
||||
flavor, _ := advPickFlavor(luigiBrowseTimeout, userID, "luigi_browse")
|
||||
p.SendDM(userID, fmt.Sprintf("*%s*", flavor))
|
||||
}()
|
||||
}
|
||||
|
||||
// ── Display: Luigi Greeting + Category Menu ─────────────────────────────────
|
||||
|
||||
func luigiShopGreeting(userID id.UserID, equip map[EquipmentSlot]*AdvEquipment, balance float64, showAll bool) string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString("🛒 **Equipment Shop**\n")
|
||||
sb.WriteString(fmt.Sprintf("💰 Balance: €%.0f\n\n", balance))
|
||||
|
||||
// Check if fully maxed out.
|
||||
allMaxed := true
|
||||
for _, slot := range allSlots {
|
||||
current := equip[slot]
|
||||
currentTier := 0
|
||||
currentName := "None"
|
||||
if current != nil {
|
||||
currentTier = current.Tier
|
||||
currentName = current.Name
|
||||
eq := equip[slot]
|
||||
if eq == nil || eq.Tier < 5 {
|
||||
allMaxed = false
|
||||
break
|
||||
}
|
||||
|
||||
emoji := slotEmoji(slot)
|
||||
title := slotTitle(slot)
|
||||
|
||||
// Find next upgrade.
|
||||
defs := equipmentTiers[slot]
|
||||
nextUpgrade := ""
|
||||
for _, def := range defs {
|
||||
if def.Tier > currentTier && def.Price > 0 {
|
||||
nextUpgrade = fmt.Sprintf("Next: %s — €%.0f", def.Name, def.Price)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
sb.WriteString(fmt.Sprintf("%s **%s** — %s (T%d)\n", emoji, title, currentName, currentTier))
|
||||
if nextUpgrade != "" {
|
||||
sb.WriteString(fmt.Sprintf(" %s\n", nextUpgrade))
|
||||
} else {
|
||||
sb.WriteString(" ✨ Maxed out!\n")
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
sb.WriteString("Browse a category: `!adventure shop <category>`\n")
|
||||
sb.WriteString("Categories: `weapon` · `armor` · `helmet` · `boots` · `tool`")
|
||||
if allMaxed {
|
||||
flavor, _ := advPickFlavor(luigiMaxedOut, userID, "luigi_maxed")
|
||||
sb.WriteString(fmt.Sprintf("🛒 **Luigi's**\n\n*%s*", flavor))
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
greet, _ := advPickFlavor(luigiGreetings, userID, "luigi_greet")
|
||||
sb.WriteString(fmt.Sprintf("🛒 **Luigi's**\n💰 Balance: €%.0f\n\n", balance))
|
||||
sb.WriteString(fmt.Sprintf("*%s*\n\n", greet))
|
||||
|
||||
sb.WriteString("⚔️ Weapons 🛡️ Armor\n")
|
||||
sb.WriteString("🪖 Helmets 👢 Boots\n")
|
||||
sb.WriteString("⛏️ Tools\n\n")
|
||||
|
||||
if showAll {
|
||||
flavor, _ := advPickFlavor(luigiShowAllComment, userID, "luigi_showall")
|
||||
sb.WriteString(fmt.Sprintf("*%s*\n\n", flavor))
|
||||
}
|
||||
|
||||
sb.WriteString("Reply with a category name to browse.")
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
var shopCategoryAliases = map[string]EquipmentSlot{
|
||||
"weapon": SlotWeapon, "weapons": SlotWeapon, "sword": SlotWeapon, "swords": SlotWeapon,
|
||||
"armor": SlotArmor, "armour": SlotArmor,
|
||||
"helmet": SlotHelmet, "helm": SlotHelmet, "helmets": SlotHelmet,
|
||||
"boots": SlotBoots, "boot": SlotBoots,
|
||||
"tool": SlotTool, "tools": SlotTool, "pickaxe": SlotTool,
|
||||
}
|
||||
// ── Display: Category Item List ─────────────────────────────────────────────
|
||||
|
||||
// advParseShopCategory maps user input to an EquipmentSlot.
|
||||
func advParseShopCategory(input string) EquipmentSlot {
|
||||
return shopCategoryAliases[strings.ToLower(strings.TrimSpace(input))]
|
||||
}
|
||||
|
||||
// advShopCategory shows detailed listings for a single equipment slot.
|
||||
func advShopCategory(slot EquipmentSlot, equip map[EquipmentSlot]*AdvEquipment, balance float64) string {
|
||||
func luigiCategoryView(userID id.UserID, slot EquipmentSlot, equip map[EquipmentSlot]*AdvEquipment, balance float64, showAll bool) string {
|
||||
var sb strings.Builder
|
||||
|
||||
current := equip[slot]
|
||||
currentTier := 0
|
||||
currentName := "None"
|
||||
isMWOrArena := false
|
||||
if current != nil {
|
||||
currentTier = current.Tier
|
||||
currentName = current.Name
|
||||
isMWOrArena = current.Masterwork || current.ArenaTier > 0
|
||||
}
|
||||
|
||||
emoji := slotEmoji(slot)
|
||||
title := strings.ToUpper(string(slot))
|
||||
|
||||
// Category intro
|
||||
if intros, ok := luigiCategoryIntros[slot]; ok && len(intros) > 0 {
|
||||
intro, _ := advPickFlavor(intros, userID, "luigi_cat_"+string(slot))
|
||||
sb.WriteString(fmt.Sprintf("*%s*\n\n", intro))
|
||||
}
|
||||
|
||||
sb.WriteString(fmt.Sprintf("%s **%s** — Your current: %s (T%d)", emoji, title, currentName, currentTier))
|
||||
if isMWOrArena {
|
||||
sb.WriteString(" ⭐")
|
||||
}
|
||||
sb.WriteString("\n\n")
|
||||
|
||||
if isMWOrArena {
|
||||
ack, _ := advPickFlavor(luigiMasterworkAck, userID, "luigi_mw_ack")
|
||||
sb.WriteString(fmt.Sprintf("*%s*\n\n", ack))
|
||||
}
|
||||
|
||||
defs := equipmentTiers[slot]
|
||||
hasItems := false
|
||||
|
||||
// Show current equipped item first.
|
||||
if current != nil {
|
||||
for _, def := range defs {
|
||||
if def.Tier == currentTier {
|
||||
sb.WriteString(fmt.Sprintf("🟢 %-28s T%d €%.0f Currently equipped\n",
|
||||
def.Name, def.Tier, def.Price))
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, def := range defs {
|
||||
if def.Price == 0 {
|
||||
continue // skip tier 0
|
||||
}
|
||||
if def.Tier == currentTier {
|
||||
continue // already shown as 🟢
|
||||
}
|
||||
if !showAll && def.Tier < currentTier {
|
||||
continue // skip downgrades unless show all
|
||||
}
|
||||
|
||||
hasItems = true
|
||||
|
||||
// Upgrade indicator
|
||||
var indicator string
|
||||
switch {
|
||||
case def.Tier > currentTier:
|
||||
indicator = "⬆️"
|
||||
case def.Tier == currentTier:
|
||||
indicator = "➡️"
|
||||
default:
|
||||
indicator = "⬇️"
|
||||
}
|
||||
|
||||
// Affordability
|
||||
afford := "✅"
|
||||
if balance < def.Price {
|
||||
afford = "💸"
|
||||
}
|
||||
|
||||
// One-liner
|
||||
oneLiner := ""
|
||||
if line, ok := luigiItemOneLiners[luigiItemKey{slot, def.Tier}]; ok {
|
||||
oneLiner = fmt.Sprintf(" \"%s\"", line)
|
||||
}
|
||||
|
||||
sb.WriteString(fmt.Sprintf("%s %-28s T%d €%-8.0f %s%s\n",
|
||||
indicator, def.Name, def.Tier, def.Price, afford, oneLiner))
|
||||
}
|
||||
|
||||
// Occasional unprompted Luigi commentary (~30% chance).
|
||||
if hasItems && rand.IntN(100) < 30 {
|
||||
comment, _ := advPickFlavor(luigiCommentary, userID, "luigi_comment")
|
||||
sb.WriteString(fmt.Sprintf("\n*%s*\n", comment))
|
||||
}
|
||||
|
||||
if !hasItems {
|
||||
sb.WriteString("✨ You've reached max tier! Nothing left to buy here.\n")
|
||||
}
|
||||
|
||||
if !showAll && currentTier > 1 {
|
||||
sb.WriteString(fmt.Sprintf("\nTiers below T%d omitted. Use `!adventure shop show all` to see everything.\n", currentTier))
|
||||
}
|
||||
|
||||
sb.WriteString("\nReply with an item name to buy, or \"back\" to return to categories.")
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// ── Display: Item Confirm ───────────────────────────────────────────────────
|
||||
|
||||
func luigiItemConfirm(userID id.UserID, slot EquipmentSlot, def *EquipmentDef, current *AdvEquipment, balance float64) string {
|
||||
var sb strings.Builder
|
||||
|
||||
currentTier := 0
|
||||
currentName := "None"
|
||||
if current != nil {
|
||||
@@ -103,42 +277,272 @@ func advShopCategory(slot EquipmentSlot, equip map[EquipmentSlot]*AdvEquipment,
|
||||
currentName = current.Name
|
||||
}
|
||||
|
||||
emoji := slotEmoji(slot)
|
||||
title := slotTitle(slot)
|
||||
|
||||
sb.WriteString(fmt.Sprintf("%s **%s Shop**\n", emoji, title))
|
||||
sb.WriteString(fmt.Sprintf("💰 Balance: €%.0f\n", balance))
|
||||
sb.WriteString(fmt.Sprintf("Equipped: **%s** (Tier %d)\n\n", currentName, currentTier))
|
||||
|
||||
defs := equipmentTiers[slot]
|
||||
hasUpgrades := false
|
||||
for _, def := range defs {
|
||||
if def.Tier <= currentTier || def.Price == 0 {
|
||||
continue
|
||||
}
|
||||
hasUpgrades = true
|
||||
|
||||
priceTag := fmt.Sprintf("€%.0f", def.Price)
|
||||
if balance < def.Price {
|
||||
priceTag += " 💸 can't afford"
|
||||
} else {
|
||||
priceTag += " ✅ affordable"
|
||||
}
|
||||
|
||||
sb.WriteString(fmt.Sprintf("**Tier %d: %s** — %s\n", def.Tier, def.Name, priceTag))
|
||||
sb.WriteString(fmt.Sprintf("%s\n\n", def.Description))
|
||||
// Upgrade indicator
|
||||
indicator := "⬆️"
|
||||
if def.Tier == currentTier {
|
||||
indicator = "➡️"
|
||||
} else if def.Tier < currentTier {
|
||||
indicator = "⬇️"
|
||||
}
|
||||
|
||||
if !hasUpgrades {
|
||||
sb.WriteString("✨ You've reached max tier! Nothing left to buy here.\n\n")
|
||||
sb.WriteString(fmt.Sprintf("%s **%s** — T%d %s — €%.0f\n\n", indicator, def.Name, def.Tier, slotTitle(slot), def.Price))
|
||||
|
||||
if def.Tier > currentTier {
|
||||
sb.WriteString(fmt.Sprintf("An upgrade from your %s (T%d).\n", currentName, currentTier))
|
||||
} else if def.Tier == currentTier {
|
||||
sb.WriteString(fmt.Sprintf("Same tier as your current %s.\n", currentName))
|
||||
} else {
|
||||
sb.WriteString(fmt.Sprintf("A downgrade from your %s (T%d).\n", currentName, currentTier))
|
||||
}
|
||||
|
||||
sb.WriteString("To buy: `!adventure buy <item name>` or `!adventure buy <tier> <category>`\n")
|
||||
sb.WriteString("Example: `!adventure buy Enchanted Blade` or `!adventure buy 4 sword`\n")
|
||||
sb.WriteString("Back to overview: `!adventure shop`")
|
||||
// Full Luigi description
|
||||
if desc, ok := luigiItemDescriptions[luigiItemKey{slot, def.Tier}]; ok {
|
||||
sb.WriteString(fmt.Sprintf("\n\"%s\"\n", desc))
|
||||
}
|
||||
|
||||
afterBalance := balance - def.Price
|
||||
sb.WriteString(fmt.Sprintf("\nYour balance: €%.0f → €%.0f after purchase\n\n", balance, afterBalance))
|
||||
sb.WriteString("Confirm? (yes / no)")
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// ── Resolver: Category Choice ───────────────────────────────────────────────
|
||||
|
||||
func (p *AdventurePlugin) resolveShopCategoryChoice(ctx MessageContext, interaction *advPendingInteraction) error {
|
||||
data := interaction.Data.(*advPendingShopCategory)
|
||||
reply := strings.ToLower(strings.TrimSpace(ctx.Body))
|
||||
|
||||
if reply == "back" || reply == "exit" || reply == "cancel" {
|
||||
p.shopSessionEnd(ctx.Sender)
|
||||
flavor, _ := advPickFlavor(luigiCancellation, ctx.Sender, "luigi_cancel")
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("*%s*", flavor))
|
||||
}
|
||||
|
||||
slot := advParseShopCategory(reply)
|
||||
if slot == "" {
|
||||
// Re-store pending and reprompt.
|
||||
p.pending.Store(string(ctx.Sender), interaction)
|
||||
return p.SendDM(ctx.Sender, "I didn't catch that. Reply with a category: weapons, armor, helmets, boots, or tools.")
|
||||
}
|
||||
|
||||
_, equip, err := p.ensureCharacter(ctx.Sender)
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender, "Failed to load your character.")
|
||||
}
|
||||
balance := p.euro.GetBalance(ctx.Sender)
|
||||
|
||||
text := luigiCategoryView(ctx.Sender, slot, equip, balance, data.ShowAll)
|
||||
p.pending.Store(string(ctx.Sender), &advPendingInteraction{
|
||||
Type: "shop_item",
|
||||
Data: &advPendingShopItem{Slot: slot, ShowAll: data.ShowAll},
|
||||
ExpiresAt: time.Now().Add(advDMResponseWindow),
|
||||
})
|
||||
p.advMarkMenuSent(ctx.Sender)
|
||||
p.shopScheduleBrowseNudge(ctx.Sender)
|
||||
|
||||
return p.SendDM(ctx.Sender, text)
|
||||
}
|
||||
|
||||
// ── Resolver: Item Choice ───────────────────────────────────────────────────
|
||||
|
||||
func (p *AdventurePlugin) resolveShopItemChoice(ctx MessageContext, interaction *advPendingInteraction) error {
|
||||
data := interaction.Data.(*advPendingShopItem)
|
||||
reply := strings.TrimSpace(ctx.Body)
|
||||
lower := strings.ToLower(reply)
|
||||
|
||||
if lower == "back" {
|
||||
// Return to category menu.
|
||||
_, equip, err := p.ensureCharacter(ctx.Sender)
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender, "Failed to load your character.")
|
||||
}
|
||||
balance := p.euro.GetBalance(ctx.Sender)
|
||||
|
||||
text := luigiShopGreeting(ctx.Sender, equip, balance, data.ShowAll)
|
||||
p.pending.Store(string(ctx.Sender), &advPendingInteraction{
|
||||
Type: "shop_category",
|
||||
Data: &advPendingShopCategory{ShowAll: data.ShowAll},
|
||||
ExpiresAt: time.Now().Add(advDMResponseWindow),
|
||||
})
|
||||
p.advMarkMenuSent(ctx.Sender)
|
||||
return p.SendDM(ctx.Sender, text)
|
||||
}
|
||||
|
||||
if lower == "exit" || lower == "cancel" {
|
||||
p.shopSessionEnd(ctx.Sender)
|
||||
flavor, _ := advPickFlavor(luigiCancellation, ctx.Sender, "luigi_cancel")
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("*%s*", flavor))
|
||||
}
|
||||
|
||||
// Try to find the item scoped to the current slot first, then globally.
|
||||
slot, def, found := advFindShopItemInSlot(reply, data.Slot)
|
||||
if !found {
|
||||
slot, def, found = advFindShopItem(reply)
|
||||
}
|
||||
if !found {
|
||||
p.pending.Store(string(ctx.Sender), interaction)
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("I don't have anything matching \"%s\". Reply with an item name from the list, or \"back\" to return.", reply))
|
||||
}
|
||||
|
||||
// Load fresh data.
|
||||
_, equip, err := p.ensureCharacter(ctx.Sender)
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender, "Failed to load your character.")
|
||||
}
|
||||
balance := p.euro.GetBalance(ctx.Sender)
|
||||
|
||||
current := equip[slot]
|
||||
|
||||
// Check Arena/Masterwork block — only block if shop item isn't clearly better.
|
||||
// Arena gear always blocks (1.5x multiplier, earned through combat).
|
||||
// Masterwork blocks only if the shop item's tier doesn't exceed the MW effective tier.
|
||||
if current != nil && current.ArenaTier > 0 {
|
||||
p.pending.Store(string(ctx.Sender), interaction)
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("You have **%s** (Arena gear) in that slot. A shop item can't replace that. You earned it.\n\nPick something else, or \"back\" to return.",
|
||||
current.Name))
|
||||
}
|
||||
if current != nil && current.Masterwork && float64(def.Tier) <= advEffectiveTier(current) {
|
||||
p.pending.Store(string(ctx.Sender), interaction)
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("You have **%s** (Masterwork ⭐) in that slot. That T%d shop item isn't an upgrade over your T%d Masterwork.\n\nPick something else, or \"back\" to return.",
|
||||
current.Name, def.Tier, current.Tier))
|
||||
}
|
||||
|
||||
text := luigiItemConfirm(ctx.Sender, slot, def, current, balance)
|
||||
p.pending.Store(string(ctx.Sender), &advPendingInteraction{
|
||||
Type: "shop_confirm",
|
||||
Data: &advPendingShopConfirm{Slot: slot, Tier: def.Tier},
|
||||
ExpiresAt: time.Now().Add(advDMResponseWindow),
|
||||
})
|
||||
p.advMarkMenuSent(ctx.Sender)
|
||||
p.shopScheduleBrowseNudge(ctx.Sender)
|
||||
|
||||
return p.SendDM(ctx.Sender, text)
|
||||
}
|
||||
|
||||
// ── Resolver: Purchase Confirm ──────────────────────────────────────────────
|
||||
|
||||
func (p *AdventurePlugin) resolveShopConfirm(ctx MessageContext, interaction *advPendingInteraction) error {
|
||||
userMu := p.advUserLock(ctx.Sender)
|
||||
userMu.Lock()
|
||||
defer userMu.Unlock()
|
||||
|
||||
data := interaction.Data.(*advPendingShopConfirm)
|
||||
reply := strings.ToLower(strings.TrimSpace(ctx.Body))
|
||||
|
||||
if reply != "yes" && reply != "y" && reply != "confirm" {
|
||||
p.shopSessionEnd(ctx.Sender)
|
||||
flavor, _ := advPickFlavor(luigiCancellation, ctx.Sender, "luigi_cancel")
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("*%s*", flavor))
|
||||
}
|
||||
|
||||
// Reload fresh equipment and balance (TOCTOU protection).
|
||||
_, equip, err := p.ensureCharacter(ctx.Sender)
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender, "Failed to load your character.")
|
||||
}
|
||||
balance := p.euro.GetBalance(ctx.Sender)
|
||||
|
||||
// Find the def fresh.
|
||||
defs := equipmentTiers[data.Slot]
|
||||
var def *EquipmentDef
|
||||
for i := range defs {
|
||||
if defs[i].Tier == data.Tier {
|
||||
def = &defs[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if def == nil {
|
||||
return p.SendDM(ctx.Sender, "Something went wrong. Item not found.")
|
||||
}
|
||||
|
||||
current := equip[data.Slot]
|
||||
|
||||
// Re-check Arena/Masterwork block.
|
||||
if current != nil && current.ArenaTier > 0 {
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("Your **%s** is Arena gear. Luigi won't overwrite it.", current.Name))
|
||||
}
|
||||
if current != nil && current.Masterwork && float64(def.Tier) <= advEffectiveTier(current) {
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("Your **%s** (Masterwork ⭐) is better than that T%d shop item.", current.Name, def.Tier))
|
||||
}
|
||||
|
||||
// Affordability.
|
||||
if balance < def.Price {
|
||||
flavor, _ := advPickFlavor(luigiInsufficientFunds, ctx.Sender, "luigi_broke")
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("*%s*\n\nYou have €%.0f. %s costs €%.0f.", flavor, balance, def.Name, def.Price))
|
||||
}
|
||||
|
||||
// Debit.
|
||||
if !p.euro.Debit(ctx.Sender, def.Price, "adventure_shop_"+string(data.Slot)) {
|
||||
return p.SendDM(ctx.Sender, "Transaction failed. The economy is having a moment.")
|
||||
}
|
||||
|
||||
// Move old gear to inventory before replacing.
|
||||
if current != nil && current.Tier > 0 {
|
||||
itemType := "ShopGear"
|
||||
var resaleValue int64
|
||||
if current.Masterwork {
|
||||
itemType = "MasterworkGear"
|
||||
// Masterwork has no resale value (it's special, not shop-priced)
|
||||
} else if current.Tier < len(equipmentTiers[data.Slot]) {
|
||||
resaleValue = int64(equipmentTiers[data.Slot][current.Tier].Price * 0.3)
|
||||
}
|
||||
err := addAdvInventoryItem(ctx.Sender, AdvItem{
|
||||
Name: current.Name,
|
||||
Type: itemType,
|
||||
Tier: current.Tier,
|
||||
Value: resaleValue,
|
||||
Slot: data.Slot,
|
||||
SkillSource: current.SkillSource,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("shop: failed to move old gear to inventory", "user", ctx.Sender, "slot", data.Slot, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Save new equipment.
|
||||
eq := &AdvEquipment{
|
||||
Slot: data.Slot,
|
||||
Tier: def.Tier,
|
||||
Condition: 100,
|
||||
Name: def.Name,
|
||||
ActionsUsed: 0,
|
||||
}
|
||||
if err := saveAdvEquipment(ctx.Sender, eq); err != nil {
|
||||
// Refund on DB error.
|
||||
p.euro.Credit(ctx.Sender, def.Price, "adventure_shop_refund")
|
||||
return p.SendDM(ctx.Sender, "Something went wrong saving your equipment. You've been refunded.")
|
||||
}
|
||||
|
||||
// Build confirmation message.
|
||||
p.shopSessionBump(ctx.Sender)
|
||||
|
||||
var confirmText string
|
||||
sess := p.shopSessionGet(ctx.Sender)
|
||||
isCombo := sess != nil && sess.ItemsBought >= 3
|
||||
|
||||
if isCombo {
|
||||
flavor, _ := advPickFlavor(luigiComboConfirm, ctx.Sender, "luigi_combo")
|
||||
confirmText = fmt.Sprintf(flavor, int(def.Price), def.Name)
|
||||
} else if def.Tier == 5 {
|
||||
flavor, _ := advPickFlavor(luigiTier5Confirm, ctx.Sender, "luigi_t5")
|
||||
confirmText = fmt.Sprintf(flavor, int(def.Price), def.Name)
|
||||
} else {
|
||||
flavor, _ := advPickFlavor(luigiPurchaseConfirm, ctx.Sender, "luigi_buy")
|
||||
confirmText = fmt.Sprintf(flavor, def.Name, int(def.Price))
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString(confirmText)
|
||||
sb.WriteString(fmt.Sprintf("\n\n✅ **%s** equipped.", def.Name))
|
||||
if current != nil && current.Tier > 0 {
|
||||
sb.WriteString(fmt.Sprintf(" %s moved to inventory.", current.Name))
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("\nBalance: €%.0f", balance-def.Price))
|
||||
|
||||
return p.SendDM(ctx.Sender, sb.String())
|
||||
}
|
||||
|
||||
// ── Find Shop Item ───────────────────────────────────────────────────────────
|
||||
|
||||
// normalizeQuotes replaces common Unicode quotes/apostrophes with ASCII equivalents.
|
||||
@@ -174,6 +578,22 @@ func advFindShopItem(name string) (EquipmentSlot, *EquipmentDef, bool) {
|
||||
return "", nil, false
|
||||
}
|
||||
|
||||
// advFindShopItemInSlot finds an item scoped to a specific slot.
|
||||
func advFindShopItemInSlot(name string, slot EquipmentSlot) (EquipmentSlot, *EquipmentDef, bool) {
|
||||
name = normalizeQuotes(strings.TrimSpace(name))
|
||||
|
||||
for i := range equipmentTiers[slot] {
|
||||
def := &equipmentTiers[slot][i]
|
||||
if def.Price == 0 {
|
||||
continue
|
||||
}
|
||||
if strings.EqualFold(def.Name, name) || containsFold(normalizeQuotes(def.Name), name) {
|
||||
return slot, def, true
|
||||
}
|
||||
}
|
||||
return "", nil, false
|
||||
}
|
||||
|
||||
// advFindByTierShorthand matches patterns like "3 sword", "tier 3 weapon", "t3 boots".
|
||||
func advFindByTierShorthand(input string) (EquipmentSlot, *EquipmentDef, bool) {
|
||||
lower := strings.ToLower(input)
|
||||
@@ -213,7 +633,20 @@ func advFindByTierShorthand(input string) (EquipmentSlot, *EquipmentDef, bool) {
|
||||
return "", nil, false
|
||||
}
|
||||
|
||||
// ── Buy Equipment ────────────────────────────────────────────────────────────
|
||||
var shopCategoryAliases = map[string]EquipmentSlot{
|
||||
"weapon": SlotWeapon, "weapons": SlotWeapon, "sword": SlotWeapon, "swords": SlotWeapon,
|
||||
"armor": SlotArmor, "armour": SlotArmor,
|
||||
"helmet": SlotHelmet, "helm": SlotHelmet, "helmets": SlotHelmet,
|
||||
"boots": SlotBoots, "boot": SlotBoots,
|
||||
"tool": SlotTool, "tools": SlotTool, "pickaxe": SlotTool,
|
||||
}
|
||||
|
||||
// advParseShopCategory maps user input to an EquipmentSlot.
|
||||
func advParseShopCategory(input string) EquipmentSlot {
|
||||
return shopCategoryAliases[strings.ToLower(strings.TrimSpace(input))]
|
||||
}
|
||||
|
||||
// ── Buy Equipment (legacy !adventure buy path) ──────────────────────────────
|
||||
|
||||
func (p *AdventurePlugin) advBuyEquipment(userID id.UserID, slot EquipmentSlot, def *EquipmentDef, equip map[EquipmentSlot]*AdvEquipment) string {
|
||||
current := equip[slot]
|
||||
@@ -221,35 +654,56 @@ func (p *AdventurePlugin) advBuyEquipment(userID id.UserID, slot EquipmentSlot,
|
||||
return fmt.Sprintf("You already have %s (Tier %d). %s is Tier %d. That's not an upgrade, that's a lateral move at best.",
|
||||
current.Name, current.Tier, def.Name, def.Tier)
|
||||
}
|
||||
// Block shop purchases that would overwrite arena or masterwork gear
|
||||
if current != nil && (current.ArenaTier > 0 || current.Masterwork) {
|
||||
label := "Masterwork"
|
||||
if current.ArenaTier > 0 {
|
||||
label = "Arena"
|
||||
}
|
||||
return fmt.Sprintf("You already have **%s** (%s gear). A shop item cannot replace this. "+
|
||||
// Block shop purchases that would overwrite arena gear (always) or
|
||||
// masterwork gear (only if shop item isn't clearly better).
|
||||
if current != nil && current.ArenaTier > 0 {
|
||||
return fmt.Sprintf("You already have **%s** (Arena gear). A shop item cannot replace this. "+
|
||||
"You earned that. Don't throw it away.",
|
||||
current.Name, label)
|
||||
current.Name)
|
||||
}
|
||||
if current != nil && current.Masterwork && float64(def.Tier) <= advEffectiveTier(current) {
|
||||
return fmt.Sprintf("You already have **%s** (Masterwork ⭐ T%d). That T%d shop item isn't an upgrade.",
|
||||
current.Name, current.Tier, def.Tier)
|
||||
}
|
||||
|
||||
balance := p.euro.GetBalance(userID)
|
||||
if balance < def.Price {
|
||||
return fmt.Sprintf("You cannot afford %s. You have €%.0f. %s costs €%.0f. "+
|
||||
"The gap between these numbers is both mathematical and deeply personal. "+
|
||||
"Go do something about it. Not gambling. You know what I mean.",
|
||||
def.Name, balance, def.Name, def.Price)
|
||||
flavor, _ := advPickFlavor(luigiInsufficientFunds, userID, "luigi_broke")
|
||||
return fmt.Sprintf("*%s*\n\nYou have €%.0f. %s costs €%.0f.", flavor, balance, def.Name, def.Price)
|
||||
}
|
||||
|
||||
if !p.euro.Debit(userID, def.Price, "adventure_shop_"+string(slot)) {
|
||||
return "Transaction failed. The economy is having a moment."
|
||||
}
|
||||
|
||||
// Move old gear to inventory.
|
||||
if current != nil && current.Tier > 0 {
|
||||
itemType := "ShopGear"
|
||||
var resaleValue int64
|
||||
if current.Masterwork {
|
||||
itemType = "MasterworkGear"
|
||||
} else if current.Tier < len(equipmentTiers[slot]) {
|
||||
resaleValue = int64(equipmentTiers[slot][current.Tier].Price * 0.3)
|
||||
}
|
||||
err := addAdvInventoryItem(userID, AdvItem{
|
||||
Name: current.Name,
|
||||
Type: itemType,
|
||||
Tier: current.Tier,
|
||||
Value: resaleValue,
|
||||
Slot: slot,
|
||||
SkillSource: current.SkillSource,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("shop: failed to move old gear to inventory", "user", userID, "slot", slot, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Update equipment
|
||||
eq := &AdvEquipment{
|
||||
Slot: slot,
|
||||
Tier: def.Tier,
|
||||
Condition: 100,
|
||||
Name: def.Name,
|
||||
Slot: slot,
|
||||
Tier: def.Tier,
|
||||
Condition: 100,
|
||||
Name: def.Name,
|
||||
ActionsUsed: 0,
|
||||
}
|
||||
if err := saveAdvEquipment(userID, eq); err != nil {
|
||||
@@ -259,10 +713,20 @@ func (p *AdventurePlugin) advBuyEquipment(userID id.UserID, slot EquipmentSlot,
|
||||
}
|
||||
equip[slot] = eq
|
||||
|
||||
return fmt.Sprintf("You have purchased **%s** for €%.0f.\n\n_%s_\n\n"+
|
||||
"This is an upgrade over what you had. That bar was underground, but you cleared it. "+
|
||||
"Equip it now before you do something stupid with the money instead.",
|
||||
def.Name, def.Price, def.Description)
|
||||
var sb strings.Builder
|
||||
if def.Tier == 5 {
|
||||
flavor, _ := advPickFlavor(luigiTier5Confirm, userID, "luigi_t5")
|
||||
sb.WriteString(fmt.Sprintf(flavor, int(def.Price), def.Name))
|
||||
} else {
|
||||
flavor, _ := advPickFlavor(luigiPurchaseConfirm, userID, "luigi_buy")
|
||||
sb.WriteString(fmt.Sprintf(flavor, def.Name, int(def.Price)))
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("\n\n✅ **%s** equipped.", def.Name))
|
||||
if current != nil && current.Tier > 0 {
|
||||
sb.WriteString(fmt.Sprintf(" %s moved to inventory.", current.Name))
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// ── Sell Items ────────────────────────────────────────────────────────────────
|
||||
@@ -280,7 +744,7 @@ func (p *AdventurePlugin) advSellAll(userID id.UserID) string {
|
||||
var sold int
|
||||
var keptSpecial int
|
||||
for _, item := range items {
|
||||
if item.Type == "MasterworkGear" || item.Type == "ArenaGear" {
|
||||
if item.Type == "MasterworkGear" || item.Type == "ArenaGear" || item.Type == "card" {
|
||||
keptSpecial++
|
||||
continue
|
||||
}
|
||||
@@ -318,7 +782,7 @@ func (p *AdventurePlugin) advSellItem(userID id.UserID, itemName string) string
|
||||
// Fuzzy match
|
||||
for _, item := range items {
|
||||
if containsFold(item.Name, itemName) {
|
||||
if item.Type == "MasterworkGear" || item.Type == "ArenaGear" {
|
||||
if item.Type == "MasterworkGear" || item.Type == "ArenaGear" || item.Type == "card" {
|
||||
return fmt.Sprintf("**%s** is special gear. The merchant won't touch it. Use `!adventure equip` to equip it.", item.Name)
|
||||
}
|
||||
if err := removeAdvInventoryItem(item.ID); err != nil {
|
||||
@@ -352,6 +816,8 @@ func advInventoryDisplay(userID id.UserID) string {
|
||||
sb.WriteString(fmt.Sprintf(" ⭐ %s (T%d Masterwork %s)\n", item.Name, item.Tier, slotTitle(item.Slot)))
|
||||
} else if item.Type == "ArenaGear" {
|
||||
sb.WriteString(fmt.Sprintf(" ⚔️ %s (T%d Arena %s)\n", item.Name, item.Tier, slotTitle(item.Slot)))
|
||||
} else if item.Type == "card" {
|
||||
sb.WriteString(fmt.Sprintf(" 🃏 %s\n", item.Name))
|
||||
} else {
|
||||
sb.WriteString(fmt.Sprintf(" %s (T%d %s) — €%d\n", item.Name, item.Tier, item.Type, item.Value))
|
||||
total += item.Value
|
||||
|
||||
353
internal/plugin/adventure_shop_test.go
Normal file
353
internal/plugin/adventure_shop_test.go
Normal file
@@ -0,0 +1,353 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ── Display Tests ───────────────────────────────────────────────────────────
|
||||
|
||||
func TestLuigiShopGreeting_Basic(t *testing.T) {
|
||||
equip := map[EquipmentSlot]*AdvEquipment{
|
||||
SlotWeapon: {Tier: 2, Condition: 100},
|
||||
}
|
||||
text := luigiShopGreeting("@user:test", equip, 5000, false)
|
||||
|
||||
if !strings.Contains(text, "Luigi's") {
|
||||
t.Error("greeting should contain Luigi's")
|
||||
}
|
||||
if !strings.Contains(text, "€5000") {
|
||||
t.Error("greeting should show balance")
|
||||
}
|
||||
if !strings.Contains(text, "Weapons") {
|
||||
t.Error("greeting should show category grid")
|
||||
}
|
||||
if !strings.Contains(text, "Reply with a category") {
|
||||
t.Error("greeting should prompt for category")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLuigiShopGreeting_MaxedOut(t *testing.T) {
|
||||
equip := map[EquipmentSlot]*AdvEquipment{
|
||||
SlotWeapon: {Tier: 5, Condition: 100},
|
||||
SlotArmor: {Tier: 5, Condition: 100},
|
||||
SlotHelmet: {Tier: 5, Condition: 100},
|
||||
SlotBoots: {Tier: 5, Condition: 100},
|
||||
SlotTool: {Tier: 5, Condition: 100},
|
||||
}
|
||||
text := luigiShopGreeting("@user:test", equip, 99999, false)
|
||||
|
||||
// Should NOT show category grid.
|
||||
if strings.Contains(text, "Reply with a category") {
|
||||
t.Error("maxed-out greeting should not show category prompt")
|
||||
}
|
||||
// Should use maxed-out flavor.
|
||||
if !strings.Contains(text, "fully kitted") {
|
||||
t.Error("maxed-out greeting should mention being fully kitted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLuigiShopGreeting_ShowAll(t *testing.T) {
|
||||
equip := map[EquipmentSlot]*AdvEquipment{}
|
||||
text := luigiShopGreeting("@user:test", equip, 1000, true)
|
||||
|
||||
// Should contain a show-all comment.
|
||||
if !strings.Contains(text, "judgment") && !strings.Contains(text, "thoroughness") {
|
||||
t.Error("show all greeting should contain a show-all comment")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLuigiCategoryView_UpgradeIndicators(t *testing.T) {
|
||||
equip := map[EquipmentSlot]*AdvEquipment{
|
||||
SlotWeapon: {Tier: 2, Condition: 100, Name: "Dull Steel Sword of Mediocrity"},
|
||||
}
|
||||
text := luigiCategoryView("@user:test", SlotWeapon, equip, 50000, false)
|
||||
|
||||
// Should show current equipped.
|
||||
if !strings.Contains(text, "🟢") {
|
||||
t.Error("should show 🟢 for currently equipped item")
|
||||
}
|
||||
// Should show upgrade indicators.
|
||||
if !strings.Contains(text, "⬆️") {
|
||||
t.Error("should show ⬆️ for items above current tier")
|
||||
}
|
||||
// Should NOT show T1 (below current tier) by default.
|
||||
if strings.Contains(text, "Sad Iron Sword") {
|
||||
t.Error("should not show items below current tier when showAll=false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLuigiCategoryView_ShowAll(t *testing.T) {
|
||||
equip := map[EquipmentSlot]*AdvEquipment{
|
||||
SlotWeapon: {Tier: 3, Condition: 100, Name: "Sword (It's Fine)"},
|
||||
}
|
||||
text := luigiCategoryView("@user:test", SlotWeapon, equip, 50000, true)
|
||||
|
||||
// Should show items below current tier.
|
||||
if !strings.Contains(text, "Sad Iron Sword") {
|
||||
t.Error("show all should include items below current tier")
|
||||
}
|
||||
// Below-tier items should show ⬇️.
|
||||
if !strings.Contains(text, "⬇️") {
|
||||
t.Error("show all should show ⬇️ for items below current tier")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLuigiCategoryView_Affordability(t *testing.T) {
|
||||
equip := map[EquipmentSlot]*AdvEquipment{
|
||||
SlotWeapon: {Tier: 0, Condition: 100, Name: "Basic Ass Sword"},
|
||||
}
|
||||
// Can afford T1 (€100) but not T2 (€450).
|
||||
text := luigiCategoryView("@user:test", SlotWeapon, equip, 200, false)
|
||||
|
||||
if !strings.Contains(text, "✅") {
|
||||
t.Error("should show ✅ for affordable items")
|
||||
}
|
||||
if !strings.Contains(text, "💸") {
|
||||
t.Error("should show 💸 for unaffordable items")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLuigiCategoryView_MasterworkAck(t *testing.T) {
|
||||
equip := map[EquipmentSlot]*AdvEquipment{
|
||||
SlotWeapon: {Tier: 3, Condition: 100, Name: "MW Blade", Masterwork: true},
|
||||
}
|
||||
text := luigiCategoryView("@user:test", SlotWeapon, equip, 50000, false)
|
||||
|
||||
if !strings.Contains(text, "⭐") {
|
||||
t.Error("should show ⭐ for masterwork gear")
|
||||
}
|
||||
// The masterwork acknowledgement pool has varied text — just check it's referenced.
|
||||
hasMWAck := strings.Contains(text, "Masterwork") || strings.Contains(text, "Arena gear") || strings.Contains(text, "can't beat that")
|
||||
if !hasMWAck {
|
||||
t.Error("should acknowledge masterwork/arena gear")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLuigiCategoryView_MaxedSlot(t *testing.T) {
|
||||
equip := map[EquipmentSlot]*AdvEquipment{
|
||||
SlotWeapon: {Tier: 5, Condition: 100, Name: "Vorpal Sword"},
|
||||
}
|
||||
text := luigiCategoryView("@user:test", SlotWeapon, equip, 50000, false)
|
||||
|
||||
if !strings.Contains(text, "max tier") {
|
||||
t.Error("should show max-tier message when nothing to buy")
|
||||
}
|
||||
}
|
||||
|
||||
// ── Item Confirm Tests ──────────────────────────────────────────────<E29480><E29480><EFBFBD>───────
|
||||
|
||||
func TestLuigiItemConfirm_Upgrade(t *testing.T) {
|
||||
def := &EquipmentDef{Name: "Silver Blade", Tier: 3, Price: 1500}
|
||||
current := &AdvEquipment{Tier: 2, Name: "Dull Steel Sword"}
|
||||
text := luigiItemConfirm("@user:test", SlotWeapon, def, current, 5000)
|
||||
|
||||
if !strings.Contains(text, "⬆️") {
|
||||
t.Error("should show upgrade indicator")
|
||||
}
|
||||
if !strings.Contains(text, "upgrade from") {
|
||||
t.Error("should mention it's an upgrade")
|
||||
}
|
||||
if !strings.Contains(text, "€5000") {
|
||||
t.Error("should show current balance")
|
||||
}
|
||||
if !strings.Contains(text, "€3500") {
|
||||
t.Error("should show balance after purchase")
|
||||
}
|
||||
if !strings.Contains(text, "Confirm?") {
|
||||
t.Error("should prompt for confirmation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLuigiItemConfirm_NoCurrent(t *testing.T) {
|
||||
def := &EquipmentDef{Name: "Sad Iron Sword", Tier: 1, Price: 100}
|
||||
text := luigiItemConfirm("@user:test", SlotWeapon, def, nil, 1000)
|
||||
|
||||
if !strings.Contains(text, "upgrade from") {
|
||||
t.Error("should say upgrade from None")
|
||||
}
|
||||
}
|
||||
|
||||
// ── Item Lookup Tests ──────────────────────────────────────────────<E29480><E29480>────────
|
||||
|
||||
func TestAdvFindShopItemInSlot_ExactMatch(t *testing.T) {
|
||||
slot, def, ok := advFindShopItemInSlot("Enchanted Plate", SlotArmor)
|
||||
if !ok {
|
||||
t.Fatal("should find Enchanted Plate in armor slot")
|
||||
}
|
||||
if slot != SlotArmor {
|
||||
t.Errorf("slot should be armor, got %s", slot)
|
||||
}
|
||||
if def.Tier != 4 {
|
||||
t.Errorf("Enchanted Plate should be tier 4, got %d", def.Tier)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdvFindShopItemInSlot_WrongSlot(t *testing.T) {
|
||||
_, _, ok := advFindShopItemInSlot("Dragonscale", SlotWeapon)
|
||||
if ok {
|
||||
t.Error("should not find armor item in weapon slot")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdvFindShopItemInSlot_PartialMatch(t *testing.T) {
|
||||
slot, def, ok := advFindShopItemInSlot("Enchanted Blade", SlotWeapon)
|
||||
if !ok {
|
||||
t.Fatal("should find Enchanted Blade by partial match")
|
||||
}
|
||||
if slot != SlotWeapon {
|
||||
t.Errorf("slot should be weapon, got %s", slot)
|
||||
}
|
||||
if def.Tier != 4 {
|
||||
t.Errorf("Enchanted Blade should be tier 4, got %d", def.Tier)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdvFindShopItemInSlot_CaseInsensitive(t *testing.T) {
|
||||
_, _, ok := advFindShopItemInSlot("enchanted blade", SlotWeapon)
|
||||
if !ok {
|
||||
t.Error("should find item case-insensitively")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdvFindShopItemInSlot_NoTier0(t *testing.T) {
|
||||
_, _, ok := advFindShopItemInSlot("Basic Ass Sword", SlotWeapon)
|
||||
if ok {
|
||||
t.Error("should not find tier 0 items (price 0)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdvFindShopItem_TierShorthand(t *testing.T) {
|
||||
slot, def, ok := advFindShopItem("3 sword")
|
||||
if !ok {
|
||||
t.Fatal("should find via tier shorthand")
|
||||
}
|
||||
if slot != SlotWeapon {
|
||||
t.Errorf("slot should be weapon, got %s", slot)
|
||||
}
|
||||
if def.Tier != 3 {
|
||||
t.Errorf("tier should be 3, got %d", def.Tier)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdvFindShopItem_TierPrefix(t *testing.T) {
|
||||
_, def, ok := advFindShopItem("tier 5 boots")
|
||||
if !ok {
|
||||
t.Fatal("should find via 'tier 5 boots'")
|
||||
}
|
||||
if def.Tier != 5 {
|
||||
t.Errorf("tier should be 5, got %d", def.Tier)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdvFindShopItem_TShorthand(t *testing.T) {
|
||||
_, def, ok := advFindShopItem("t4 armor")
|
||||
if !ok {
|
||||
t.Fatal("should find via 't4 armor'")
|
||||
}
|
||||
if def.Tier != 4 {
|
||||
t.Errorf("tier should be 4, got %d", def.Tier)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Flavor Coverage Tests ───────────────────────────────────────────────────
|
||||
|
||||
func TestLuigiItemDescriptions_Coverage(t *testing.T) {
|
||||
for _, slot := range allSlots {
|
||||
defs := equipmentTiers[slot]
|
||||
for _, def := range defs {
|
||||
if def.Price == 0 {
|
||||
continue // skip tier 0
|
||||
}
|
||||
key := luigiItemKey{slot, def.Tier}
|
||||
if _, ok := luigiItemDescriptions[key]; !ok {
|
||||
t.Errorf("missing Luigi item description for %s tier %d (%s)", slot, def.Tier, def.Name)
|
||||
}
|
||||
if _, ok := luigiItemOneLiners[key]; !ok {
|
||||
t.Errorf("missing Luigi one-liner for %s tier %d (%s)", slot, def.Tier, def.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLuigiCategoryIntros_Coverage(t *testing.T) {
|
||||
for _, slot := range allSlots {
|
||||
intros, ok := luigiCategoryIntros[slot]
|
||||
if !ok || len(intros) == 0 {
|
||||
t.Errorf("missing Luigi category intros for slot %s", slot)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLuigiFlavorPools_NonEmpty(t *testing.T) {
|
||||
pools := map[string][]string{
|
||||
"luigiGreetings": luigiGreetings,
|
||||
"luigiPurchaseConfirm": luigiPurchaseConfirm,
|
||||
"luigiTier5Confirm": luigiTier5Confirm,
|
||||
"luigiComboConfirm": luigiComboConfirm,
|
||||
"luigiInsufficientFunds": luigiInsufficientFunds,
|
||||
"luigiBrowseTimeout": luigiBrowseTimeout,
|
||||
"luigiMaxedOut": luigiMaxedOut,
|
||||
"luigiMasterworkAck": luigiMasterworkAck,
|
||||
"luigiShowAllComment": luigiShowAllComment,
|
||||
"luigiCommentary": luigiCommentary,
|
||||
"luigiCancellation": luigiCancellation,
|
||||
}
|
||||
for name, pool := range pools {
|
||||
if len(pool) == 0 {
|
||||
t.Errorf("flavor pool %s is empty", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Format String Safety ────────────────────────────────────────────────────
|
||||
|
||||
func TestLuigiPurchaseConfirm_FormatStrings(t *testing.T) {
|
||||
for i, tmpl := range luigiPurchaseConfirm {
|
||||
// Should have %s then %d
|
||||
result := strings.Contains(tmpl, "%s") && strings.Contains(tmpl, "%d")
|
||||
if !result {
|
||||
t.Errorf("luigiPurchaseConfirm[%d] missing %%s or %%d placeholder", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLuigiTier5Confirm_FormatStrings(t *testing.T) {
|
||||
for i, tmpl := range luigiTier5Confirm {
|
||||
result := strings.Contains(tmpl, "%s") && strings.Contains(tmpl, "%d")
|
||||
if !result {
|
||||
t.Errorf("luigiTier5Confirm[%d] missing %%s or %%d placeholder", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLuigiComboConfirm_FormatStrings(t *testing.T) {
|
||||
for i, tmpl := range luigiComboConfirm {
|
||||
result := strings.Contains(tmpl, "%s") && strings.Contains(tmpl, "%d")
|
||||
if !result {
|
||||
t.Errorf("luigiComboConfirm[%d] missing %%s or %%d placeholder", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tier Bounds Safety ──────────────────────────────────────────────────────
|
||||
|
||||
func TestEquipmentTiers_ConsistentIndexing(t *testing.T) {
|
||||
for _, slot := range allSlots {
|
||||
defs := equipmentTiers[slot]
|
||||
for i, def := range defs {
|
||||
if def.Tier != i {
|
||||
t.Errorf("slot %s: tier %d at index %d (should match for safe direct indexing)", slot, def.Tier, i)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEquipmentTiers_SixTiersPerSlot(t *testing.T) {
|
||||
for _, slot := range allSlots {
|
||||
defs := equipmentTiers[slot]
|
||||
if len(defs) != 6 {
|
||||
t.Errorf("slot %s: expected 6 tiers (0-5), got %d", slot, len(defs))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import "testing"
|
||||
func TestAdvEquipmentScore_Empty(t *testing.T) {
|
||||
score := advEquipmentScore(map[EquipmentSlot]*AdvEquipment{})
|
||||
if score != 0 {
|
||||
t.Errorf("empty equipment should score 0, got %d", score)
|
||||
t.Errorf("empty equipment should score 0, got %.2f", score)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,8 +16,8 @@ func TestAdvEquipmentScore_WeaponDoubled(t *testing.T) {
|
||||
}
|
||||
score := advEquipmentScore(equip)
|
||||
// Weapon: 3*2=6, Armor: 3
|
||||
if score != 9 {
|
||||
t.Errorf("got %d, want 9 (weapon 6 + armor 3)", score)
|
||||
if score != 9.0 {
|
||||
t.Errorf("got %.2f, want 9 (weapon 6 + armor 3)", score)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,8 +27,8 @@ func TestAdvEquipmentScore_LowConditionHalved(t *testing.T) {
|
||||
}
|
||||
score := advEquipmentScore(equip)
|
||||
// Tier 4, halved = 2
|
||||
if score != 2 {
|
||||
t.Errorf("got %d, want 2 (tier 4 halved for low condition)", score)
|
||||
if score != 2.0 {
|
||||
t.Errorf("got %.2f, want 2 (tier 4 halved for low condition)", score)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,8 +42,8 @@ func TestAdvEquipmentScore_FullLoadout(t *testing.T) {
|
||||
}
|
||||
score := advEquipmentScore(equip)
|
||||
// Weapon: 5*2=10, others: 5*4=20, total=30
|
||||
if score != 30 {
|
||||
t.Errorf("got %d, want 30", score)
|
||||
if score != 30.0 {
|
||||
t.Errorf("got %.2f, want 30", score)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -574,7 +574,7 @@ func (p *HangmanPlugin) startMultilingualGame(ctx MessageContext, lang, clueLang
|
||||
// Pick a word, optionally with a translatable clue
|
||||
var word, clueWord string
|
||||
for attempt := 0; attempt < 3; attempt++ {
|
||||
candidate, err := p.dict.RandomWord(lang, "", 4, 12)
|
||||
candidate, err := p.dict.RandomWord(lang, "", 4, 12, 0)
|
||||
if err != nil {
|
||||
slog.Error("hangman: random word failed", "lang", lang, "err", err)
|
||||
rollback()
|
||||
@@ -597,7 +597,7 @@ func (p *HangmanPlugin) startMultilingualGame(ctx MessageContext, lang, clueLang
|
||||
|
||||
if word == "" {
|
||||
// Fallback: start without a clue
|
||||
candidate, err := p.dict.RandomWord(lang, "", 4, 12)
|
||||
candidate, err := p.dict.RandomWord(lang, "", 4, 12, 0)
|
||||
if err != nil {
|
||||
rollback()
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "❌ Dictionary service unavailable. Try again shortly.")
|
||||
|
||||
@@ -398,7 +398,7 @@ func (p *StatsPlugin) handleSuperStats(ctx MessageContext) error {
|
||||
// Equipment score (use canonical scoring function)
|
||||
equip, eqErr := loadAdvEquipment(id.UserID(uid))
|
||||
if eqErr == nil && len(equip) > 0 {
|
||||
sb.WriteString(fmt.Sprintf(" Equipment score: %d\n", advEquipmentScore(equip)))
|
||||
sb.WriteString(fmt.Sprintf(" Equipment score: %.1f\n", advEquipmentScore(equip)))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
@@ -390,7 +390,7 @@ func (p *WordlePlugin) pickWord(length int, category WordleCategory) string {
|
||||
|
||||
// Try a few times to get a word that hasn't been used recently.
|
||||
for range 10 {
|
||||
word, err := p.dict.RandomWord(lang, "", length, length)
|
||||
word, err := p.dict.RandomWord(lang, "", length, length, 500)
|
||||
if err != nil {
|
||||
slog.Warn("wordle: DreamDict random word failed, falling back", "err", err)
|
||||
break
|
||||
|
||||
@@ -93,7 +93,7 @@ func (p *WOTDPlugin) pickWord(lang string) (string, string, string, string) {
|
||||
}
|
||||
|
||||
for attempt := 0; attempt < 100; attempt++ {
|
||||
candidate, err := p.dict.RandomWord(lang, "", 4, 14)
|
||||
candidate, err := p.dict.RandomWord(lang, "", 4, 14, 0)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user