Files
gogobee/internal/plugin/adventure_npcs.go
prosolis 76110f61ca Add housing/pets/Thom Krooke, wordle overhaul, boost system, backup strategy
Part 3 (Housing, Thom Krooke & Pets):
- Housing system with tiered upgrades, mortgage loans, FRED API rates
- Thom Krooke realtor NPC with !thom commands and !thom pay extra principal
- Pet system with arrival, naming, leveling, combat, morning defense
- Pet ditch recovery scales with level, name validation

Wordle overhaul:
- Remove daily expiration — puzzles persist until solved/failed
- Auto-start new puzzle after solve, fail, or skip
- Sequential puzzle IDs instead of date-based
- Auto-create puzzle on first guess if none active

Adventure fixes:
- Double XP/money boost toggle (!adv boost, admin only)
- Fix ensureCharacter wiping existing data on query errors
- Fix rival RPS format string bug
- Fix daily summary empty data (load today+yesterday logs)
- Fix holdem DM-to-room reply routing
- Fix Robbie payout to 25% of item value
- Add fishing to leaderboard and TwinBee calculations
- Add Thom to morning menu
- Persist mortgage rate across restarts via db.CacheGet/Set

Infrastructure:
- Daily database backup via VACUUM INTO with 7-day retention
- Admin DM notification on backup failure (async)
- Daily NPC house balance reset for holdem

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 16:50:49 -07:00

