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>
This commit is contained in:
prosolis
2026-04-10 16:50:49 -07:00
parent 90865d1c51
commit 76110f61ca
24 changed files with 3298 additions and 166 deletions

View File

@@ -1,6 +1,8 @@
package plugin
import (
"database/sql"
"errors"
"fmt"
"log/slog"
"math/rand/v2"
@@ -9,6 +11,8 @@ import (
"sync"
"time"
"gogobee/internal/db"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/id"
)
@@ -99,6 +103,7 @@ func (p *AdventurePlugin) Commands() []CommandDef {
return []CommandDef{
{Name: "adventure", Description: "Daily adventure game — dungeon, mine, forage, or rest", Usage: "!adventure", Category: "Games"},
{Name: "arena", Description: "Arena combat — fight through 5 tiers of increasingly deadly monsters", Usage: "!arena", Category: "Games"},
{Name: "thom", Description: "Visit Thom Krooke — housing and loans", Usage: "!thom", Category: "Games"},
}
}
@@ -133,6 +138,7 @@ func (p *AdventurePlugin) Init() error {
go p.rivalChallengeTicker()
go p.robbieTicker()
go p.hospitalNudgeTicker()
go p.mortgageTicker()
// Auto-cashout any arena runs left in 'awaiting' from a prior restart
p.arenaCleanupStaleRuns()
@@ -193,7 +199,12 @@ func (p *AdventurePlugin) OnMessage(ctx MessageContext) error {
})
}
// 4. Command dispatch
// 4. Thom Krooke commands (work in rooms and DMs)
if p.IsCommand(ctx.Body, "thom") {
return p.handleThomCmd(ctx)
}
// 5. Command dispatch
if !p.IsCommand(ctx.Body, "adventure") && !p.IsCommand(ctx.Body, "adv") {
return nil
}
@@ -243,6 +254,8 @@ func (p *AdventurePlugin) dispatchCommand(ctx MessageContext) error {
return p.handleRepairAllCmd(ctx)
case strings.HasPrefix(lower, "repair "):
return p.handleRepairSlotCmd(ctx, strings.TrimSpace(args[7:]))
case lower == "boost":
return p.handleBoostCmd(ctx)
}
return p.SendDM(ctx.Sender, "Unknown command. Type `!adventure help` to see available commands.")
@@ -266,6 +279,7 @@ const advHelpText = `**Adventure Commands**
` + "`!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)
` + "`!thom`" + ` — Visit Thom Krooke (housing and loans)
` + "`!adventure help`" + ` — This message
**Arena:**
@@ -511,10 +525,15 @@ func (p *AdventurePlugin) handleDMReply(ctx MessageContext) error {
// Skip if it looks like a command for another plugin
lower := strings.ToLower(body)
if strings.HasPrefix(body, "!") && !strings.HasPrefix(lower, "!adventure") && !strings.HasPrefix(lower, "!adv") {
if strings.HasPrefix(body, "!") && !strings.HasPrefix(lower, "!adventure") && !strings.HasPrefix(lower, "!adv") && !strings.HasPrefix(lower, "!thom") {
return nil
}
// Handle !thom in DMs
if strings.HasPrefix(lower, "!thom") {
return p.handleThomCmd(ctx)
}
// Strip !adventure / !adv prefix if present — dispatch directly to avoid recursion
if strings.HasPrefix(lower, "!adventure") || strings.HasPrefix(lower, "!adv") {
return p.dispatchCommand(ctx)
@@ -568,6 +587,12 @@ func (p *AdventurePlugin) resolvePendingInteraction(ctx MessageContext, interact
return p.resolveHospitalPay(ctx, interaction)
case "npc_encounter":
return p.resolveNPCEncounter(ctx, interaction)
case "pet_arrival":
return p.resolvePetArrival(ctx)
case "pet_type":
return p.resolvePetType(ctx)
case "pet_name":
return p.resolvePetName(ctx)
}
return nil
}
@@ -790,6 +815,9 @@ func (p *AdventurePlugin) resolveActivity(ctx MessageContext, char *AdventureCha
result.XPGained = int(float64(result.XPGained) * (1.0 + bonus))
}
// Double XP/money boost
advApplyBoost(result)
// Apply XP
switch result.XPSkill {
case "combat":
@@ -874,6 +902,16 @@ func (p *AdventurePlugin) resolveActivity(ctx MessageContext, char *AdventureCha
return p.SendDM(ctx.Sender, "Something went wrong saving your progress. Your action was not recorded. Try again.")
}
// Pet XP
if char.HasPet() && result.Outcome != AdvOutcomeDeath {
if petGrantXP(char) {
_ = saveAdvCharacter(char)
_ = p.SendDM(char.UserID, fmt.Sprintf("🐾 %s leveled up to **Level %d**!", char.PetName, char.PetLevel))
} else {
_ = saveAdvCharacter(char)
}
}
// Save equipment changes
for _, slot := range allSlots {
if eq, ok := equip[slot]; ok {
@@ -1203,7 +1241,12 @@ func (p *AdventurePlugin) selectFlavorText(char *AdventureCharacter, result *Adv
func (p *AdventurePlugin) ensureCharacter(userID id.UserID) (*AdventureCharacter, map[EquipmentSlot]*AdvEquipment, error) {
char, err := loadAdvCharacter(userID)
if err != nil {
// Auto-create
if !errors.Is(err, sql.ErrNoRows) {
// Query error (e.g. missing column) — do NOT auto-create over existing data
slog.Error("adventure: loadAdvCharacter failed", "user", userID, "err", err)
return nil, nil, fmt.Errorf("failed to load character: %w", err)
}
// Genuinely new player — auto-create
displayName := p.DisplayName(userID)
if err := createAdvCharacter(userID, displayName); err != nil {
return nil, nil, err
@@ -1229,3 +1272,44 @@ func (p *AdventurePlugin) ensureCharacter(userID id.UserID) (*AdventureCharacter
return char, equip, nil
}
// ── Double XP/Money Boost ───────────────────────────────────────────────────
const advBoostCacheKey = "adv_boost_active"
func advBoostActive() bool {
return db.CacheGet(advBoostCacheKey, 365*86400) == "1"
}
func advSetBoost(active bool) {
v := "0"
if active {
v = "1"
}
db.CacheSet(advBoostCacheKey, v)
}
func advApplyBoost(result *AdvActionResult) {
if !advBoostActive() {
return
}
result.XPGained *= 2
result.TotalLootValue = 0
for i := range result.LootItems {
result.LootItems[i].Value *= 2
result.TotalLootValue += result.LootItems[i].Value
}
}
func (p *AdventurePlugin) handleBoostCmd(ctx MessageContext) error {
if !p.IsAdmin(ctx.Sender) {
return nil
}
if advBoostActive() {
advSetBoost(false)
return p.SendReply(ctx.RoomID, ctx.EventID, "⚡ Double XP/money boost **disabled**.")
}
advSetBoost(true)
return p.SendReply(ctx.RoomID, ctx.EventID, "⚡ Double XP/money boost **enabled**! All adventure XP and loot values are doubled.")
}