mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
High-priority fixes from the multi-agent audit of Adventure 2.0: - Phase 11 nil-deref cluster: zoneOrFallback() helper replaces 8 unsafe getZone(_) sites in dnd_zone_cmd.go. Corrupted zone IDs render a placeholder instead of panicking. - Briefing/recap idempotency: deliverBriefing/deliverRecap now claim the rollover via a conditional UPDATE. Double-fires from clock skew or restart become no-ops; supply burn and day++ no longer reapply. - Graceful ticker shutdown: AdventurePlugin gains stopCh + Stop(); all 11 background tickers now select on stopCh in addition to ticker.C. - Mood decay (§3.2): math.Round(elapsed*2) replaces int() truncation so sub-hour gaps decay correctly. - 24h auto-abandon (§4.3): getActiveZoneRun returns clean slate and abandons stale runs whose LastActionAt is over 24h old. - Respec / auto-migrate orphan cleanup: !respec and the auto-migrated draft wipe now abandon active zone runs and expeditions before deleting the dnd_character row. - Phase R combat-link: applyBossDefeatThreat now wired from resolveBossRoom (-20 threat); applyRoomCombatThreatForUser adds +5/+8 from non-boss/elite kills (§8.1). - Starvation → forced extraction: briefing-time check forces extract with §10.2 coin tax when supplies hit zero. - GMNat20/Nat1 narration wired into resolveCombatRoom and resolveBossRoom (nat-20 takes precedence over nat-1). - Treasure-undo race: LoadAndDelete on both timer-fire and `undo` paths so only one side wins. - Battle Master: Disarming, Menacing, Parry maneuvers added (3 → 6 of 10). Remaining 4 (Pushing, Goading, Riposte, Commander's Strike) documented inline as needing ally/reaction mechanics the engine doesn't model. - Threat-70 warning: tracked in RegionState["siege_warning_fired"] so a drop-and-recross doesn't re-fire the beat. - region_state JSON decode error now logged via slog.Warn instead of silently discarded. Failing TestProdDB_DnDLayer fixed via option (a): track migrated characters and only run round-trip / idempotency assertions on those, skipping pre-existing prod-DB rows accumulated from live bot use. New tests in dnd_audit_phase_R7_test.go cover: 24h auto-abandon, briefing double-fire idempotency, threat-70 warning idempotency, multi-region extract→resume state preservation, and starvation forced-extraction. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
309 lines
8.7 KiB
Go
309 lines
8.7 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)
|
||
}
|
||
|
||
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)
|
||
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
|
||
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 {
|
||
// Never touch Arena gear, cards, or consumables. Consumables are a
|
||
// player-curated stockpile (crafted or dropped); selling them is an
|
||
// explicit decision the player must make themselves.
|
||
if item.Type == "ArenaGear" || item.Type == "card" || item.Type == "consumable" {
|
||
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 ─────────────────────────────────────────────────────────────
|
||
|
||
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)
|
||
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")
|
||
|
||
// 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)
|
||
}
|