mirror of
https://github.com/prosolis/gogobee.git
synced 2026-09-14 19:01:09 +00:00
Review fallout from the locked-doors commit. Five defects, all in the seams that commit opened: backtrackFromDeadFork stepped to VisitedNodes[idx-1]. That slice is a first-entry ordered *set*, not a path stack (see appendVisited), so once a run has doubled back once the entry before CurrentNode can sit on a different branch entirely — the party teleports across the map to a room no edge connects. `!revisit` refuses exactly that move via adjacentNodes; the autopilot has no business doing what the player is forbidden from doing. Now routed through backtrackTarget, which also refuses to fall back into a corridor whose only exit is the sealed fork: the lock rolls are seeded per (run, edge), so walking back in gets the same answer every time, forever. The autopilot spent the player's thieves' tools *before* committing the move, so an advanceZoneRunNode failure left them charged and then had the caller's backtrack clear the fork they had just paid to open. The tools are now reported by autoPickWithTools and only removed once the party is actually through the door. `sell all` never learned the new "tool" type and turned a €600 set into €300 of loot — the same silent deletion Robbie was taught to avoid two commits ago. It was already doing this to "key" quest tokens, so both now sit with the special gear. The shop's tools branch asked "is the reply a substring of Thieves' Tools", which is true for a bare "s", for "to", and for an empty message body — and it runs ahead of the consumable list, so a stray keystroke in the Supplies view bought a set. isThievesToolsReply matches on the item's own words instead. Plus two cleanups in the same code: Robbie built his haul gifts by calling consumableCache(tier, 1) in a loop when it already takes a count, and the unlock flow read the whole inventory three times per command.
414 lines
13 KiB
Go
414 lines
13 KiB
Go
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 {
|
||
select {
|
||
case <-p.stopCh:
|
||
return
|
||
case <-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 {
|
||
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)
|
||
}
|
||
|
||
name, _ := loadDisplayName(char.UserID)
|
||
p.robbieVisitPlayer(char.UserID, name)
|
||
}
|
||
}
|
||
|
||
// ── 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)
|
||
payout := item.Value / 4
|
||
if payout < 1 {
|
||
payout = 1
|
||
}
|
||
totalPayout += payout
|
||
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 and work out what he leaves behind.
|
||
var leftGifts []AdvItem
|
||
char, err := loadAdvCharacter(userID)
|
||
if err == nil {
|
||
char.RobbieVisitCount++
|
||
// Use the canonical DnD level (like the arena's tier gate), not the
|
||
// frozen legacy CombatLevel — that snapshots at 1–3 once D&D setup
|
||
// completes, so reading it here would peg every gift at tier 1.
|
||
tier := robbieGiftTier(arenaDnDLevelOrZero(userID))
|
||
for _, gift := range consumableCache(tier, robbieGiftCount(char.RobbieVisitCount, len(takenItems))) {
|
||
if err := addAdvInventoryItem(userID, gift); err == nil {
|
||
leftGifts = append(leftGifts, gift)
|
||
}
|
||
}
|
||
_ = saveAdvCharacter(char)
|
||
_ = upsertPlayerMetaNPCState(userID, npcStateFromAdvChar(char))
|
||
}
|
||
|
||
// Send DM
|
||
dm := renderRobbieDM(userID, takenItems, totalPayout, masterworkTaken, gaveCard, leftGifts)
|
||
if err := p.SendDM(userID, dm); err != nil {
|
||
slog.Error("adventure: robbie: failed to send DM", "user", userID, "err", err)
|
||
}
|
||
|
||
// Room announcement — but not for a player who isn't there.
|
||
//
|
||
// A bored adventurer (gogobee_boredom_plan.md) auto-harvests ore and junk
|
||
// into inventory on every run, and Robbie takes all of it, every day. Left
|
||
// alone, each abandoned character would file a public bulletin every single
|
||
// day, indefinitely, about somebody who stopped playing weeks ago. He still
|
||
// visits and still pays — that income is what keeps the neglected adventurer
|
||
// walking — he just doesn't announce a house with nobody in it.
|
||
if playerIsIdle(userID, time.Now().UTC()) {
|
||
return
|
||
}
|
||
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 {
|
||
// Never touch Arena gear, cards, consumables, keys, or tools.
|
||
// Consumables are a player-curated stockpile (crafted or dropped);
|
||
// selling them is an explicit decision the player must make themselves.
|
||
// Keys are cross-zone unlock tokens (N5/D4) that must persist in
|
||
// inventory to open their vault later — sweeping one permanently breaks
|
||
// that unlock. Tools are the same shape of promise: thieves' tools are
|
||
// bought precisely so a locked fork can be opened *later*, and a bandit
|
||
// who pockets them between the purchase and the door has taken the
|
||
// thing the player paid to still have.
|
||
switch item.Type {
|
||
case "ArenaGear", "card", "consumable", "key", thievesToolsItemType:
|
||
continue
|
||
}
|
||
|
||
// Non-gear items (ores, fish, junk, treasure, etc.) — always take
|
||
if item.Slot == "" {
|
||
result = append(result, item)
|
||
continue
|
||
}
|
||
|
||
// Slotted gear — check against equipped piece
|
||
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 ─────────────────────────────────────────────────────────────
|
||
|
||
// robbieGiftEveryNVisits is how often Robbie leaves a consumable behind on the
|
||
// loyalty track alone, independent of how much he hauled off.
|
||
const robbieGiftEveryNVisits = 10
|
||
|
||
// robbieHaulPerGift is how many items one visit has to be worth before Robbie
|
||
// leaves something for the trouble, and robbieMaxHaulGifts caps how generous a
|
||
// single monster haul can get.
|
||
//
|
||
// The loyalty track on its own was far too thin to read as a reward: a visit is
|
||
// a 40% daily roll, so every-10-visits works out to one consumable per ~25 real
|
||
// days — and it paid exactly the same for a stockpile of sixty items as it did
|
||
// for one rock. Volume is the thing the player actually controls, so volume is
|
||
// what the haul track pays on.
|
||
const (
|
||
robbieHaulPerGift = 15
|
||
robbieMaxHaulGifts = 3
|
||
)
|
||
|
||
// robbieGiftCount returns how many consumables Robbie leaves this visit: the
|
||
// every-Nth-visit loyalty gift plus one per robbieHaulPerGift items carried
|
||
// off, capped. Pure so the curve is testable without a DB or a Matrix stub.
|
||
func robbieGiftCount(visitCount, itemsTaken int) int {
|
||
n := 0
|
||
if visitCount > 0 && visitCount%robbieGiftEveryNVisits == 0 {
|
||
n++
|
||
}
|
||
if haul := itemsTaken / robbieHaulPerGift; haul > 0 {
|
||
n += min(haul, robbieMaxHaulGifts)
|
||
}
|
||
return n
|
||
}
|
||
|
||
// joinAnd renders a list as "a", "a and b", or "a, b and c".
|
||
func joinAnd(xs []string) string {
|
||
switch len(xs) {
|
||
case 0:
|
||
return ""
|
||
case 1:
|
||
return xs[0]
|
||
}
|
||
return strings.Join(xs[:len(xs)-1], ", ") + " and " + xs[len(xs)-1]
|
||
}
|
||
|
||
// robbieGiftTier maps a player's combat level to a consumable tier, matching
|
||
// the arena tier bands (1–3 / 4–7 / 8–12 / 13–17 / 18+).
|
||
func robbieGiftTier(level int) int {
|
||
switch {
|
||
case level >= 18:
|
||
return 5
|
||
case level >= 13:
|
||
return 4
|
||
case level >= 8:
|
||
return 3
|
||
case level >= 4:
|
||
return 2
|
||
default:
|
||
return 1
|
||
}
|
||
}
|
||
|
||
func renderRobbieDM(userID id.UserID, items []AdvItem, total int64, mwTaken, gaveCard bool, leftGifts []AdvItem) 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)
|
||
payout := item.Value / 4
|
||
if payout < 1 {
|
||
payout = 1
|
||
}
|
||
if item.Type == "MasterworkGear" {
|
||
sb.WriteString(fmt.Sprintf(" %s %s (Masterwork T%d) → €%d", emoji, item.Name, item.Tier, payout))
|
||
if gaveCard && !cardShownOnLine {
|
||
sb.WriteString(" + 🃏 Get Out of Medical Debt Free card")
|
||
cardShownOnLine = true
|
||
}
|
||
} else {
|
||
sb.WriteString(fmt.Sprintf(" %s %s (T%d) → €%d", emoji, item.Name, item.Tier, payout))
|
||
}
|
||
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")
|
||
|
||
// What he left behind: the every-10th-visit loyalty consumable (D2), the
|
||
// big-haul thank-you, or both rolled into one line. A single gift keeps the
|
||
// original loyalty phrasing; anything more is the haul talking.
|
||
if len(leftGifts) > 0 {
|
||
names := make([]string, 0, len(leftGifts))
|
||
for _, g := range leftGifts {
|
||
names = append(names, g.Name)
|
||
}
|
||
if len(names) == 1 {
|
||
sb.WriteString(fmt.Sprintf(robbieLeftConsumable, names[0]))
|
||
} else {
|
||
sb.WriteString(fmt.Sprintf(robbieLeftForTheHaul, joinAnd(names)))
|
||
}
|
||
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)
|
||
}
|