391 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package plugin
import (
"fmt"
"log/slog"
"math/rand/v2"
"strings"
"time"
"gogobee/internal/db"
"maunium.net/go/mautrix/id"
)
// ── NPC Constants ──────────────────────────────────────────────────────────
const (
mistyCost = 100
arinaCost = 5000
npcCooldownDays = 7
npcEncounterChance = 0.075 // 7.5%
npcBuffDuration = 7 * 24 * time.Hour
// Arena effect chances per round
mistyEffectChance = 0.20 // 20%
arinaEffectChance = 0.08 // 8%
// Misty crowd revenge damage range
crowdRevengeDmgMin = 3
crowdRevengeDmgMax = 8
// Misty gourmet food — enemy damage
gourmetEnemyDmg = 5
// Misty gourmet food — equipment condition repair
gourmetConditionRepair = 5
)
// ── Message Count Tracking ─────────────────────────────────────────────────
// npcTrackMessage is called on every room message from an adventure player.
// It increments their daily message count and fires NPC encounter rolls
// when thresholds are hit.
func (p *AdventurePlugin) npcTrackMessage(userID id.UserID) {
// Acquire user lock to prevent lost updates on message count and
// double encounter triggers from rapid messages.
userMu := p.advUserLock(userID)
userMu.Lock()
char, err := loadAdvCharacter(userID)
if err != nil || !char.Alive {
userMu.Unlock()
return
}
today := time.Now().UTC().Format("2006-01-02")
// Reset count if it's a new day
if char.NPCMsgCountDate != today {
char.NPCMsgCount = 0
char.NPCMsgCountDate = today
char.MistyRollTarget = 5 + rand.IntN(6) // 510
char.ArinaRollTarget = 5 + rand.IntN(6) // 510
}
char.NPCMsgCount++
var fireNPC string
// Check Misty threshold
if char.MistyRollTarget > 0 && char.NPCMsgCount == char.MistyRollTarget {
if p.npcShouldEncounter(char, "misty") {
char.MistyRollTarget = 0
fireNPC = "misty"
}
}
// Check Arina threshold (only if Misty didn't fire — one encounter per message)
if fireNPC == "" && char.ArinaRollTarget > 0 && char.NPCMsgCount == char.ArinaRollTarget {
if p.npcShouldEncounter(char, "arina") {
char.ArinaRollTarget = 0
fireNPC = "arina"
}
}
if err := saveAdvCharacter(char); err != nil {
slog.Error("npc: failed to save msg count", "user", userID, "err", err)
}
// Release lock BEFORE firing encounter (npcFireEncounter re-acquires it)
userMu.Unlock()
if fireNPC != "" {
safeGo("npc-"+fireNPC+"-encounter", func() {
p.npcFireEncounter(userID, fireNPC)
})
}
}
// npcShouldEncounter checks cooldown and rolls the 7.5% chance.
func (p *AdventurePlugin) npcShouldEncounter(char *AdventureCharacter, npc string) bool {
now := time.Now().UTC()
cooldown := npcCooldownDays * 24 * time.Hour
switch npc {
case "misty":
if char.MistyLastSeen != nil && now.Sub(*char.MistyLastSeen) < cooldown {
return false
}
case "arina":
if char.ArinaLastSeen != nil && now.Sub(*char.ArinaLastSeen) < cooldown {
return false
}
}
return rand.Float64() < npcEncounterChance
}
// ── Encounter Fire ─────────────────────────────────────────────────────────
func (p *AdventurePlugin) npcFireEncounter(userID id.UserID, npc string) {
userMu := p.advUserLock(userID)
userMu.Lock()
defer userMu.Unlock()
char, err := loadAdvCharacter(userID)
if err != nil || !char.Alive {
return
}
now := time.Now().UTC()
var opening, prompt string
switch npc {
case "misty":
char.MistyLastSeen = &now
char.MistyEncounterCount++
opening = mistyOpenings[rand.IntN(len(mistyOpenings))]
prompt = fmt.Sprintf("👤 A woman approaches you.\n\n_%s_\n\n"+
"Reply `yes` to give €%d, or `no` to walk away.", opening, mistyCost)
case "arina":
char.ArinaLastSeen = &now
opening = arinaOpenings[rand.IntN(len(arinaOpenings))]
prompt = fmt.Sprintf("💎 A woman in expensive clothing stops you.\n\n_%s_\n\n"+
"Reply `yes` to pay €%d, or `no` to decline.", opening, arinaCost)
}
if err := saveAdvCharacter(char); err != nil {
slog.Error("npc: failed to save last_seen", "user", userID, "npc", npc, "err", err)
return
}
// Don't overwrite an existing pending interaction (shop, treasure, etc.)
if _, occupied := p.pending.Load(string(userID)); occupied {
return
}
// Set pending interaction
p.pending.Store(string(userID), &advPendingInteraction{
Type: "npc_encounter",
Data: npc,
ExpiresAt: time.Now().Add(5 * time.Minute),
})
if err := p.SendDM(userID, prompt); err != nil {
slog.Error("npc: failed to send encounter DM", "user", userID, "npc", npc, "err", err)
p.pending.Delete(string(userID))
}
}
// ── Accept / Decline Resolution ────────────────────────────────────────────
func (p *AdventurePlugin) resolveNPCEncounter(ctx MessageContext, interaction *advPendingInteraction) error {
npc := interaction.Data.(string)
body := strings.ToLower(strings.TrimSpace(ctx.Body))
accepted := body == "yes" || body == "y" || body == "pay"
declined := body == "no" || body == "n" || body == "decline"
if !accepted && !declined {
// Re-store the pending interaction — they typed something else
p.pending.Store(string(ctx.Sender), interaction)
return p.SendDM(ctx.Sender, "Reply `yes` or `no`.")
}
userMu := p.advUserLock(ctx.Sender)
userMu.Lock()
defer userMu.Unlock()
char, err := loadAdvCharacter(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "Something went wrong loading your character.")
}
now := time.Now().UTC()
expires := now.Add(npcBuffDuration)
switch npc {
case "misty":
return p.resolveMisty(ctx, char, accepted, &expires)
case "arina":
return p.resolveArina(ctx, char, accepted, &expires)
}
return nil
}
func (p *AdventurePlugin) resolveMisty(ctx MessageContext, char *AdventureCharacter, accepted bool, expires *time.Time) error {
if accepted {
balance := p.euro.GetBalance(ctx.Sender)
if balance < float64(mistyCost) {
// Can't afford it — treat as decline but with a kinder message
char.MistyDebuffExpires = expires
if err := saveAdvCharacter(char); err != nil {
slog.Error("npc: failed to save misty debuff", "user", ctx.Sender, "err", err)
}
return p.SendDM(ctx.Sender, "You reach for your gold but there isn't enough. She sees this. She doesn't say anything.\n\n_"+mistyDeclineLine+"_")
}
if !p.euro.Debit(ctx.Sender, float64(mistyCost), "misty_donation") {
char.MistyDebuffExpires = expires
if err := saveAdvCharacter(char); err != nil {
slog.Error("npc: failed to save misty debuff", "user", ctx.Sender, "err", err)
}
return p.SendDM(ctx.Sender, "You reach for your gold but there isn't enough. She sees this. She doesn't say anything.\n\n_"+mistyDeclineLine+"_")
}
char.MistyBuffExpires = expires
char.MistyDonatedCount++
// Pet reactivation: donating to Misty after chasing pet away
mistyReactivatePet(char)
if err := saveAdvCharacter(char); err != nil {
slog.Error("npc: failed to save misty buff", "user", ctx.Sender, "err", err)
}
reply := mistyAcceptLines[rand.IntN(len(mistyAcceptLines))]
// Housing hint (fires once after 2+ encounters)
hint := mistyHousingHint(char)
if hint != "" {
reply += "\n\n_" + hint + "_"
}
return p.SendDM(ctx.Sender, fmt.Sprintf("_%s_", reply))
}
// Declined
char.MistyDebuffExpires = expires
if err := saveAdvCharacter(char); err != nil {
slog.Error("npc: failed to save misty debuff", "user", ctx.Sender, "err", err)
}
return p.SendDM(ctx.Sender, fmt.Sprintf("_%s_", mistyDeclineLine))
}
func (p *AdventurePlugin) resolveArina(ctx MessageContext, char *AdventureCharacter, accepted bool, expires *time.Time) error {
if accepted {
balance := p.euro.GetBalance(ctx.Sender)
if balance < float64(arinaCost) {
reply := arinaDeclineLines[rand.IntN(len(arinaDeclineLines))]
return p.SendDM(ctx.Sender, fmt.Sprintf("You can't afford it. She noticed before you did.\n\n_%s_", reply))
}
if !p.euro.Debit(ctx.Sender, float64(arinaCost), "arina_investment") {
reply := arinaDeclineLines[rand.IntN(len(arinaDeclineLines))]
return p.SendDM(ctx.Sender, fmt.Sprintf("You can't afford it. She noticed before you did.\n\n_%s_", reply))
}
char.ArinaBuffExpires = expires
if err := saveAdvCharacter(char); err != nil {
slog.Error("npc: failed to save arina buff", "user", ctx.Sender, "err", err)
}
return p.SendDM(ctx.Sender, fmt.Sprintf("_%s_", arinaAcceptLine))
}
// Declined — no mechanical effect, just insults
reply := arinaDeclineLines[rand.IntN(len(arinaDeclineLines))]
return p.SendDM(ctx.Sender, fmt.Sprintf("_%s_", reply))
}
// ── Arena Effect Checks ────────────────────────────────────────────────────
// npcArenaEffects is called during arena round resolution, after the normal
// combat roll but before the round log is written.
// Returns: extra text to append to round log, enemy HP modifier, player damage taken.
type npcArenaResult struct {
Text string
EnemyDmg int // damage dealt to enemy
PlayerDmg int // damage dealt to player
SniperKill bool // enemy instant kill
CondRepair int // equipment condition repair amount
}
func npcCheckArenaEffects(char *AdventureCharacter, monsterName string) *npcArenaResult {
now := time.Now().UTC()
result := &npcArenaResult{}
fired := false
// Step 2: Misty debuff — crowd revenge (20%)
if char.MistyDebuffExpires != nil && now.Before(*char.MistyDebuffExpires) {
if rand.Float64() < mistyEffectChance {
dmg := crowdRevengeDmgMin + rand.IntN(crowdRevengeDmgMax-crowdRevengeDmgMin+1)
line := mistyCrowdRevengeLines[rand.IntN(len(mistyCrowdRevengeLines))]
line = strings.ReplaceAll(line, "{damage}", fmt.Sprintf("%d", dmg))
result.Text += "\n\n" + line
result.PlayerDmg = dmg
fired = true
}
}
// Step 3: Misty buff — gourmet food (20%)
if char.MistyBuffExpires != nil && now.Before(*char.MistyBuffExpires) {
if rand.Float64() < mistyEffectChance {
line := mistyGourmetFoodLines[rand.IntN(len(mistyGourmetFoodLines))]
line = strings.ReplaceAll(line, "{enemy}", monsterName)
result.Text += "\n\n" + line
result.EnemyDmg = gourmetEnemyDmg
result.CondRepair = gourmetConditionRepair
fired = true
}
}
// Step 4: Arina buff — sniper (8%)
if char.ArinaBuffExpires != nil && now.Before(*char.ArinaBuffExpires) {
if rand.Float64() < arinaEffectChance {
line := arinaSniperLines[rand.IntN(len(arinaSniperLines))]
line = strings.ReplaceAll(line, "{enemy}", monsterName)
result.Text += "\n\n" + line
result.SniperKill = true
fired = true
}
}
if !fired {
return nil
}
return result
}
// ── Equipment Repair (Gourmet Food) ────────────────────────────────────────
// npcRepairMostDamaged heals condition on the player's most damaged equipment slot.
func npcRepairMostDamaged(userID id.UserID, equip map[EquipmentSlot]*AdvEquipment, amount int) {
if len(equip) == 0 {
return
}
// Find most damaged slot
var worstSlot EquipmentSlot
worstCondition := 101
for slot, eq := range equip {
if eq.Condition < worstCondition {
worstCondition = eq.Condition
worstSlot = slot
}
}
if worstCondition >= 100 {
return // nothing to repair
}
newCond := worstCondition + amount
if newCond > 100 {
newCond = 100
}
d := db.Get()
_, err := d.Exec(`UPDATE adventure_equipment SET condition = ? WHERE user_id = ? AND slot = ?`,
newCond, string(userID), string(worstSlot))
if err != nil {
slog.Error("npc: failed to repair equipment", "user", userID, "slot", worstSlot, "err", err)
}
}
// ── Midnight Reset ─────────────────────────────────────────────────────────
// npcMidnightReset resets message counts and regenerates roll targets.
// Called from midnightReset. Uses a direct DB update to avoid loading/saving
// every character individually.
func npcMidnightReset() {
d := db.Get()
today := time.Now().UTC().Format("2006-01-02")
_, err := d.Exec(`
UPDATE adventure_characters
SET npc_msg_count = 0,
npc_msg_count_date = ?,
misty_roll_target = ABS(RANDOM()) % 6 + 5,
arina_roll_target = ABS(RANDOM()) % 6 + 5`,
today)
if err != nil {
slog.Error("npc: midnight reset failed", "err", err)
}
}