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

@@ -16,6 +16,7 @@ import (
var (
mu sync.Mutex
globalDB *sql.DB
dataPath string
)
// Init opens (or creates) the SQLite database and runs migrations.
@@ -44,6 +45,7 @@ func Init(dataDir string) error {
}
globalDB = d
dataPath = dataDir
slog.Info("database initialized", "path", dbPath)
return nil
}
@@ -109,6 +111,28 @@ func runMigrations(d *sql.DB) error {
`ALTER TABLE adventure_characters ADD COLUMN npc_msg_count_date TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE adventure_characters ADD COLUMN misty_roll_target INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE adventure_characters ADD COLUMN arina_roll_target INTEGER NOT NULL DEFAULT 0`,
// Housing
`ALTER TABLE adventure_characters ADD COLUMN house_tier INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE adventure_characters ADD COLUMN house_loan_balance INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE adventure_characters ADD COLUMN house_loan_frozen INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE adventure_characters ADD COLUMN house_missed_payments INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE adventure_characters ADD COLUMN house_autopay INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE adventure_characters ADD COLUMN house_current_rate REAL NOT NULL DEFAULT 0`,
// Pets
`ALTER TABLE adventure_characters ADD COLUMN pet_type TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE adventure_characters ADD COLUMN pet_name TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE adventure_characters ADD COLUMN pet_xp INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE adventure_characters ADD COLUMN pet_level INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE adventure_characters ADD COLUMN pet_armor_tier INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE adventure_characters ADD COLUMN pet_chased_away INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE adventure_characters ADD COLUMN pet_reactivated INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE adventure_characters ADD COLUMN pet_arrived INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE adventure_characters ADD COLUMN misty_encounter_count INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE adventure_characters ADD COLUMN misty_donated_count INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE adventure_characters ADD COLUMN thom_animal_line_fired INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE adventure_characters ADD COLUMN pet_supply_shop_unlocked INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE adventure_characters ADD COLUMN pet_level10_date TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE adventure_characters ADD COLUMN pet_morning_defense INTEGER NOT NULL DEFAULT 0`,
}
for _, stmt := range columnMigrations {
if _, err := d.Exec(stmt); err != nil {
@@ -165,6 +189,47 @@ func CacheSet(key, data string) {
)
}
// Backup creates a consistent snapshot of the database using VACUUM INTO.
// Keeps the last 7 daily backups, deleting older ones.
func Backup() error {
backupDir := filepath.Join(dataPath, "backups")
if err := os.MkdirAll(backupDir, 0o755); err != nil {
return fmt.Errorf("create backup dir: %w", err)
}
filename := fmt.Sprintf("gogobee_%s.db", time.Now().UTC().Format("2006-01-02"))
backupPath := filepath.Join(backupDir, filename)
_, err := Get().Exec(fmt.Sprintf(`VACUUM INTO '%s'`, backupPath))
if err != nil {
return fmt.Errorf("vacuum into backup: %w", err)
}
slog.Info("database backup created", "path", backupPath)
// Prune backups older than 7 days
entries, err := os.ReadDir(backupDir)
if err != nil {
return nil // backup succeeded, prune failure is non-fatal
}
cutoff := time.Now().UTC().AddDate(0, 0, -7)
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".db") {
continue
}
info, err := e.Info()
if err != nil {
continue
}
if info.ModTime().Before(cutoff) {
os.Remove(filepath.Join(backupDir, e.Name()))
slog.Info("pruned old backup", "file", e.Name())
}
}
return nil
}
// RunMaintenance purges stale data from cache tables, old rate limits,
// expired logs, and runs SQLite optimization. Intended to run daily.
func RunMaintenance() {

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.")
}

View File

@@ -810,7 +810,7 @@ func advEligibleLocations(char *AdventureCharacter, equip map[EquipmentSlot]*Adv
// advCheckPartyBonus checks if other players visited the same location today.
func advCheckPartyBonus(userID id.UserID, location string) bool {
logs, err := loadAdvTodayLogs()
logs, err := loadAdvLogsForDate(time.Now().UTC().Format("2006-01-02"))
if err != nil {
return false
}

View File

@@ -148,21 +148,47 @@ func (p *AdventurePlugin) handleArenaFight(ctx MessageContext) error {
return p.resolveArenaSurvival(ctx, run, char, tier, monster, combatLog, npcResult)
}
// Normal combat roll
// Pet combat actions
petResult := petRollCombatActions(char, monster.Name)
// Normal combat roll — pet deflect reduces effective death chance
deathChance := arenaDeathChance(monster, char, equip)
if petResult != nil && petResult.Deflected {
deathChance *= 0.5 // deflect halves death chance for this round
}
roll := rand.Float64()
died := roll < deathChance
closeness := 1.0 - math.Abs(roll-deathChance)/math.Max(deathChance, 1-deathChance)
combatLog := generateArenaCombatLog(!died, closeness)
// Append pet text to combat log
var petText string
if petResult != nil {
if petResult.Attacked {
petText += "\n\n" + petResult.AttackText
}
if petResult.Deflected {
petText += "\n\n" + petResult.DeflectText
}
}
if died {
if npcResult != nil && npcResult.Text != "" {
combatLog.NPCText = npcResult.Text
}
combatLog.NPCText += petText
if petResult != nil && (petResult.Attacked || petResult.Deflected) {
combatLog.NPCText += "\n\n" + petDeathText(char)
}
return p.resolveArenaDeath(ctx, run, char, tier, monster, combatLog)
}
combatLog.NPCText += petText
if petResult != nil && (petResult.Attacked || petResult.Deflected) {
combatLog.NPCText += "\n\n" + petVictoryText(char)
}
return p.resolveArenaSurvival(ctx, run, char, tier, monster, combatLog, npcResult)
}
@@ -212,20 +238,44 @@ func (p *AdventurePlugin) confirmAndStartArenaRun(ctx MessageContext) error {
return p.resolveArenaSurvival(ctx, run, char, tier, monster, combatLog, npcResult)
}
petResult := petRollCombatActions(char, monster.Name)
deathChance := arenaDeathChance(monster, char, equip)
if petResult != nil && petResult.Deflected {
deathChance *= 0.5
}
roll := rand.Float64()
died := roll < deathChance
closeness := 1.0 - math.Abs(roll-deathChance)/math.Max(deathChance, 1-deathChance)
combatLog := generateArenaCombatLog(!died, closeness)
var petText string
if petResult != nil {
if petResult.Attacked {
petText += "\n\n" + petResult.AttackText
}
if petResult.Deflected {
petText += "\n\n" + petResult.DeflectText
}
}
if died {
if npcResult != nil && npcResult.Text != "" {
combatLog.NPCText = npcResult.Text
}
combatLog.NPCText += petText
if petResult != nil && (petResult.Attacked || petResult.Deflected) {
combatLog.NPCText += "\n\n" + petDeathText(char)
}
return p.resolveArenaDeath(ctx, run, char, tier, monster, combatLog)
}
combatLog.NPCText += petText
if petResult != nil && (petResult.Attacked || petResult.Deflected) {
combatLog.NPCText += "\n\n" + petVictoryText(char)
}
return p.resolveArenaSurvival(ctx, run, char, tier, monster, combatLog, npcResult)
}
@@ -531,6 +581,51 @@ func (p *AdventurePlugin) resolveArenaDeath(ctx MessageContext, run *ArenaRun, c
return p.SendDM(ctx.Sender, text)
}
// ── Pet ditch recovery — reduced death penalty ──
petRecovery := petRollDitchRecovery(char)
if petRecovery {
// Pet intervenes — player still dies but respawn timer is reduced
char.Kill()
if char.DeadUntil != nil {
reduced := time.Now().UTC().Add(petDitchRecoveryTime(char.PetLevel))
char.DeadUntil = &reduced
}
char.ArenaLosses++
char.CombatXP += arenaParticipationXP
if leveled, _ := checkAdvLevelUp(char, "combat"); leveled {
p.checkRivalPoolUnlock(char)
}
if err := saveAdvCharacter(char); err != nil {
slog.Error("arena: failed to save after pet ditch recovery", "user", ctx.Sender, "err", err)
}
run.Status = "dead"
run.Earnings = 0
run.TierEarnings = 0
run.XPAccumulated = 0
endNow := time.Now().UTC()
run.EndedAt = &endNow
_ = saveArenaRun(run)
insertArenaHistory(run.UserID, run.StartTier, run.Tier, run.RoundsSurvived, 0, "dead", monster.Name)
upsertArenaStats(run.UserID, 0, true, run.Tier)
closer := arenaLoseCloser(monster.Name, len(combatLog.Rounds))
text := renderArenaCombatLog(combatLog, monster, false, 0, arenaParticipationXP, closer)
text += "\n\n_oof_"
_ = p.SendDM(ctx.Sender, text)
// Game room posts
gr := gamesRoom()
if gr != "" {
_ = p.SendMessage(gr, petDitchRecoveryGameRoom(char.DisplayName, char.PetName, true))
}
p.sendHospitalAd(ctx.Sender, char)
return nil
}
// ── Actual death — forfeit all session rewards ──
lostEarnings := run.Earnings + run.TierEarnings
@@ -722,7 +817,16 @@ func arenaDeathChance(monster *ArenaMonster, char *AdventureCharacter, equip map
}
equipMod := avgTier * 0.03 // 0 at tier 0, 0.15 at tier 5
deathChance := baseDeath + levelMod - equipMod + skillMod
// Housing HP bonus reduces death chance
houseMod := char.HouseHPBonus() // 0-20% based on house tier
// Pet morning defense buff (cat offering / dog smothering)
petDefMod := 0.0
if char.PetMorningDefense {
petDefMod = 0.05
}
deathChance := baseDeath + levelMod - equipMod + skillMod - houseMod - petDefMod
return math.Max(0.01, math.Min(0.98, deathChance))
}

View File

@@ -284,6 +284,9 @@ func (p *AdventurePlugin) runBabysitAction(char *AdventureCharacter, equip map[E
result.EquipBroken = nil
}
// Double XP/money boost
advApplyBoost(result)
// Apply XP
switch result.XPSkill {
case "combat":

View File

@@ -71,6 +71,28 @@ type AdventureCharacter struct {
NPCMsgCountDate string
MistyRollTarget int
ArinaRollTarget int
// Housing
HouseTier int
HouseLoanBalance int
HouseLoanFrozen bool
HouseMissedPayments int
HouseAutopay bool
HouseCurrentRate float64
// Pets
PetType string
PetName string
PetXP int
PetLevel int
PetArmorTier int
PetChasedAway bool
PetReactivated bool
PetArrived bool
MistyEncounterCount int
MistyDonatedCount int
ThomAnimalLineFired bool
PetSupplyShopUnlocked bool
PetLevel10Date string
PetMorningDefense bool
}
type AdvEquipment struct {
@@ -232,6 +254,31 @@ func (c *AdventureCharacter) Kill() {
c.LastDeathDate = time.Now().UTC().Format("2006-01-02")
}
// HasPet returns true if the player has an active pet (not chased away).
func (c *AdventureCharacter) HasPet() bool {
return c.PetType != "" && c.PetArrived && !c.PetChasedAway
}
// HasHouse returns true if the player has purchased at least a base house.
func (c *AdventureCharacter) HasHouse() bool {
return c.HouseTier > 0 || c.HouseLoanBalance > 0
}
// HouseHPBonus returns the HP bonus percentage from housing tier.
// Tier 1 (Base) = +0%, Tier 2 (Livable) = +5%, Tier 3 (Comfortable) = +12%, Tier 4 (Established) = +20%
func (c *AdventureCharacter) HouseHPBonus() float64 {
switch c.HouseTier {
case 2:
return 0.05
case 3:
return 0.12
case 4:
return 0.20
default:
return 0
}
}
// ── Action Economy ──────────────────────────────────────────────────────────
const maxCombatActions = 1
@@ -368,6 +415,9 @@ func loadAdvCharacter(userID id.UserID) (*AdventureCharacter, error) {
var deadUntil, reprieveLast, babysitExp, pardonUsed sql.NullTime
var mistyLastSeen, arinaLastSeen, mistyBuffExp, mistyDebuffExp, arinaBuffExp sql.NullTime
var houseFrozen, houseAutopay int
var petChasedAway, petReactivated, petArrived, thomAnimalLine, petSupplyUnlocked, petMorningDef int
err := d.QueryRow(`
SELECT user_id, display_name,
combat_level, mining_skill, foraging_skill, fishing_skill,
@@ -385,7 +435,14 @@ func loadAdvCharacter(userID id.UserID) (*AdventureCharacter, error) {
misty_last_seen, arina_last_seen,
misty_buff_expires, misty_debuff_expires, arina_buff_expires,
npc_msg_count, npc_msg_count_date,
misty_roll_target, arina_roll_target
misty_roll_target, arina_roll_target,
house_tier, house_loan_balance, house_loan_frozen, house_missed_payments,
house_autopay, house_current_rate,
pet_type, pet_name, pet_xp, pet_level, pet_armor_tier,
pet_chased_away, pet_reactivated, pet_arrived,
misty_encounter_count, misty_donated_count,
thom_animal_line_fired, pet_supply_shop_unlocked, pet_level10_date,
pet_morning_defense
FROM adventure_characters WHERE user_id = ?`, string(userID)).Scan(
&c.UserID, &c.DisplayName,
&c.CombatLevel, &c.MiningSkill, &c.ForagingSkill, &c.FishingSkill,
@@ -404,6 +461,13 @@ func loadAdvCharacter(userID id.UserID) (*AdventureCharacter, error) {
&mistyBuffExp, &mistyDebuffExp, &arinaBuffExp,
&c.NPCMsgCount, &c.NPCMsgCountDate,
&c.MistyRollTarget, &c.ArinaRollTarget,
&c.HouseTier, &c.HouseLoanBalance, &houseFrozen, &c.HouseMissedPayments,
&houseAutopay, &c.HouseCurrentRate,
&c.PetType, &c.PetName, &c.PetXP, &c.PetLevel, &c.PetArmorTier,
&petChasedAway, &petReactivated, &petArrived,
&c.MistyEncounterCount, &c.MistyDonatedCount,
&thomAnimalLine, &petSupplyUnlocked, &c.PetLevel10Date,
&petMorningDef,
)
if err != nil {
return nil, err
@@ -413,6 +477,7 @@ func loadAdvCharacter(userID id.UserID) (*AdventureCharacter, error) {
c.HolidayActionTaken = holidayTaken == 1
c.RivalUnlockedNotified = rivalUnlocked == 1
c.BabysitActive = babysitAct == 1
c.PetMorningDefense = petMorningDef == 1
if deadUntil.Valid {
c.DeadUntil = &deadUntil.Time
}
@@ -440,6 +505,13 @@ func loadAdvCharacter(userID id.UserID) (*AdventureCharacter, error) {
if arinaBuffExp.Valid {
c.ArinaBuffExpires = &arinaBuffExp.Time
}
c.HouseLoanFrozen = houseFrozen == 1
c.HouseAutopay = houseAutopay == 1
c.PetChasedAway = petChasedAway == 1
c.PetReactivated = petReactivated == 1
c.PetArrived = petArrived == 1
c.ThomAnimalLineFired = thomAnimalLine == 1
c.PetSupplyShopUnlocked = petSupplyUnlocked == 1
return c, nil
}
@@ -494,6 +566,38 @@ func saveAdvCharacter(char *AdventureCharacter) error {
if char.BabysitActive {
babysitAct = 1
}
houseFrozen := 0
if char.HouseLoanFrozen {
houseFrozen = 1
}
houseAutopay := 0
if char.HouseAutopay {
houseAutopay = 1
}
petChasedAway := 0
if char.PetChasedAway {
petChasedAway = 1
}
petReactivated := 0
if char.PetReactivated {
petReactivated = 1
}
petArrived := 0
if char.PetArrived {
petArrived = 1
}
thomAnimalLine := 0
if char.ThomAnimalLineFired {
thomAnimalLine = 1
}
petSupplyUnlocked := 0
if char.PetSupplyShopUnlocked {
petSupplyUnlocked = 1
}
petMorningDef := 0
if char.PetMorningDefense {
petMorningDef = 1
}
_, err := d.Exec(`
UPDATE adventure_characters SET
@@ -512,7 +616,14 @@ func saveAdvCharacter(char *AdventureCharacter) error {
misty_last_seen = ?, arina_last_seen = ?,
misty_buff_expires = ?, misty_debuff_expires = ?, arina_buff_expires = ?,
npc_msg_count = ?, npc_msg_count_date = ?,
misty_roll_target = ?, arina_roll_target = ?
misty_roll_target = ?, arina_roll_target = ?,
house_tier = ?, house_loan_balance = ?, house_loan_frozen = ?, house_missed_payments = ?,
house_autopay = ?, house_current_rate = ?,
pet_type = ?, pet_name = ?, pet_xp = ?, pet_level = ?, pet_armor_tier = ?,
pet_chased_away = ?, pet_reactivated = ?, pet_arrived = ?,
misty_encounter_count = ?, misty_donated_count = ?,
thom_animal_line_fired = ?, pet_supply_shop_unlocked = ?, pet_level10_date = ?,
pet_morning_defense = ?
WHERE user_id = ?`,
char.DisplayName, char.CombatLevel, char.MiningSkill, char.ForagingSkill, char.FishingSkill,
char.CombatXP, char.MiningXP, char.ForagingXP, char.FishingXP,
@@ -529,6 +640,13 @@ func saveAdvCharacter(char *AdventureCharacter) error {
char.MistyBuffExpires, char.MistyDebuffExpires, char.ArinaBuffExpires,
char.NPCMsgCount, char.NPCMsgCountDate,
char.MistyRollTarget, char.ArinaRollTarget,
char.HouseTier, char.HouseLoanBalance, houseFrozen, char.HouseMissedPayments,
houseAutopay, char.HouseCurrentRate,
char.PetType, char.PetName, char.PetXP, char.PetLevel, char.PetArmorTier,
petChasedAway, petReactivated, petArrived,
char.MistyEncounterCount, char.MistyDonatedCount,
thomAnimalLine, petSupplyUnlocked, char.PetLevel10Date,
petMorningDef,
string(char.UserID),
)
return err
@@ -646,9 +764,20 @@ func loadAllAdvCharacters() ([]AdventureCharacter, error) {
masterwork_drops_received,
rival_pool, rival_unlocked_notified,
babysit_active, babysit_expires_at, babysit_skill_focus,
hospital_visits, robbie_visit_count, last_death_date,
combat_actions_used, harvest_actions_used,
last_pardon_used,
misty_last_seen, arina_last_seen,
misty_buff_expires, misty_debuff_expires, arina_buff_expires,
npc_msg_count, npc_msg_count_date,
misty_roll_target, arina_roll_target
misty_roll_target, arina_roll_target,
house_tier, house_loan_balance, house_loan_frozen, house_missed_payments,
house_autopay, house_current_rate,
pet_type, pet_name, pet_xp, pet_level, pet_armor_tier,
pet_chased_away, pet_reactivated, pet_arrived,
misty_encounter_count, misty_donated_count,
thom_animal_line_fired, pet_supply_shop_unlocked, pet_level10_date,
pet_morning_defense
FROM adventure_characters`)
if err != nil {
return nil, err
@@ -659,7 +788,10 @@ func loadAllAdvCharacters() ([]AdventureCharacter, error) {
for rows.Next() {
c := AdventureCharacter{}
var alive, actionTaken, holidayTaken, rivalUnlocked, babysitAct int
var deadUntil, reprieveLast, babysitExp sql.NullTime
var deadUntil, reprieveLast, babysitExp, pardonUsed sql.NullTime
var mistyLastSeen, arinaLastSeen, mistyBuffExp, mistyDebuffExp, arinaBuffExp sql.NullTime
var houseFrozen, houseAutopay int
var petChasedAway, petReactivated, petArrived, thomAnimalLine, petSupplyUnlocked, petMorningDef int
if err := rows.Scan(
&c.UserID, &c.DisplayName,
&c.CombatLevel, &c.MiningSkill, &c.ForagingSkill, &c.FishingSkill,
@@ -671,9 +803,20 @@ func loadAllAdvCharacters() ([]AdventureCharacter, error) {
&c.MasterworkDropsReceived,
&c.RivalPool, &rivalUnlocked,
&babysitAct, &babysitExp, &c.BabysitSkillFocus,
&c.CombatActionsUsed, &c.HarvestActionsUsed,
&c.NPCMsgCount, &c.NPCMsgCountDate,
&c.MistyRollTarget, &c.ArinaRollTarget,
&c.HospitalVisits, &c.RobbieVisitCount, &c.LastDeathDate,
&c.CombatActionsUsed, &c.HarvestActionsUsed,
&pardonUsed,
&mistyLastSeen, &arinaLastSeen,
&mistyBuffExp, &mistyDebuffExp, &arinaBuffExp,
&c.NPCMsgCount, &c.NPCMsgCountDate,
&c.MistyRollTarget, &c.ArinaRollTarget,
&c.HouseTier, &c.HouseLoanBalance, &houseFrozen, &c.HouseMissedPayments,
&houseAutopay, &c.HouseCurrentRate,
&c.PetType, &c.PetName, &c.PetXP, &c.PetLevel, &c.PetArmorTier,
&petChasedAway, &petReactivated, &petArrived,
&c.MistyEncounterCount, &c.MistyDonatedCount,
&thomAnimalLine, &petSupplyUnlocked, &c.PetLevel10Date,
&petMorningDef,
); err != nil {
return nil, err
}
@@ -682,6 +825,14 @@ func loadAllAdvCharacters() ([]AdventureCharacter, error) {
c.HolidayActionTaken = holidayTaken == 1
c.RivalUnlockedNotified = rivalUnlocked == 1
c.BabysitActive = babysitAct == 1
c.PetMorningDefense = petMorningDef == 1
c.HouseLoanFrozen = houseFrozen == 1
c.HouseAutopay = houseAutopay == 1
c.PetChasedAway = petChasedAway == 1
c.PetReactivated = petReactivated == 1
c.PetArrived = petArrived == 1
c.ThomAnimalLineFired = thomAnimalLine == 1
c.PetSupplyShopUnlocked = petSupplyUnlocked == 1
if deadUntil.Valid {
c.DeadUntil = &deadUntil.Time
}
@@ -691,6 +842,24 @@ func loadAllAdvCharacters() ([]AdventureCharacter, error) {
if babysitExp.Valid {
c.BabysitExpiresAt = &babysitExp.Time
}
if pardonUsed.Valid {
c.LastPardonUsed = &pardonUsed.Time
}
if mistyLastSeen.Valid {
c.MistyLastSeen = &mistyLastSeen.Time
}
if arinaLastSeen.Valid {
c.ArinaLastSeen = &arinaLastSeen.Time
}
if mistyBuffExp.Valid {
c.MistyBuffExpires = &mistyBuffExp.Time
}
if mistyDebuffExp.Valid {
c.MistyDebuffExpires = &mistyDebuffExp.Time
}
if arinaBuffExp.Valid {
c.ArinaBuffExpires = &arinaBuffExp.Time
}
chars = append(chars, c)
}
return chars, rows.Err()
@@ -701,7 +870,7 @@ func resetAllAdvDailyActions() error {
// Only reset actions taken before today — protects against race if a player
// resolves their action at exactly midnight.
today := time.Now().UTC().Format("2006-01-02")
_, err := d.Exec(`UPDATE adventure_characters SET action_taken_today = 0, holiday_action_taken = 0, combat_actions_used = 0, harvest_actions_used = 0 WHERE last_action_date < ? OR last_action_date IS NULL`, today)
_, err := d.Exec(`UPDATE adventure_characters SET action_taken_today = 0, holiday_action_taken = 0, combat_actions_used = 0, harvest_actions_used = 0, pet_morning_defense = 0 WHERE last_action_date < ? OR last_action_date IS NULL`, today)
return err
}
@@ -762,14 +931,13 @@ type AdvDayLog struct {
XPGained int
}
func loadAdvTodayLogs() ([]AdvDayLog, error) {
func loadAdvLogsForDate(date string) ([]AdvDayLog, error) {
d := db.Get()
today := time.Now().UTC().Format("2006-01-02")
rows, err := d.Query(`
SELECT user_id, activity_type, COALESCE(location,''), outcome, loot_value, xp_gained
FROM adventure_activity_log
WHERE logged_at >= ? AND logged_at < DATE(?, '+1 day')
ORDER BY logged_at`, today, today)
ORDER BY logged_at`, date, date)
if err != nil {
return nil, err
}

View File

@@ -1,5 +1,13 @@
package plugin
// ── IMPORTANT: DO NOT REWRITE, SUMMARIZE, OR SHORTEN ANY ENTRIES IN THIS FILE ──
//
// This file contains hand-written flavor text. Every entry is the length it is
// on purpose. The voice, pacing, and specific wording are intentional.
// If an entry seems too long: it isn't. Leave it alone.
// If you are tempted to "clean up" or "condense" entries: don't.
// If your context window is too small to fit this file: that is your problem, not ours.
//
// ── FISHING FLAVOR TEXT ───────────────────────────────────────────────────────
//
// Tier 1: Muddy Pond — garbage, sad fish, puns, Stardew energy

View File

@@ -0,0 +1,363 @@
package plugin
// ── IMPORTANT: DO NOT REWRITE, SUMMARIZE, OR SHORTEN ANY ENTRIES IN THIS FILE ──
//
// This file contains hand-written flavor text. Every entry is the length it is
// on purpose. The voice, pacing, and specific wording are intentional.
// If an entry seems too long: it isn't. Leave it alone.
// If you are tempted to "clean up" or "condense" entries: don't.
// If your context window is too small to fit this file: that is your problem, not ours.
//
// ── PET FLAVOR TEXT ───────────────────────────────────────────────────────────
//
// Two pets. One register each.
//
// The Dog: enthusiastic, physical, zero technique, completely committed.
// Loves you unconditionally and expresses this through violence on your behalf.
// The Cat: massive, precise, indifferent. Helps occasionally. Does not explain why.
// The enemy should feel honored and also concerned.
//
// Both: never die, never leave, have opinions about dinner.
// ── DOG ATTACK ────────────────────────────────────────────────────────────────
var PetDogAttack = []string{
"Your dog has decided to get involved.\n\n" +
"Not strategically. Not tactically. Just fully and immediately.\n\n" +
"{enemy} takes {damage} damage and a lot of general chaos.",
"Your dog launches itself at {enemy} with the energy of something " +
"that has never once considered consequences.\n\n" +
"{damage} damage. Your dog lands, shakes itself off, and looks at you " +
"like it just did the most normal thing in the world.\n\nIt did not.",
"Your dog has joined the fight.\n\n" +
"Your dog did not ask permission.\n\n" +
"Your dog never asks permission.\n\n" +
"{enemy} takes {damage} damage. Your dog takes a victory lap.",
"Without warning, without strategy, and without any apparent plan beyond " +
"extreme commitment, your dog hits {enemy} for {damage} damage.\n\n" +
"The crowd goes absolutely feral.",
"Your dog sees an opening that you did not see, will never see, " +
"and could not explain if asked.\n\n" +
"It takes it. {damage} damage to {enemy}.\n\n" +
"The dog returns to your side looking very pleased with itself.\n\n" +
"It should be.",
"Your dog barrels into {enemy} at full speed with its entire body weight, " +
"which is considerable, because it is a massive dog.\n\n" +
"{damage} damage.\n\nPhysics was involved.",
"Your dog bites {enemy}.\n\nDeliberately.\n\nRepeatedly.\n\n" +
"{damage} damage total.\n\nYour dog lets go when it feels like it.",
"Your dog has entered the arena.\n\nYour dog was not supposed to enter the arena.\n\n" +
"Your dog did not read the rules.\n\nYour dog cannot read.\n\n" +
"{enemy} takes {damage} damage. The rules remain technically intact.",
}
// ── CAT ATTACK ────────────────────────────────────────────────────────────────
var PetCatAttack = []string{
"Your cat intervenes.\n\n" +
"Once. Precisely. Without looking at you afterward.\n\n" +
"{enemy} takes {damage} damage and is left with the unsettling feeling " +
"that this was a favor they did not earn.",
"Your cat has briefly decided you are worth defending.\n\n" +
"It acts on this decision with surgical efficiency.\n\n" +
"{damage} damage to {enemy}.\n\n" +
"The decision has already been rescinded. Don't read into it.",
"Your cat moves.\n\nThat's all.\n\nYour cat just moves.\n\n" +
"{enemy} takes {damage} damage and spends the rest of the round " +
"trying to understand what happened.",
"Your cat looks at {enemy}.\n\nThen at you.\n\nThen back at {enemy}.\n\n" +
"Then it handles it.\n\n{damage} damage.\n\nYour cat sits back down.",
"Your cat has, for reasons it will not share, chosen this moment.\n\n" +
"{damage} damage. Clean. Efficient. Completely without ego.\n\n" +
"Your cat is already elsewhere.",
"The cat moves through the fight like it owns the arena.\n\n" +
"It does not own the arena.\n\nThe arena is reconsidering its position on this.\n\n" +
"{enemy} takes {damage} damage. The cat does not acknowledge the applause.",
"Your cat strikes {enemy} once.\n\nIt does not strike twice.\n\n" +
"It did not need to.\n\n{damage} damage.\n\n" +
"Your cat has returned to doing whatever it was doing before, " +
"which was nothing, and also everything.",
"Your massive cat drops from somewhere it wasn't a moment ago " +
"and lands directly on {enemy}'s immediate future.\n\n" +
"{damage} damage.\n\nYour cat leaves without comment.\n\n" +
"{enemy} has questions. The cat will not be answering them.",
}
// ── DOG DEATH REACTION ────────────────────────────────────────────────────────
var PetDogDeath = []string{
"Your dog was napping as your foe finished you off.\n\n" +
"They're upset now.\n\nNot because of what happened to you.\n\n" +
"Because dinner won't be arriving on time today.",
"Your dog watched the whole thing.\n\nDid nothing.\n\nIs now sitting by the door.\n\n" +
"Not waiting for you to come back.\n\nWaiting for whoever brings the food to come back.\n\n" +
"These are different.",
"Your dog has located your empty bowl and is moving it around the kitchen floor " +
"to let someone know there's been some kind of administrative error.",
"Your dog is fine.\n\nYour dog is great, actually.\n\n" +
"Your dog found something on the floor and ate it and is having a wonderful afternoon.\n\n" +
"You were gone, they noted. Briefly. Then the floor thing happened.",
"Your dog is sitting in front of your equipment and looking at it.\n\n" +
"Not mournfully.\n\nSpeculatively.\n\n" +
"Your dog has never fully ruled out that the equipment is food.",
"Your dog is howling.\n\nNot in grief.\n\nIn the specific register of a dog " +
"who has been waiting an unreasonable amount of time for their walk.\n\n" +
"The neighbors are aware.",
"Your dog is absolutely fine and has already made several new friends " +
"who came to check on the situation.\n\n" +
"Your dog showed them around.\n\nYour dog let them pet it.\n\n" +
"Your dog has no concept of what just happened to you and is having the best day.",
}
// ── CAT DEATH REACTION ────────────────────────────────────────────────────────
var PetCatDeath = []string{
"Your cat is aware you died.\n\n" +
"Your cat has elected not to comment at this time.\n\n" +
"Dinner, however, is now late, and your cat has many comments about that.",
"Your cat watched you lose from across the arena.\n\n" +
"Its expression did not change.\n\n" +
"Its expression never changes.\n\n" +
"Dinner is late. Your cat's expression has changed.",
"Your cat is sitting on your things.\n\nNot to mourn.\n\n" +
"Your cat sits on things. That's just what it does.\n\n" +
"Dinner being late is a separate and more pressing issue " +
"that your cat would like addressed immediately.",
"Your cat has knocked something off a surface.\n\n" +
"Not in grief.\n\nJust because it was there.\n\n" +
"Dinner is late. Your cat will continue knocking things off surfaces " +
"until this is corrected.",
"Your cat is fine.\n\nYour cat is always fine.\n\n" +
"Your cat was fine before you and will be fine after.\n\n" +
"Dinner is late and your cat is making it everyone's problem.",
"Your cat has sat down directly in the center of the room and is staring at the wall.\n\n" +
"This could mean anything.\n\nIt means dinner is late.",
}
// ── DOG VICTORY REACTION ──────────────────────────────────────────────────────
// Fires occasionally. Not every win. The dog has other things going on.
var PetDogVictory = []string{
"Your dog is aware you won.\n\nYour dog is losing its mind about this.\n\n" +
"Your dog has won every fight you have ever been in, " +
"in the sense that it loves you and you came home, " +
"and it is treating this occasion with the same energy as all the others, " +
"which is: maximum.",
"Your dog is running in circles.\n\nNot for any reason.\n\n" +
"Just because you won and it's a good day and " +
"running in circles is how your dog processes good days.",
"Your dog has brought you something.\n\nYou don't know what it is.\n\n" +
"Your dog is very proud of it.\n\nYou accept it.\n\n" +
"This is what winning looks like.",
"Your dog is pressed against your leg.\n\nHard.\n\n" +
"Your dog does not fully understand what the arena is.\n\n" +
"Your dog understands that you left and came back.\n\n" +
"Your dog finds this extremely good news every single time.",
"Your dog has decided that the victory belongs to both of you equally " +
"and is accepting congratulations from nearby strangers on this basis.\n\n" +
"The strangers are obliging.\n\nThe dog deserves it.",
}
// ── CAT VICTORY REACTION ──────────────────────────────────────────────────────
// Fires rarely. The cat has acknowledged maybe three of your wins. Total.
var PetCatVictory = []string{
"Your cat glances at you.\n\nOnce.\n\nThen away.\n\n" +
"This is the cat equivalent of a standing ovation.\n\n" +
"You will not receive another one this week.",
"Your cat is sitting near you.\n\nNot with you.\n\nNear you.\n\n" +
"This is as close as it gets to celebration.\n\n" +
"Take it.",
"Your cat blinks slowly in your direction.\n\n" +
"You have been approved of.\n\n" +
"Briefly.\n\nConditionally.\n\nDon't push it.",
"Your cat has moved to a slightly closer position than usual.\n\n" +
"It will not explain this.\n\nIt may move back.\n\n" +
"For now: proximity. That's the win.",
}
// ── DOG DEFLECT ───────────────────────────────────────────────────────────────
var PetDogDeflect = []string{
"Your dog steps in front of {enemy}'s attack.\n\n" +
"Not strategically.\n\nJust because you were there and the thing coming at you " +
"was also coming at you and your dog had opinions about that.\n\n" +
"{enemy} misses.\n\nYour dog looks very pleased with itself.",
"Your dog saw it coming before you did.\n\n" +
"Your dog always sees it coming before you do.\n\n" +
"You've never fully processed this.\n\n" +
"{enemy}'s strike lands on nothing.\n\nYour dog is already back at your side.",
"{enemy} swings.\n\nYour dog moves.\n\n" +
"These two things happened in the wrong order for {enemy}.\n\n" +
"The attack misses and causes a dog treat to fall out of its pocket.\n\n" +
"Mission accomplished.. treat acquired! ..Oh and you weren't hit.. I guess. ...yay.",
"Your dog body-checks {enemy}'s weapon arm at full speed.\n\n" +
"This was not a trained technique.\n\n" +
"This was a massive dog moving very fast toward something it didn't like.\n\n" +
"The attack goes wide.\n\nPhysics handled it.",
"{enemy} commits to the strike.\n\nYour dog doesn't like what it sees and barks loudly enough to startle {enemy} so strongly that it yelps.\n\n" +
"This would have been awesome if you hadn't also yelped as well.\n\n" +
"Either way.. {enemy} misses!\n\n",
"Your dog throws itself between you and {enemy}'s attack " +
"with the energy of something that has never once considered " +
"that this might not work.\n\n" +
"It works.\n\n{enemy} misses.\n\nYour dog shakes itself off and keeps going.",
}
// ── CAT DEFLECT ───────────────────────────────────────────────────────────────
var PetCatDeflect = []string{
"Your cat moves once.\n\n" +
"{enemy}'s attack finds nothing.\n\n" +
"Your cat does not look at {enemy}.\n\n" +
"Your cat does not look at you.\n\n" +
"Your cat has already moved on.",
"{enemy} strikes.\n\nYour cat was there.\n\nThen your cat wasn't.\n\n" +
"The attack misses.\n\nYour cat is now somewhere else entirely.\n\n" +
"Nobody saw it move.",
"Your cat steps into the path of {enemy}'s attack " +
"and then out of it in a single motion that takes approximately no time.\n\n" +
"{enemy} misses.\n\nYour cat sits down.\n\n" +
"The crowd roars excitedly at the spectacle which you foolishly believe is for you " +
"until the announcer \"helpfully\" corrects you.",
"Your cat redirects {enemy}'s strike with one paw.\n\n" +
"Casually.\n\nLike it had something else to do and this was briefly in the way.\n\n" +
"The attack goes wide.\n\nYour cat returns to having something else to do.",
"{enemy} swings.\n\nYour cat looks at the swing.\n\n" +
"Your cat decides against it.\n\n" +
"The swing finds nothing.\n\n" +
"Your cat had already made its decision before {enemy} had finished committing.\n\n" +
"That's the difference.",
"Your cat inserts itself into the situation briefly and then removes itself.\n\n" +
"{enemy}'s attack lands on the space your cat just vacated.\n\n" +
"Your cat is already somewhere else.\n\nIt blinks.\n\nNot at you.\n\nAt nothing.\n\n" +
"The way cats do.",
}
// Fires randomly in the morning DM. Cat has left something.
// Results in a defense boost for the day.
// The cat is proud. The cat will never stop doing this.
var PetCatOffering = []string{
"There is half a rat on your doorstep.\n\n" +
"You notice that your cat is looking at you from a distance.\n\n" +
"You pretend to take a bite.\n\nYour cat looks genuinely upset.\n\n" +
"Defense increased for today.",
"Something has been left at your door.\n\nSomething that was recently alive.\n\n" +
"Your cat has arranged it thoughtfully.\n\nThis took effort.\n\n" +
"You are moved in a way you did not expect and cannot fully explain.\n\n" +
"Defense increased for today.",
"Your cat has brought you a bird.\n\nMost of a bird.\n\n" +
"Your cat is extremely pleased with itself.\n\n" +
"You look at the bird.\n\nYou look at your cat.\n\n" +
"Your cat has never once doubted you (in a way that *you* would notice anyway).\n\n" +
"You head out with that energy.\n\nDefense increased for today.",
"There is something on your doorstep that you will not be describing in detail.\n\n" +
"Your cat is sitting beside it with the composure of someone " +
"who has provided and would like acknowledgement.\n\n" +
"Your horrified expression pleases the cat and you are now ready for anything.\n\nDefense increased for today.",
"Your cat hunted last night.\n\nYour cat hunted successfully.\n\n" +
"Your cat brought the evidence to your door because you are its person " +
"and it wanted you to know that things have been handled.\n\n" +
"Things have been handled.\n\nDefense increased for today.",
"Half a mouse.\n\nYour cat.\n\nThe specific expression of an animal that loves you " +
"in the only language it fully trusts.\n\n" +
"You stand there for a moment.\n\nYou go inside.\n\nYou come back with a treat.\n\n" +
"Your cat sniffs it and eats the rest of the mouse instead.\n\n" +
"Defense increased for today.",
}
// ── DOG MORNING SMOTHERING ────────────────────────────────────────────────────
// Fires randomly in the morning DM. Dog has slept on top of the player.
// Results in a defense boost for the day.
// The dog has no idea. The dog never has any idea.
var PetDogSmothering = []string{
"You woke up this morning unable to breathe.\n\n" +
"This was because your dog was on top of you.\n\nAll of your dog.\n\n" +
"Your dog was asleep.\n\nYour dog is still asleep.\n\n" +
"You moved your dog.\n\nYour dog made a noise.\n\n" +
"You lay there for a moment, alive, aware of it.\n\n" +
"Defense increased for today.",
"At some point last night your dog relocated from its bed to your chest.\n\n" +
"Your dog weighs a considerable amount because it is a massive dog.\n\n" +
"You slept under this weight for several hours.\n\n" +
"You feel like you have survived something.\n\nYou have survived something.\n\n" +
"Defense increased for today.",
"Your dog was on your legs when you woke up.\n\n" +
"Your dog was also somehow on your chest.\n\n" +
"The physics of this are unclear.\n\n" +
"You have no feeling in your lower half.\n\n" +
"You have never felt more alive.\n\nDefense increased for today.",
"You woke up with your dog's full weight distributed across your torso " +
"in a way that suggests your dog spent the night actively trying to become part of you.\n\n" +
"Your dog succeeded, spiritually.\n\n" +
"You are a unit now.\n\nDefense increased for today.",
"Your dog smothered you last night.\n\nNot with malice.\n\nWith love.\n\n" +
"There is no meaningful difference in outcome but the intent matters " +
"and the intent was pure.\n\n" +
"You made it.\n\nDefense increased for today.",
"You nearly died in your own bed.\n\n" +
"Your dog doesn't know this.\n\nYour dog is wagging its tail.\n\n" +
"Your dog slept better than it ever has.\n\n" +
"You look at your dog.\n\nYour dog is wagging its tail.\n\n" +
"Despite the near death experience, you feel all is right with the world at that moment.\n\n" +
"Defense increased for today.",
"Your dog is 'little spoon' in a technical sense only.\n\n" +
"In practice your dog is the entire bed and you are whatever " +
"fits in the remaining space, which last night was: not much.\n\n" +
"You woke up on the edge.\n\nYou did not fall.\n\n" +
"You are tougher than you thought.\n\nDefense increased for today.",
}

View File

@@ -0,0 +1,759 @@
package plugin
import (
"fmt"
"log/slog"
"math/rand/v2"
"strconv"
"strings"
"time"
)
// ── Housing Tier Definitions ───────────────────────────────────────────────
type HouseTierDef struct {
Tier int
Name string
Description string
BasePrice int
}
// HouseTier 0 = no house. Purchasable tiers are 1-4.
var houseTiers = []HouseTierDef{
{Tier: 1, Name: "Base House", Description: "Four walls and a roof. 'Livable' is a strong word.", BasePrice: 75000},
{Tier: 2, Name: "Livable", Description: "A place you can sit down in without questioning your life choices.", BasePrice: 150000},
{Tier: 3, Name: "Comfortable", Description: "Walls that don't leak. A door that locks. Luxuries of the modern age.", BasePrice: 300000},
{Tier: 4, Name: "Established", Description: "This is a house that says things about you. Good things. Mostly.", BasePrice: 600000},
}
// houseTierByTier returns the tier definition for a given tier number, or nil.
func houseTierByTier(tier int) *HouseTierDef {
for i := range houseTiers {
if houseTiers[i].Tier == tier {
return &houseTiers[i]
}
}
return nil
}
// houseNextTier returns the next purchasable tier definition, or nil if maxed.
func houseNextTier(currentTier int) *HouseTierDef {
for i := range houseTiers {
if houseTiers[i].Tier == currentTier+1 {
return &houseTiers[i]
}
}
return nil
}
// houseClosingCosts returns 5% closing + 6% realtor fees = 11% of base price.
func houseClosingCosts(basePrice int) int {
return int(float64(basePrice) * 0.11)
}
// houseTotalCost returns base price + closing costs + realtor fees.
func houseTotalCost(basePrice int) int {
return basePrice + houseClosingCosts(basePrice)
}
// houseWeeklyPayment calculates weekly payment based on balance and rate.
// Uses a simple amortization: interest + fixed principal portion.
// Rate is annual percentage (e.g., 6.5 means 6.5%).
func houseWeeklyPayment(balance int, annualRate float64) int {
if balance <= 0 || annualRate <= 0 {
return 0
}
weeklyRate := annualRate / 100.0 / 52.0
interest := float64(balance) * weeklyRate
// Principal portion: 0.5% of balance per week (~26% of balance per year)
// Ensures loans pay off in roughly 2-4 years of active play.
principal := float64(balance) * 0.005
payment := int(interest + principal)
if payment < 1 {
payment = 1
}
return payment
}
// houseMissedPenalty returns the penalty for a missed payment (5% of weekly payment).
func houseMissedPenalty(weeklyPayment int) int {
penalty := int(float64(weeklyPayment) * 0.05)
if penalty < 1 {
penalty = 1
}
return penalty
}
// ── Pending Interaction Types ──────────────────────────────────────────────
type advPendingHouseConfirm struct {
Tier int
TotalCost int
DownPayment int
}
type advPendingHouseDownPayment struct {
Tier int
TotalCost int
}
type advPendingHouseAutopay struct{}
type advPendingPetArrival struct{}
type advPendingPetType struct{}
type advPendingPetName struct {
PetType string
}
// ── Thom Krooke Greeting ───────────────────────────────────────────────────
var thomGreetingsPreAdoption = []string{
"Welcome in! Great timing. I've got something perfect for you. I say that to everyone and I mean it every time.",
"You don't look like someone ready to make a smart investment. I can work with that. Sit down. Don't touch anything.",
"Thom Krooke, Krooke Realty. No relation to that other guy. Mostly.",
}
var thomGreetingsPostAdoption = []string{
"You're back. Good. I've got things.",
"Oh. You. Sure. What do you need.",
"Come in. Wipe your feet. How's the animal.",
}
var thomGreetingsPostLevel10 = []string{
"Hello, {pet_name}'s caretaker. The usual?",
"Ah. {pet_name}'s caretaker. I've been expecting you. I have things {pet_name} needs.",
"Come in, {pet_name} and the caretaker! Wipe your feet! How are things? *You open your mouth to answer but quickly notice Thom is speaking to your pet.*",
}
var thomHouseSellingLines = []string{
"Charming property. Full of character. 'Character' means different things to different people and that's what makes real estate exciting.",
"Minor structural considerations that a positive attitude will absolutely address. Price reflects the opportunity.",
"Previous owner left in a hurry. Didn't say why. The chalk outlines in the living room are almost certainly decorative. Kids do that now apparently.",
}
const thomAnimalLine = "I heard an animal got in your house. Luckily for you, it's a sweet one that will give you the world in exchange for a tiny bit of kindness. ...that's what I heard anyway."
var thomMissedPaymentNoPet = "Missed payment. Penalty applied. Balance is now €%d. Sort it out."
var thomMissedPaymentPet = "Missed payment. Penalty applied. Balance is now €%d. %s doesn't need to know about this. Keep it together."
var thomFreezeNoPet = "Ten missed payments. I've stopped trying for now. The house is yours. The debt is yours. Come see me when you're ready to talk about it."
var thomFreezePet = "Ten missed payments. I've stopped trying for now. The house is yours. The debt is yours. Come see me when you're ready to talk about it. %s is fine. You should work on being fine."
var thomPaidOffNoPet = "Paid off. Noted. The next tier is available when you're ready. I'll be here."
var thomPaidOffPet = "Paid off. Good. Come in when you're ready for the next tier. I've been thinking about what %s would need in a better space. I have thoughts."
// thomGreeting returns an appropriate greeting based on pet state.
func thomGreeting(char *AdventureCharacter) string {
if char.HasPet() && char.PetLevel >= 10 {
line := thomGreetingsPostLevel10[rand.IntN(len(thomGreetingsPostLevel10))]
return strings.ReplaceAll(line, "{pet_name}", char.PetName)
}
if char.HasPet() {
return thomGreetingsPostAdoption[rand.IntN(len(thomGreetingsPostAdoption))]
}
return thomGreetingsPreAdoption[rand.IntN(len(thomGreetingsPreAdoption))]
}
// ── Thom Shop Display ──────────────────────────────────────────────────────
func thomShopView(char *AdventureCharacter, balance float64) string {
var sb strings.Builder
sb.WriteString("🏠 **Krooke Realty**\n")
sb.WriteString(fmt.Sprintf("💰 Balance: €%.0f\n\n", balance))
sb.WriteString(fmt.Sprintf("*%s*\n\n", thomGreeting(char)))
// Check for animal line (fires once after pet appears)
if char.HasPet() && !char.ThomAnimalLineFired {
sb.WriteString(fmt.Sprintf("*%s*\n\n", thomAnimalLine))
}
// Current housing status
if !char.HasHouse() {
sb.WriteString("You don't own a house yet.\n\n")
} else {
tierDef := houseTierByTier(char.HouseTier)
tierName := "Unknown"
if tierDef != nil {
tierName = tierDef.Name
}
sb.WriteString(fmt.Sprintf("🏡 Your house: **%s** (Tier %d)\n", tierName, char.HouseTier))
if char.HouseLoanBalance > 0 {
weekly := houseWeeklyPayment(char.HouseLoanBalance, char.HouseCurrentRate)
if char.HouseAutopay {
weekly = int(float64(weekly) * 0.98) // 2% autopay discount
}
sb.WriteString(fmt.Sprintf("💳 Loan balance: €%d | Weekly: €%d", char.HouseLoanBalance, weekly))
if char.HouseAutopay {
sb.WriteString(" (autopay)")
}
if char.HouseLoanFrozen {
sb.WriteString(" ❄️ FROZEN")
}
sb.WriteString(fmt.Sprintf(" | Rate: %.2f%%\n", char.HouseCurrentRate))
}
sb.WriteString("\n")
}
// Available upgrades
if char.HouseLoanBalance > 0 {
sb.WriteString("Pay off your current loan before upgrading.\n")
sb.WriteString(fmt.Sprintf("\nTo pay off early: `!thom payoff`\n"))
if !char.HouseAutopay {
sb.WriteString("To enable autopay (2% discount): `!thom autopay`\n")
}
} else {
// Can purchase next tier
nextDef := houseNextTier(char.HouseTier)
if nextDef == nil {
sb.WriteString("✨ Max tier reached. There is nothing left to sell you. This has never happened before.\n")
} else {
def := *nextDef
total := houseTotalCost(def.BasePrice)
selling := thomHouseSellingLines[rand.IntN(len(thomHouseSellingLines))]
sb.WriteString(fmt.Sprintf("*%s*\n\n", selling))
sb.WriteString(fmt.Sprintf("📋 **%s** (Tier %d)\n", def.Name, def.Tier))
sb.WriteString(fmt.Sprintf(" Base price: €%d\n", def.BasePrice))
sb.WriteString(fmt.Sprintf(" Closing costs (5%%): €%d\n", int(float64(def.BasePrice)*0.05)))
sb.WriteString(fmt.Sprintf(" Realtor fees (6%%): €%d\n", int(float64(def.BasePrice)*0.06)))
sb.WriteString(fmt.Sprintf(" **Total: €%d**\n\n", total))
sb.WriteString(fmt.Sprintf("Reply `buy` to purchase, or `buy <amount>` for a down payment.\n"))
}
}
// Pet supply shop (unlocks 1 week after pet level 10)
if char.PetSupplyShopUnlocked && char.HasPet() {
sb.WriteString("\n---\n")
sb.WriteString(fmt.Sprintf("🐾 **Pet Supplies** — for %s\n\n", char.PetName))
sb.WriteString(petArmorShopView(char))
}
return sb.String()
}
// ── Thom Shop Command Handler ──────────────────────────────────────────────
func (p *AdventurePlugin) handleThomCmd(ctx MessageContext) error {
args := strings.TrimSpace(p.GetArgs(ctx.Body, "thom"))
char, _, err := p.ensureCharacter(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "Failed to load your character.")
}
balance := p.euro.GetBalance(ctx.Sender)
lower := strings.ToLower(args)
switch {
case args == "" || lower == "shop":
text := thomShopView(char, balance)
// Mark animal line as fired if applicable
if char.HasPet() && !char.ThomAnimalLineFired {
userMu := p.advUserLock(ctx.Sender)
userMu.Lock()
char.ThomAnimalLineFired = true
_ = saveAdvCharacter(char)
userMu.Unlock()
}
return p.SendDM(ctx.Sender, text)
case lower == "payoff":
return p.handleThomPayoff(ctx, char)
case strings.HasPrefix(lower, "pay "):
return p.handleThomPay(ctx, char, strings.TrimSpace(lower[4:]))
case lower == "autopay":
return p.handleThomAutopay(ctx, char)
case lower == "buy" || strings.HasPrefix(lower, "buy "):
return p.handleThomBuy(ctx, char, balance, strings.TrimSpace(strings.TrimPrefix(lower, "buy")))
case strings.HasPrefix(lower, "petbuy "):
return p.handlePetArmorBuy(ctx, char, strings.TrimSpace(args[7:]))
default:
return p.SendDM(ctx.Sender, "Unknown command. Type `!thom` to visit Krooke Realty.")
}
}
// ── Buy House ──────────────────────────────────────────────────────────────
func (p *AdventurePlugin) handleThomBuy(ctx MessageContext, char *AdventureCharacter, balance float64, downPaymentStr string) error {
userMu := p.advUserLock(ctx.Sender)
userMu.Lock()
defer userMu.Unlock()
// Reload fresh
char, _, err := p.ensureCharacter(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "Failed to load your character.")
}
balance = p.euro.GetBalance(ctx.Sender)
if char.HouseLoanBalance > 0 {
return p.SendDM(ctx.Sender, "You still have an outstanding loan. Pay it off first with `!thom payoff`.")
}
nextDef := houseNextTier(char.HouseTier)
if nextDef == nil {
return p.SendDM(ctx.Sender, "You've reached max tier. There is nothing left to sell you.")
}
def := *nextDef
totalCost := houseTotalCost(def.BasePrice)
// Parse optional down payment
downPayment := 0
if downPaymentStr != "" {
dp := 0
for _, c := range downPaymentStr {
if c < '0' || c > '9' {
return p.SendDM(ctx.Sender, "Down payment must be a number. Example: `!thom buy 10000`")
}
dp = dp*10 + int(c-'0')
}
downPayment = dp
}
if downPayment > 0 && float64(downPayment) > balance {
return p.SendDM(ctx.Sender, fmt.Sprintf("You can't afford a €%d down payment. Balance: €%.0f.", downPayment, balance))
}
if downPayment >= totalCost {
// Full purchase, no loan
if !p.euro.Debit(ctx.Sender, float64(totalCost), "house_purchase_full") {
return p.SendDM(ctx.Sender, "Transaction failed.")
}
char.HouseTier = def.Tier
if err := saveAdvCharacter(char); err != nil {
p.euro.Credit(ctx.Sender, float64(totalCost), "house_purchase_refund")
return p.SendDM(ctx.Sender, "Failed to save. You've been refunded.")
}
return p.SendDM(ctx.Sender, fmt.Sprintf("🏠 **%s** purchased outright for €%d.\n\nNo loan. No weekly payments. Thom is visibly disappointed.", def.Name, totalCost))
}
// Loan purchase
loanAmount := totalCost - downPayment
// Get current mortgage rate
rate := getCurrentMortgageRate()
if rate <= 0 {
rate = 6.5 // fallback default
}
// Debit down payment if any
if downPayment > 0 {
if !p.euro.Debit(ctx.Sender, float64(downPayment), "house_down_payment") {
return p.SendDM(ctx.Sender, "Transaction failed.")
}
}
char.HouseTier = def.Tier
char.HouseLoanBalance = loanAmount
char.HouseCurrentRate = rate
char.HouseMissedPayments = 0
char.HouseLoanFrozen = false
if err := saveAdvCharacter(char); err != nil {
if downPayment > 0 {
p.euro.Credit(ctx.Sender, float64(downPayment), "house_dp_refund")
}
return p.SendDM(ctx.Sender, "Failed to save. You've been refunded.")
}
weekly := houseWeeklyPayment(loanAmount, rate)
var sb strings.Builder
sb.WriteString(fmt.Sprintf("🏠 **%s** purchased!\n\n", def.Name))
if downPayment > 0 {
sb.WriteString(fmt.Sprintf("Down payment: €%d\n", downPayment))
}
sb.WriteString(fmt.Sprintf("Loan balance: €%d\n", loanAmount))
sb.WriteString(fmt.Sprintf("Weekly payment: ~€%d (%.2f%% ARM rate)\n", weekly, rate))
sb.WriteString("\nPayments are collected weekly. Enable autopay for a 2% discount: `!thom autopay`")
return p.SendDM(ctx.Sender, sb.String())
}
// ── Payoff ─────────────────────────────────────────────────────────────────
func (p *AdventurePlugin) handleThomPayoff(ctx MessageContext, char *AdventureCharacter) error {
userMu := p.advUserLock(ctx.Sender)
userMu.Lock()
defer userMu.Unlock()
// Reload fresh
char, _, err := p.ensureCharacter(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "Failed to load your character.")
}
if char.HouseLoanBalance <= 0 {
return p.SendDM(ctx.Sender, "You don't have an outstanding loan.")
}
balance := p.euro.GetBalance(ctx.Sender)
if balance < float64(char.HouseLoanBalance) {
return p.SendDM(ctx.Sender, fmt.Sprintf("You need €%d to pay off the loan. Balance: €%.0f.", char.HouseLoanBalance, balance))
}
payoffAmount := float64(char.HouseLoanBalance)
if !p.euro.Debit(ctx.Sender, payoffAmount, "house_payoff") {
return p.SendDM(ctx.Sender, "Transaction failed.")
}
char.HouseLoanBalance = 0
char.HouseLoanFrozen = false
char.HouseMissedPayments = 0
if err := saveAdvCharacter(char); err != nil {
p.euro.Credit(ctx.Sender, payoffAmount, "house_payoff_refund")
return p.SendDM(ctx.Sender, "Failed to save. Your money has been refunded.")
}
if char.HasPet() {
return p.SendDM(ctx.Sender, fmt.Sprintf("🏠 %s", fmt.Sprintf(thomPaidOffPet, char.PetName)))
}
return p.SendDM(ctx.Sender, fmt.Sprintf("🏠 %s", thomPaidOffNoPet))
}
// ── Extra Payment ─────────────────────────────────────────────────────────
func (p *AdventurePlugin) handleThomPay(ctx MessageContext, char *AdventureCharacter, amountStr string) error {
userMu := p.advUserLock(ctx.Sender)
userMu.Lock()
defer userMu.Unlock()
char, _, err := p.ensureCharacter(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "Failed to load your character.")
}
if char.HouseLoanBalance <= 0 {
return p.SendDM(ctx.Sender, "You don't have an outstanding loan.")
}
amount, err := strconv.Atoi(amountStr)
if err != nil || amount <= 0 {
return p.SendDM(ctx.Sender, "Usage: `!thom pay <amount>` — e.g. `!thom pay 5000`")
}
// Cap at remaining balance
if amount > char.HouseLoanBalance {
amount = char.HouseLoanBalance
}
balance := p.euro.GetBalance(ctx.Sender)
if balance < float64(amount) {
return p.SendDM(ctx.Sender, fmt.Sprintf("You need €%d but only have €%.0f.", amount, balance))
}
if !p.euro.Debit(ctx.Sender, float64(amount), "house_extra_payment") {
return p.SendDM(ctx.Sender, "Transaction failed.")
}
char.HouseLoanBalance -= amount
if char.HouseLoanBalance <= 0 {
char.HouseLoanBalance = 0
char.HouseLoanFrozen = false
char.HouseMissedPayments = 0
}
if err := saveAdvCharacter(char); err != nil {
p.euro.Credit(ctx.Sender, float64(amount), "house_extra_payment_refund")
return p.SendDM(ctx.Sender, "Failed to save. Your money has been refunded.")
}
if char.HouseLoanBalance == 0 {
if char.HasPet() {
return p.SendDM(ctx.Sender, fmt.Sprintf("🏠 Payment of €%d received. %s", amount, fmt.Sprintf(thomPaidOffPet, char.PetName)))
}
return p.SendDM(ctx.Sender, fmt.Sprintf("🏠 Payment of €%d received. %s", amount, thomPaidOffNoPet))
}
return p.SendDM(ctx.Sender, fmt.Sprintf("🏠 Payment of €%d received. Remaining balance: €%d.", amount, char.HouseLoanBalance))
}
// ── Autopay Toggle ─────────────────────────────────────────────────────────
func (p *AdventurePlugin) handleThomAutopay(ctx MessageContext, char *AdventureCharacter) error {
userMu := p.advUserLock(ctx.Sender)
userMu.Lock()
defer userMu.Unlock()
char, _, err := p.ensureCharacter(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "Failed to load your character.")
}
if char.HouseLoanBalance <= 0 {
return p.SendDM(ctx.Sender, "You don't have an outstanding loan.")
}
char.HouseAutopay = !char.HouseAutopay
if err := saveAdvCharacter(char); err != nil {
return p.SendDM(ctx.Sender, "Failed to save.")
}
if char.HouseAutopay {
return p.SendDM(ctx.Sender, "✅ Autopay enabled. 2% discount on weekly payments. Thom calls this a favour. It is not.")
}
return p.SendDM(ctx.Sender, "Autopay disabled.")
}
// ── Weekly Mortgage Payment Processing ─────────────────────────────────────
func (p *AdventurePlugin) processMortgagePayments() {
chars, err := loadAllAdvCharacters()
if err != nil {
slog.Error("mortgage: failed to load characters", "err", err)
return
}
for _, char := range chars {
if char.HouseLoanBalance <= 0 {
continue
}
if char.HouseLoanFrozen {
continue
}
userMu := p.advUserLock(char.UserID)
userMu.Lock()
// Reload fresh inside lock
freshChar, err := loadAdvCharacter(char.UserID)
if err != nil || freshChar.HouseLoanBalance <= 0 || freshChar.HouseLoanFrozen {
userMu.Unlock()
continue
}
weekly := houseWeeklyPayment(freshChar.HouseLoanBalance, freshChar.HouseCurrentRate)
if freshChar.HouseAutopay {
weekly = int(float64(weekly) * 0.98)
}
if p.euro.Debit(freshChar.UserID, float64(weekly), "mortgage_weekly") {
freshChar.HouseLoanBalance -= weekly
if freshChar.HouseLoanBalance <= 0 {
freshChar.HouseLoanBalance = 0
freshChar.HouseMissedPayments = 0
_ = saveAdvCharacter(freshChar)
userMu.Unlock()
// Paid off!
if freshChar.HasPet() {
_ = p.SendDM(freshChar.UserID, fmt.Sprintf("🏠 %s", fmt.Sprintf(thomPaidOffPet, freshChar.PetName)))
} else {
_ = p.SendDM(freshChar.UserID, fmt.Sprintf("🏠 %s", thomPaidOffNoPet))
}
continue
}
freshChar.HouseMissedPayments = 0
_ = saveAdvCharacter(freshChar)
userMu.Unlock()
} else {
// Missed payment
penalty := houseMissedPenalty(weekly)
freshChar.HouseLoanBalance += penalty
freshChar.HouseMissedPayments++
if freshChar.HouseMissedPayments >= 10 {
freshChar.HouseLoanFrozen = true
_ = saveAdvCharacter(freshChar)
userMu.Unlock()
if freshChar.HasPet() {
_ = p.SendDM(freshChar.UserID, fmt.Sprintf("🏠 %s", fmt.Sprintf(thomFreezePet, freshChar.PetName)))
} else {
_ = p.SendDM(freshChar.UserID, fmt.Sprintf("🏠 %s", thomFreezeNoPet))
}
} else {
_ = saveAdvCharacter(freshChar)
userMu.Unlock()
if freshChar.HasPet() {
_ = p.SendDM(freshChar.UserID, fmt.Sprintf("🏠 %s", fmt.Sprintf(thomMissedPaymentPet, freshChar.HouseLoanBalance, freshChar.PetName)))
} else {
_ = p.SendDM(freshChar.UserID, fmt.Sprintf("🏠 %s", fmt.Sprintf(thomMissedPaymentNoPet, freshChar.HouseLoanBalance)))
}
}
}
}
}
// ── Mortgage Rate Change DMs ───────────────────────────────────────────────
func (p *AdventurePlugin) sendMortgageRateChangeDMs(oldRate, newRate float64) {
if oldRate == newRate {
return
}
chars, err := loadAllAdvCharacters()
if err != nil {
slog.Error("mortgage: failed to load characters for rate DMs", "err", err)
return
}
increased := newRate > oldRate
for _, char := range chars {
if char.HouseLoanBalance <= 0 {
continue
}
// Update rate
userMu := p.advUserLock(char.UserID)
userMu.Lock()
freshChar, err := loadAdvCharacter(char.UserID)
if err != nil || freshChar.HouseLoanBalance <= 0 {
userMu.Unlock()
continue
}
freshChar.HouseCurrentRate = newRate
_ = saveAdvCharacter(freshChar)
userMu.Unlock()
newWeekly := houseWeeklyPayment(freshChar.HouseLoanBalance, newRate)
amount := fmt.Sprintf("%d", newWeekly)
var pool []string
if freshChar.HasPet() {
if increased {
pool = ThomRateIncreasePet
} else {
pool = ThomRateDecreasePet
}
} else {
if increased {
pool = ThomRateIncrease
} else {
pool = ThomRateDecrease
}
}
line := pool[rand.IntN(len(pool))]
line = strings.ReplaceAll(line, "{amount}", amount)
if freshChar.HasPet() {
line = strings.ReplaceAll(line, "{pet_name}", freshChar.PetName)
}
_ = p.SendDM(char.UserID, fmt.Sprintf("🏠 %s", line))
// Jitter
time.Sleep(time.Duration(500+rand.IntN(1500)) * time.Millisecond)
}
}
// ── Pet Armor Shop ─────────────────────────────────────────────────────────
type PetArmorDef struct {
Tier int
Name string
DeflectBonus float64
Price int
}
var petDogArmor = []PetArmorDef{
{Tier: 1, Name: "Leather Dog Barding", DeflectBonus: 0.015, Price: 225},
{Tier: 2, Name: "Chain Dog Barding", DeflectBonus: 0.03, Price: 675},
{Tier: 3, Name: "Plate Dog Barding", DeflectBonus: 0.05, Price: 2250},
{Tier: 4, Name: "Enchanted Dog Barding", DeflectBonus: 0.075, Price: 11250},
{Tier: 5, Name: "Dragonscale Dog Barding", DeflectBonus: 0.105, Price: 45000},
}
var petCatArmor = []PetArmorDef{
{Tier: 1, Name: "Leather Cat Armor", DeflectBonus: 0.015, Price: 225},
{Tier: 2, Name: "Chain Cat Armor", DeflectBonus: 0.03, Price: 675},
{Tier: 3, Name: "Plate Cat Armor", DeflectBonus: 0.05, Price: 2250},
{Tier: 4, Name: "Enchanted Cat Armor", DeflectBonus: 0.075, Price: 11250},
{Tier: 5, Name: "Dragonscale Cat Armor", DeflectBonus: 0.105, Price: 45000},
}
func petArmorDefs(petType string) []PetArmorDef {
if petType == "dog" {
return petDogArmor
}
return petCatArmor
}
func petArmorShopView(char *AdventureCharacter) string {
var sb strings.Builder
defs := petArmorDefs(char.PetType)
if char.PetArmorTier >= 5 {
sb.WriteString("✨ Max pet armor tier reached. There is nothing left to buy.\n")
return sb.String()
}
for _, def := range defs {
if def.Tier <= char.PetArmorTier {
if def.Tier == char.PetArmorTier {
sb.WriteString(fmt.Sprintf("🟢 %s (T%d) — Currently equipped\n", def.Name, def.Tier))
}
continue
}
indicator := "⬆️"
sb.WriteString(fmt.Sprintf("%s %s (T%d) — €%d — +%.1f%% deflect\n", indicator, def.Name, def.Tier, def.Price, def.DeflectBonus*100))
}
sb.WriteString(fmt.Sprintf("\nTo buy: `!thom petbuy <tier>` (e.g., `!thom petbuy %d`)", char.PetArmorTier+1))
return sb.String()
}
func (p *AdventurePlugin) handlePetArmorBuy(ctx MessageContext, char *AdventureCharacter, tierStr string) error {
userMu := p.advUserLock(ctx.Sender)
userMu.Lock()
defer userMu.Unlock()
char, _, err := p.ensureCharacter(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "Failed to load your character.")
}
if !char.HasPet() {
return p.SendDM(ctx.Sender, "You don't have a pet.")
}
if !char.PetSupplyShopUnlocked {
return p.SendDM(ctx.Sender, "Pet supplies aren't available yet.")
}
tier := 0
for _, c := range tierStr {
if c < '0' || c > '9' {
return p.SendDM(ctx.Sender, "Usage: `!thom petbuy <tier>`")
}
tier = tier*10 + int(c-'0')
}
if tier != char.PetArmorTier+1 {
if tier <= char.PetArmorTier {
return p.SendDM(ctx.Sender, "That's not an upgrade.")
}
return p.SendDM(ctx.Sender, fmt.Sprintf("You need to buy tier %d first.", char.PetArmorTier+1))
}
defs := petArmorDefs(char.PetType)
var def *PetArmorDef
for i := range defs {
if defs[i].Tier == tier {
def = &defs[i]
break
}
}
if def == nil {
return p.SendDM(ctx.Sender, "Invalid tier.")
}
balance := p.euro.GetBalance(ctx.Sender)
if balance < float64(def.Price) {
return p.SendDM(ctx.Sender, fmt.Sprintf("%s deserves better, but you can't afford €%d. Balance: €%.0f.", char.PetName, def.Price, balance))
}
if !p.euro.Debit(ctx.Sender, float64(def.Price), "pet_armor_"+tierStr) {
return p.SendDM(ctx.Sender, "Transaction failed.")
}
char.PetArmorTier = tier
if err := saveAdvCharacter(char); err != nil {
p.euro.Credit(ctx.Sender, float64(def.Price), "pet_armor_refund")
return p.SendDM(ctx.Sender, "Failed to save. Refunded.")
}
return p.SendDM(ctx.Sender, fmt.Sprintf("🐾 **%s** equipped on %s.\n\n+%.1f%% deflect bonus. %s looks... objectively better than you.", def.Name, char.PetName, def.DeflectBonus*100, char.PetName))
}

View File

@@ -0,0 +1,375 @@
package plugin
import (
"strings"
"testing"
)
// ── Housing Tier Definitions ───────────────────────────────────────────────
func TestHouseTierByTier_Valid(t *testing.T) {
for _, def := range houseTiers {
got := houseTierByTier(def.Tier)
if got == nil {
t.Errorf("houseTierByTier(%d) returned nil", def.Tier)
continue
}
if got.Tier != def.Tier {
t.Errorf("houseTierByTier(%d).Tier = %d", def.Tier, got.Tier)
}
}
}
func TestHouseTierByTier_Invalid(t *testing.T) {
if houseTierByTier(0) != nil {
t.Error("tier 0 (no house) should return nil")
}
if houseTierByTier(99) != nil {
t.Error("tier 99 should return nil")
}
}
func TestHouseNextTier(t *testing.T) {
// From no house (tier 0) → tier 1
def := houseNextTier(0)
if def == nil || def.Tier != 1 {
t.Error("next tier from 0 should be 1")
}
// From tier 1 → tier 2
def = houseNextTier(1)
if def == nil || def.Tier != 2 {
t.Error("next tier from 1 should be 2")
}
// From max tier → nil
def = houseNextTier(4)
if def != nil {
t.Error("next tier from max should be nil")
}
}
// ── Closing Costs ──────────────────────────────────────────────────────────
func TestHouseClosingCosts(t *testing.T) {
// 11% of 75000 = 8250
costs := houseClosingCosts(75000)
if costs != 8250 {
t.Errorf("closing costs on 75000: got %d, want 8250", costs)
}
}
func TestHouseTotalCost(t *testing.T) {
// 75000 + 11% = 83250
total := houseTotalCost(75000)
if total != 83250 {
t.Errorf("total cost on 75000: got %d, want 83250", total)
}
}
// ── Weekly Payment ─────────────────────────────────────────────────────────
func TestHouseWeeklyPayment_Normal(t *testing.T) {
// 100000 balance at 6.5%: interest = 100000*0.065/52 ≈ 125, principal = 100000*0.005 = 500
// Total ≈ 625
payment := houseWeeklyPayment(100000, 6.5)
if payment < 600 || payment > 650 {
t.Errorf("weekly payment on 100k at 6.5%%: got %d, want ~625", payment)
}
}
func TestHouseWeeklyPayment_ZeroBalance(t *testing.T) {
if houseWeeklyPayment(0, 6.5) != 0 {
t.Error("zero balance should produce zero payment")
}
}
func TestHouseWeeklyPayment_ZeroRate(t *testing.T) {
if houseWeeklyPayment(100000, 0) != 0 {
t.Error("zero rate should produce zero payment")
}
}
func TestHouseWeeklyPayment_MinimumOne(t *testing.T) {
// Very small balance should still yield at least 1
payment := houseWeeklyPayment(1, 0.01)
if payment < 1 {
t.Errorf("tiny balance should produce at least 1, got %d", payment)
}
}
// ── Missed Payment Penalty ─────────────────────────────────────────────────
func TestHouseMissedPenalty(t *testing.T) {
// 5% of 625 = 31
penalty := houseMissedPenalty(625)
if penalty != 31 {
t.Errorf("missed penalty on 625 weekly: got %d, want 31", penalty)
}
}
func TestHouseMissedPenalty_Minimum(t *testing.T) {
// Tiny payment should still yield at least 1
penalty := houseMissedPenalty(1)
if penalty < 1 {
t.Errorf("penalty should be at least 1, got %d", penalty)
}
}
// ── Character Helper Methods ───────────────────────────────────────────────
func TestHasHouse(t *testing.T) {
tests := []struct {
name string
tier int
loan int
expected bool
}{
{"no house", 0, 0, false},
{"base house", 1, 0, true},
{"loan only", 0, 50000, true},
{"livable with loan", 2, 100000, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
char := &AdventureCharacter{HouseTier: tt.tier, HouseLoanBalance: tt.loan}
if char.HasHouse() != tt.expected {
t.Errorf("HasHouse() = %v, want %v", char.HasHouse(), tt.expected)
}
})
}
}
func TestHouseHPBonus(t *testing.T) {
tests := []struct {
tier int
expected float64
}{
{0, 0},
{1, 0}, // Base house — no bonus
{2, 0.05}, // Livable
{3, 0.12}, // Comfortable
{4, 0.20}, // Established
}
for _, tt := range tests {
char := &AdventureCharacter{HouseTier: tt.tier}
if bonus := char.HouseHPBonus(); bonus != tt.expected {
t.Errorf("tier %d: HouseHPBonus() = %f, want %f", tt.tier, bonus, tt.expected)
}
}
}
// ── Thom Greeting ──────────────────────────────────────────────────────────
func TestThomGreeting_PreAdoption(t *testing.T) {
char := &AdventureCharacter{}
greeting := thomGreeting(char)
found := false
for _, g := range thomGreetingsPreAdoption {
if greeting == g {
found = true
break
}
}
if !found {
t.Errorf("pre-adoption greeting not from expected pool: %q", greeting)
}
}
func TestThomGreeting_PostAdoption(t *testing.T) {
char := &AdventureCharacter{PetType: "dog", PetName: "Rex", PetArrived: true, PetLevel: 5}
greeting := thomGreeting(char)
found := false
for _, g := range thomGreetingsPostAdoption {
if greeting == g {
found = true
break
}
}
if !found {
t.Errorf("post-adoption greeting not from expected pool: %q", greeting)
}
}
func TestThomGreeting_PostLevel10(t *testing.T) {
char := &AdventureCharacter{PetType: "cat", PetName: "Whiskers", PetArrived: true, PetLevel: 10}
greeting := thomGreeting(char)
if !strings.Contains(greeting, "Whiskers") {
t.Errorf("post-level-10 greeting should contain pet name, got: %q", greeting)
}
}
// ── Thom Shop View ─────────────────────────────────────────────────────────
func TestThomShopView_NoHouse(t *testing.T) {
char := &AdventureCharacter{}
text := thomShopView(char, 100000)
if !strings.Contains(text, "Krooke Realty") {
t.Error("should contain shop name")
}
if !strings.Contains(text, "don't own a house") {
t.Error("should mention no house")
}
if !strings.Contains(text, "Base House") {
t.Error("should show first tier available")
}
if !strings.Contains(text, "€75000") {
t.Error("should show base price")
}
}
func TestThomShopView_WithLoan(t *testing.T) {
char := &AdventureCharacter{HouseTier: 1, HouseLoanBalance: 50000, HouseCurrentRate: 6.5}
text := thomShopView(char, 10000)
if !strings.Contains(text, "Loan balance") {
t.Error("should show loan balance")
}
if !strings.Contains(text, "Pay off") {
t.Error("should mention paying off loan")
}
}
func TestThomShopView_MaxTier(t *testing.T) {
char := &AdventureCharacter{HouseTier: 4} // max tier
text := thomShopView(char, 999999)
if !strings.Contains(text, "Max tier") {
t.Error("should mention max tier reached")
}
}
func TestThomShopView_Autopay(t *testing.T) {
char := &AdventureCharacter{HouseTier: 1, HouseLoanBalance: 50000, HouseCurrentRate: 6.5, HouseAutopay: true}
text := thomShopView(char, 10000)
if !strings.Contains(text, "autopay") {
t.Error("should show autopay status")
}
}
func TestThomShopView_Frozen(t *testing.T) {
char := &AdventureCharacter{HouseTier: 1, HouseLoanBalance: 50000, HouseCurrentRate: 6.5, HouseLoanFrozen: true}
text := thomShopView(char, 10000)
if !strings.Contains(text, "FROZEN") {
t.Error("should show frozen status")
}
}
// ── Pet Armor ──────────────────────────────────────────────────────────────
func TestPetArmorDefs_BothTypes(t *testing.T) {
dogDefs := petArmorDefs("dog")
catDefs := petArmorDefs("cat")
if len(dogDefs) != 5 {
t.Errorf("dog armor: got %d tiers, want 5", len(dogDefs))
}
if len(catDefs) != 5 {
t.Errorf("cat armor: got %d tiers, want 5", len(catDefs))
}
// Verify tiers are sequential 1-5
for i, def := range dogDefs {
if def.Tier != i+1 {
t.Errorf("dog armor[%d].Tier = %d, want %d", i, def.Tier, i+1)
}
}
// Deflect bonuses should increase with tier
for i := 1; i < len(dogDefs); i++ {
if dogDefs[i].DeflectBonus <= dogDefs[i-1].DeflectBonus {
t.Errorf("dog armor tier %d deflect bonus should exceed tier %d", dogDefs[i].Tier, dogDefs[i-1].Tier)
}
}
}
func TestPetArmorShopView_HasUpgrades(t *testing.T) {
char := &AdventureCharacter{PetType: "dog", PetName: "Rex", PetArrived: true, PetArmorTier: 0}
text := petArmorShopView(char)
if !strings.Contains(text, "⬆️") {
t.Error("should show upgrade indicators")
}
if !strings.Contains(text, "petbuy") {
t.Error("should show buy instructions")
}
}
func TestPetArmorShopView_MaxTier(t *testing.T) {
char := &AdventureCharacter{PetType: "cat", PetName: "Luna", PetArrived: true, PetArmorTier: 5}
text := petArmorShopView(char)
if !strings.Contains(text, "Max pet armor") {
t.Error("should show max tier message")
}
}
// ── Flavor Pool Coverage ───────────────────────────────────────────────────
func TestThomFlavorPools_NonEmpty(t *testing.T) {
pools := map[string][]string{
"thomGreetingsPreAdoption": thomGreetingsPreAdoption,
"thomGreetingsPostAdoption": thomGreetingsPostAdoption,
"thomGreetingsPostLevel10": thomGreetingsPostLevel10,
"thomHouseSellingLines": thomHouseSellingLines,
"ThomRateIncrease": ThomRateIncrease,
"ThomRateDecrease": ThomRateDecrease,
"ThomRateIncreasePet": ThomRateIncreasePet,
"ThomRateDecreasePet": ThomRateDecreasePet,
}
for name, pool := range pools {
if len(pool) == 0 {
t.Errorf("flavor pool %s is empty", name)
}
}
}
func TestThomRateFlavor_Placeholders(t *testing.T) {
for i, line := range ThomRateIncrease {
if !strings.Contains(line, "{amount}") {
t.Errorf("ThomRateIncrease[%d] missing {amount} placeholder", i)
}
}
for i, line := range ThomRateDecrease {
if !strings.Contains(line, "{amount}") {
t.Errorf("ThomRateDecrease[%d] missing {amount} placeholder", i)
}
}
for i, line := range ThomRateIncreasePet {
if !strings.Contains(line, "{amount}") {
t.Errorf("ThomRateIncreasePet[%d] missing {amount} placeholder", i)
}
if !strings.Contains(line, "{pet_name}") {
t.Errorf("ThomRateIncreasePet[%d] missing {pet_name} placeholder", i)
}
}
for i, line := range ThomRateDecreasePet {
if !strings.Contains(line, "{amount}") {
t.Errorf("ThomRateDecreasePet[%d] missing {amount} placeholder", i)
}
if !strings.Contains(line, "{pet_name}") {
t.Errorf("ThomRateDecreasePet[%d] missing {pet_name} placeholder", i)
}
}
}
// ── House Tiers Sanity ─────────────────────────────────────────────────────
func TestHouseTiers_PricesIncreasing(t *testing.T) {
for i := 1; i < len(houseTiers); i++ {
if houseTiers[i].BasePrice <= houseTiers[i-1].BasePrice {
t.Errorf("tier %d price (%d) should exceed tier %d price (%d)",
houseTiers[i].Tier, houseTiers[i].BasePrice,
houseTiers[i-1].Tier, houseTiers[i-1].BasePrice)
}
}
}
func TestHouseTiers_SequentialTiers(t *testing.T) {
for i, def := range houseTiers {
if def.Tier != i+1 {
t.Errorf("houseTiers[%d].Tier = %d, want %d", i, def.Tier, i+1)
}
}
}

View File

@@ -0,0 +1,158 @@
package plugin
import (
"encoding/json"
"fmt"
"log/slog"
"net/http"
"os"
"strconv"
"time"
"gogobee/internal/db"
)
// ── FRED API Integration ───────────────────────────────────────────────────
//
// Pulls the 5/1 ARM rate weekly from FRED (series: MORTGAGE5US).
// Requires FRED_API_KEY environment variable.
// Falls back to a hardcoded default if the API is unavailable.
const fredSeries = "MORTGAGE5US"
const defaultMortgageRate = 6.5
const currentRateCacheKey = "mortgage_current_rate"
type fredResponse struct {
Observations []struct {
Date string `json:"date"`
Value string `json:"value"`
} `json:"observations"`
}
// fetchFREDRate pulls the latest 5/1 ARM rate from the FRED API.
func fetchFREDRate() (float64, error) {
apiKey := os.Getenv("FRED_API_KEY")
if apiKey == "" {
return 0, fmt.Errorf("FRED_API_KEY not set")
}
url := fmt.Sprintf(
"https://api.stlouisfed.org/fred/series/observations?series_id=%s&api_key=%s&file_type=json&sort_order=desc&limit=1",
fredSeries, apiKey,
)
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Get(url)
if err != nil {
return 0, fmt.Errorf("FRED API request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return 0, fmt.Errorf("FRED API status %d", resp.StatusCode)
}
var data fredResponse
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
return 0, fmt.Errorf("FRED JSON decode: %w", err)
}
if len(data.Observations) == 0 {
return 0, fmt.Errorf("FRED: no observations returned")
}
val := data.Observations[0].Value
if val == "." {
return 0, fmt.Errorf("FRED: value is placeholder")
}
rate, err := strconv.ParseFloat(val, 64)
if err != nil {
return 0, fmt.Errorf("FRED: parse rate %q: %w", val, err)
}
// Clamp to sane range
if rate < 1.0 {
rate = 1.0
}
if rate > 15.0 {
rate = 15.0
}
return rate, nil
}
// getCurrentMortgageRate returns the cached rate, or fetches fresh if stale (daily).
func getCurrentMortgageRate() float64 {
// Check DB cache (24h TTL)
if val := db.CacheGet(currentRateCacheKey, 24*3600); val != "" {
if rate, err := strconv.ParseFloat(val, 64); err == nil {
return rate
}
}
rate, err := fetchFREDRate()
if err != nil {
slog.Warn("mortgage: FRED fetch failed, using default", "err", err)
return defaultMortgageRate
}
db.CacheSet(currentRateCacheKey, strconv.FormatFloat(rate, 'f', 2, 64))
return rate
}
// ── Weekly Mortgage Ticker ─────────────────────────────────────────────────
func (p *AdventurePlugin) mortgageTicker() {
ticker := time.NewTicker(1 * time.Hour)
defer ticker.Stop()
for range ticker.C {
now := time.Now().UTC()
// Run on Mondays only (FRED updates Thursdays, we process Mondays)
if now.Weekday() != time.Monday {
continue
}
_, week := now.ISOWeek()
dateKey := fmt.Sprintf("%d-W%02d", now.Year(), week)
jobName := "mortgage_weekly"
if db.JobCompleted(jobName, dateKey) {
continue
}
slog.Info("mortgage: running weekly payment processing")
// Fetch fresh rate and compare with previous
newRate := getCurrentMortgageRate()
oldRate := p.getLastKnownRate()
if oldRate > 0 && oldRate != newRate {
p.sendMortgageRateChangeDMs(oldRate, newRate)
}
p.setLastKnownRate(newRate)
// Process payments
p.processMortgagePayments()
db.MarkJobCompleted(jobName, dateKey)
}
}
const lastKnownRateCacheKey = "mortgage_last_known_rate"
func (p *AdventurePlugin) getLastKnownRate() float64 {
val := db.CacheGet(lastKnownRateCacheKey, 365*24*3600) // 1 year TTL
if val == "" {
return 0
}
rate, err := strconv.ParseFloat(val, 64)
if err != nil {
return 0
}
return rate
}
func (p *AdventurePlugin) setLastKnownRate(rate float64) {
db.CacheSet(lastKnownRateCacheKey, strconv.FormatFloat(rate, 'f', 2, 64))
}

View File

@@ -133,6 +133,7 @@ func (p *AdventurePlugin) npcFireEncounter(userID id.UserID, npc 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)
@@ -222,11 +223,23 @@ func (p *AdventurePlugin) resolveMisty(ctx MessageContext, char *AdventureCharac
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))
}

View File

@@ -0,0 +1,448 @@
package plugin
import (
"fmt"
"log/slog"
"math/rand/v2"
"regexp"
"strings"
"time"
"maunium.net/go/mautrix/id"
)
// ── Pet XP & Leveling ──────────────────────────────────────────────────────
const petXPPerAction = 1.5
var petNameValid = regexp.MustCompile(`^[a-zA-Z0-9 '\-]+$`)
// petXPToNextLevel returns XP needed for a given pet level.
func petXPToNextLevel(level int) int {
switch {
case level < 3:
return 10
case level < 5:
return 20
case level < 8:
return 35
default:
return 50
}
}
// petGrantXP adds XP to the pet and handles level-ups. Returns true if leveled up.
func petGrantXP(char *AdventureCharacter) bool {
if !char.HasPet() || char.PetLevel >= 10 {
return false
}
char.PetXP += int(petXPPerAction * 100) // store as centixp for precision
leveled := false
for char.PetLevel < 10 {
needed := petXPToNextLevel(char.PetLevel) * 100
if char.PetXP < needed {
break
}
char.PetXP -= needed
char.PetLevel++
leveled = true
}
if char.PetLevel >= 10 && char.PetLevel10Date == "" {
char.PetLevel10Date = time.Now().UTC().Format("2006-01-02")
}
return leveled
}
// ── Pet Combat Action Probabilities ────────────────────────────────────────
// Base probabilities scale with pet level.
// Attack: 3% + 1.5% per level (max 18% at L10)
// Deflect: 2% + 1% per level (max 12% at L10) + armor bonus
// Ditch recovery: 5% + 2% per level (max 25% at L10)
func petAttackChance(level int) float64 {
return 0.03 + float64(level)*0.015
}
func petDeflectChance(level, armorTier int) float64 {
base := 0.02 + float64(level)*0.01
defs := petDogArmor // same bonuses for both types
for _, d := range defs {
if d.Tier == armorTier {
base += d.DeflectBonus
break
}
}
return base
}
func petDitchRecoveryChance(level int) float64 {
return 0.05 + float64(level)*0.02
}
// petDitchRecoveryTime returns the death timer based on pet level.
// Level 1: 5h, Level 5: ~3.5h, Level 10: 1h (linear interpolation).
func petDitchRecoveryTime(level int) time.Duration {
hours := 5.0 - float64(level-1)*4.0/9.0 // 5h at L1, 1h at L10
if hours < 1 {
hours = 1
}
return time.Duration(hours * float64(time.Hour))
}
// ── Pet Combat Results ─────────────────────────────────────────────────────
type PetCombatResult struct {
Attacked bool
AttackDamage int
AttackText string
Deflected bool
DeflectText string
DitchRecovery bool
DitchText string
VictoryText string
DeathText string
}
// petRollCombatActions rolls attack and deflect for a combat round.
func petRollCombatActions(char *AdventureCharacter, enemyName string) *PetCombatResult {
if !char.HasPet() {
return nil
}
result := &PetCombatResult{}
// Attack roll
if rand.Float64() < petAttackChance(char.PetLevel) {
result.Attacked = true
result.AttackDamage = 3 + rand.IntN(5) + char.PetLevel // 3-7 + level
var pool []string
if char.PetType == "dog" {
pool = PetDogAttack
} else {
pool = PetCatAttack
}
line := pool[rand.IntN(len(pool))]
line = strings.ReplaceAll(line, "{enemy}", enemyName)
line = strings.ReplaceAll(line, "{damage}", fmt.Sprintf("%d", result.AttackDamage))
result.AttackText = line
}
// Deflect roll
if rand.Float64() < petDeflectChance(char.PetLevel, char.PetArmorTier) {
result.Deflected = true
var pool []string
if char.PetType == "dog" {
pool = PetDogDeflect
} else {
pool = PetCatDeflect
}
line := pool[rand.IntN(len(pool))]
line = strings.ReplaceAll(line, "{enemy}", enemyName)
result.DeflectText = line
}
return result
}
// petRollDitchRecovery rolls for pet intervention on player death.
func petRollDitchRecovery(char *AdventureCharacter) bool {
if !char.HasPet() {
return false
}
return rand.Float64() < petDitchRecoveryChance(char.PetLevel)
}
// petVictoryText returns a random victory reaction line.
func petVictoryText(char *AdventureCharacter) string {
if !char.HasPet() {
return ""
}
var pool []string
if char.PetType == "dog" {
pool = PetDogVictory
} else {
pool = PetCatVictory
}
return pool[rand.IntN(len(pool))]
}
// petDeathText returns a random death reaction line.
func petDeathText(char *AdventureCharacter) string {
if !char.HasPet() {
return ""
}
var pool []string
if char.PetType == "dog" {
pool = PetDogDeath
} else {
pool = PetCatDeath
}
return pool[rand.IntN(len(pool))]
}
// ── Pet Arrival Flow ───────────────────────────────────────────────────────
// petShouldArrive checks if pet arrival should trigger.
// Fires randomly after Tier 1 house upgrade is complete.
func petShouldArrive(char *AdventureCharacter) bool {
if char.PetArrived {
return false
}
// Pet arrives after Tier 1 (Livable) upgrade = HouseTier >= 2
if char.HouseTier < 2 {
return false
}
// Pet was chased away and reactivated via Misty — allow re-arrival
if char.PetChasedAway {
return char.PetReactivated
}
// 15% daily chance after house tier 1
return rand.Float64() < 0.15
}
// petArrivalDM sends the initial "there's an animal in your house" DM.
func (p *AdventurePlugin) petArrivalDM(userID id.UserID) {
// Don't overwrite an existing pending interaction
if _, exists := p.pending.Load(string(userID)); exists {
return
}
text := "There's an animal in your house. It looks like a...\n\n" +
"🚪 Chase it away\n" +
"🍖 Feed it\n\n" +
"Reply with `chase` or `feed`."
p.pending.Store(string(userID), &advPendingInteraction{
Type: "pet_arrival",
Data: &advPendingPetArrival{},
ExpiresAt: time.Now().Add(advDMResponseWindow),
})
_ = p.SendDM(userID, text)
}
// resolvePetArrival handles the chase/feed response.
func (p *AdventurePlugin) resolvePetArrival(ctx MessageContext) error {
userMu := p.advUserLock(ctx.Sender)
userMu.Lock()
defer userMu.Unlock()
char, _, err := p.ensureCharacter(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "Failed to load your character.")
}
reply := strings.ToLower(strings.TrimSpace(ctx.Body))
if reply == "chase" || reply == "🚪" {
char.PetChasedAway = true
char.PetReactivated = false
_ = saveAdvCharacter(char)
return p.SendDM(ctx.Sender, "You chased it away. It disappeared around the corner and didn't come back.\n\nThe house is quiet again.")
}
if reply == "feed" || reply == "🍖" {
// Ask what type
text := "It ate everything you put down. It's still here.\n\n" +
"It looks like a...\n\n" +
"🐶 Massive Dog\n" +
"🐱 Massive Cat\n\n" +
"Reply with `dog` or `cat`."
p.pending.Store(string(ctx.Sender), &advPendingInteraction{
Type: "pet_type",
Data: &advPendingPetType{},
ExpiresAt: time.Now().Add(advDMResponseWindow),
})
return p.SendDM(ctx.Sender, text)
}
// Invalid response — re-prompt
p.pending.Store(string(ctx.Sender), &advPendingInteraction{
Type: "pet_arrival",
Data: &advPendingPetArrival{},
ExpiresAt: time.Now().Add(advDMResponseWindow),
})
return p.SendDM(ctx.Sender, "Reply with `chase` or `feed`.")
}
// resolvePetType handles dog/cat selection.
func (p *AdventurePlugin) resolvePetType(ctx MessageContext) error {
userMu := p.advUserLock(ctx.Sender)
userMu.Lock()
defer userMu.Unlock()
reply := strings.ToLower(strings.TrimSpace(ctx.Body))
petType := ""
if reply == "dog" || reply == "🐶" {
petType = "dog"
} else if reply == "cat" || reply == "🐱" {
petType = "cat"
}
if petType == "" {
p.pending.Store(string(ctx.Sender), &advPendingInteraction{
Type: "pet_type",
Data: &advPendingPetType{},
ExpiresAt: time.Now().Add(advDMResponseWindow),
})
return p.SendDM(ctx.Sender, "Reply with `dog` or `cat`.")
}
p.pending.Store(string(ctx.Sender), &advPendingInteraction{
Type: "pet_name",
Data: &advPendingPetName{PetType: petType},
ExpiresAt: time.Now().Add(advDMResponseWindow),
})
article := "a"
if petType == "dog" {
article = "a"
}
return p.SendDM(ctx.Sender, fmt.Sprintf("It's %s Massive %s. What would you like to name it?\n\nReply with a name.",
article, titleCase(petType)))
}
// resolvePetName handles naming the pet.
func (p *AdventurePlugin) resolvePetName(ctx MessageContext) error {
userMu := p.advUserLock(ctx.Sender)
userMu.Lock()
defer userMu.Unlock()
char, _, err := p.ensureCharacter(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "Failed to load your character.")
}
val, ok := p.pending.LoadAndDelete(string(ctx.Sender))
if !ok {
return nil
}
pi := val.(*advPendingInteraction)
data := pi.Data.(*advPendingPetName)
name := strings.TrimSpace(ctx.Body)
if len(name) == 0 || len(name) > 30 {
p.pending.Store(string(ctx.Sender), pi)
return p.SendDM(ctx.Sender, "Name must be 1-30 characters. Try again.")
}
if !petNameValid.MatchString(name) {
p.pending.Store(string(ctx.Sender), pi)
return p.SendDM(ctx.Sender, "Name can only contain letters, numbers, spaces, hyphens, and apostrophes. Try again.")
}
char.PetType = data.PetType
char.PetName = name
char.PetArrived = true
char.PetChasedAway = false
char.PetLevel = 1
char.PetXP = 0
if err := saveAdvCharacter(char); err != nil {
return p.SendDM(ctx.Sender, "Failed to save.")
}
emoji := "🐶"
if data.PetType == "cat" {
emoji = "🐱"
}
return p.SendDM(ctx.Sender, fmt.Sprintf("%s **%s** has moved in.\n\nBreed: Massive %s.\nLevel: 1\n\n%s will join you in combat. %s will not explain their decisions.",
emoji, name, titleCase(data.PetType), name, name))
}
// ── Morning Pet Events ─────────────────────────────────────────────────────
// petMorningEvent rolls for a morning pet event and returns flavor text.
// Returns empty string if no event fires.
func petMorningEvent(char *AdventureCharacter) string {
if !char.HasPet() {
return ""
}
// 25% chance of morning event
if rand.Float64() >= 0.25 {
return ""
}
if char.PetType == "cat" {
line := PetCatOffering[rand.IntN(len(PetCatOffering))]
return strings.ReplaceAll(line, "{pet_name}", char.PetName)
}
line := PetDogSmothering[rand.IntN(len(PetDogSmothering))]
return strings.ReplaceAll(line, "{pet_name}", char.PetName)
}
// ── Pet Supply Shop Unlock Check ───────────────────────────────────────────
// petCheckSupplyShopUnlock checks if 1 week has passed since pet hit level 10.
func petCheckSupplyShopUnlock(char *AdventureCharacter) bool {
if char.PetSupplyShopUnlocked || char.PetLevel10Date == "" {
return false
}
d, err := time.Parse("2006-01-02", char.PetLevel10Date)
if err != nil {
return false
}
return time.Now().UTC().After(d.Add(7 * 24 * time.Hour))
}
// ── Misty Housing Hint ─────────────────────────────────────────────────────
// mistyHousingHint returns a hint about housing/pets if conditions are met.
// Fires once, after 2+ Misty encounters, player has no house.
func mistyHousingHint(char *AdventureCharacter) string {
if char.HasHouse() || char.MistyEncounterCount < 2 {
return ""
}
if char.MistyDonatedCount > 0 {
return "You seem like a good person. I can imagine you sitting in a house caring for a pet someday."
}
return "Thank you for your time."
}
// ── Misty Pet Reactivation ─────────────────────────────────────────────────
// mistyReactivatePet marks the pet as reactivated if it was chased away.
func mistyReactivatePet(char *AdventureCharacter) {
if char.PetChasedAway && !char.PetReactivated {
char.PetReactivated = true
}
}
// ── Pet Level 10 Ticker (runs daily at midnight) ───────────────────────────
func (p *AdventurePlugin) petMidnightCheck() {
chars, err := loadAllAdvCharacters()
if err != nil {
slog.Error("pet: failed to load characters", "err", err)
return
}
for _, char := range chars {
if !char.HasPet() {
continue
}
// Check supply shop unlock
if petCheckSupplyShopUnlock(&char) {
char.PetSupplyShopUnlocked = true
_ = saveAdvCharacter(&char)
slog.Info("pet: supply shop unlocked", "user", char.UserID, "pet", char.PetName)
}
}
}
// ── Ditch Recovery Text ────────────────────────────────────────────────────
func petDitchRecoveryGameRoom(username, petName string, petInvolved bool) string {
base := fmt.Sprintf("🏥 %s was brought into St. Guildmore's on a stretcher. They have been returned to the ditch. They'll be fine eventually.", username)
if petInvolved {
base = fmt.Sprintf("🏥 St. Guildmore's notes that %s has once again intervened before our personnel could reach the scene. %s has been reminded that the ditch is not a medical facility. %s did not respond to this reminder.", petName, petName, petName)
}
return base
}

View File

@@ -0,0 +1,442 @@
package plugin
import (
"strings"
"testing"
)
// ── Pet XP & Leveling ──────────────────────────────────────────────────────
func TestPetXPToNextLevel(t *testing.T) {
tests := []struct {
level int
expected int
}{
{0, 10},
{1, 10},
{2, 10},
{4, 20},
{5, 35},
{7, 35},
{8, 50},
{9, 50},
}
for _, tt := range tests {
if got := petXPToNextLevel(tt.level); got != tt.expected {
t.Errorf("petXPToNextLevel(%d) = %d, want %d", tt.level, got, tt.expected)
}
}
}
func TestPetXPToNextLevel_Increasing(t *testing.T) {
// XP requirements should never decrease as level increases
prev := petXPToNextLevel(0)
for level := 1; level < 10; level++ {
curr := petXPToNextLevel(level)
if curr < prev {
t.Errorf("XP for level %d (%d) should not be less than level %d (%d)", level, curr, level-1, prev)
}
prev = curr
}
}
func TestPetGrantXP_LevelsUp(t *testing.T) {
char := &AdventureCharacter{
PetType: "dog",
PetName: "Rex",
PetArrived: true,
PetLevel: 1,
PetXP: 950, // close to needing 1000 (10 * 100 centixp)
}
leveled := petGrantXP(char)
if !leveled {
t.Error("should have leveled up")
}
if char.PetLevel != 2 {
t.Errorf("pet level should be 2, got %d", char.PetLevel)
}
}
func TestPetGrantXP_NoPet(t *testing.T) {
char := &AdventureCharacter{}
if petGrantXP(char) {
t.Error("should not grant XP without a pet")
}
}
func TestPetGrantXP_MaxLevel(t *testing.T) {
char := &AdventureCharacter{
PetType: "cat",
PetName: "Luna",
PetArrived: true,
PetLevel: 10,
PetXP: 9999,
}
if petGrantXP(char) {
t.Error("should not level up past max level 10")
}
}
func TestPetGrantXP_ChasedAway(t *testing.T) {
char := &AdventureCharacter{
PetType: "dog",
PetName: "Rex",
PetArrived: true,
PetChasedAway: true,
PetLevel: 1,
PetXP: 0,
}
if petGrantXP(char) {
t.Error("should not grant XP to chased-away pet")
}
}
func TestPetGrantXP_SetsLevel10Date(t *testing.T) {
char := &AdventureCharacter{
PetType: "dog",
PetName: "Rex",
PetArrived: true,
PetLevel: 9,
PetXP: 4999, // needs 5000 (50 * 100) for level 9→10
}
petGrantXP(char)
if char.PetLevel == 10 && char.PetLevel10Date == "" {
t.Error("reaching level 10 should set PetLevel10Date")
}
}
// ── Pet Combat Probabilities ───────────────────────────────────────────────
func TestPetAttackChance_ScalesWithLevel(t *testing.T) {
prev := petAttackChance(0)
for level := 1; level <= 10; level++ {
curr := petAttackChance(level)
if curr <= prev {
t.Errorf("attack chance at level %d (%.3f) should exceed level %d (%.3f)", level, curr, level-1, prev)
}
prev = curr
}
}
func TestPetAttackChance_Level10(t *testing.T) {
chance := petAttackChance(10)
// 3% + 10*1.5% = 18%
if chance < 0.179 || chance > 0.181 {
t.Errorf("level 10 attack chance: got %.3f, want 0.18", chance)
}
}
func TestPetDeflectChance_ScalesWithLevel(t *testing.T) {
prev := petDeflectChance(0, 0)
for level := 1; level <= 10; level++ {
curr := petDeflectChance(level, 0)
if curr <= prev {
t.Errorf("deflect chance at level %d (%.3f) should exceed level %d (%.3f)", level, curr, level-1, prev)
}
prev = curr
}
}
func TestPetDeflectChance_ArmorBonus(t *testing.T) {
baseChance := petDeflectChance(5, 0)
armoredChance := petDeflectChance(5, 3)
if armoredChance <= baseChance {
t.Errorf("armored deflect (%.3f) should exceed base (%.3f)", armoredChance, baseChance)
}
}
func TestPetDitchRecoveryChance_ScalesWithLevel(t *testing.T) {
prev := petDitchRecoveryChance(0)
for level := 1; level <= 10; level++ {
curr := petDitchRecoveryChance(level)
if curr <= prev {
t.Errorf("ditch chance at level %d (%.3f) should exceed level %d (%.3f)", level, curr, level-1, prev)
}
prev = curr
}
}
func TestPetDitchRecoveryChance_Level10(t *testing.T) {
chance := petDitchRecoveryChance(10)
// 5% + 10*2% = 25%
if chance < 0.249 || chance > 0.251 {
t.Errorf("level 10 ditch chance: got %.3f, want 0.25", chance)
}
}
// ── Pet Combat Results ─────────────────────────────────────────────────────
func TestPetRollCombatActions_NoPet(t *testing.T) {
char := &AdventureCharacter{}
result := petRollCombatActions(char, "Goblin")
if result != nil {
t.Error("should return nil without a pet")
}
}
func TestPetRollDitchRecovery_NoPet(t *testing.T) {
char := &AdventureCharacter{}
if petRollDitchRecovery(char) {
t.Error("should return false without a pet")
}
}
func TestPetVictoryText_Dog(t *testing.T) {
char := &AdventureCharacter{PetType: "dog", PetName: "Rex", PetArrived: true}
text := petVictoryText(char)
if text == "" {
t.Error("should return victory text for dog")
}
}
func TestPetVictoryText_Cat(t *testing.T) {
char := &AdventureCharacter{PetType: "cat", PetName: "Luna", PetArrived: true}
text := petVictoryText(char)
if text == "" {
t.Error("should return victory text for cat")
}
}
func TestPetVictoryText_NoPet(t *testing.T) {
char := &AdventureCharacter{}
if petVictoryText(char) != "" {
t.Error("should return empty string without pet")
}
}
func TestPetDeathText_NoPet(t *testing.T) {
char := &AdventureCharacter{}
if petDeathText(char) != "" {
t.Error("should return empty string without pet")
}
}
// ── Pet Arrival Logic ──────────────────────────────────────────────────────
func TestPetShouldArrive_NoHouse(t *testing.T) {
char := &AdventureCharacter{HouseTier: 0}
if petShouldArrive(char) {
t.Error("should not arrive without house")
}
}
func TestPetShouldArrive_BaseHouseOnly(t *testing.T) {
char := &AdventureCharacter{HouseTier: 1} // Base house, not yet Livable
if petShouldArrive(char) {
t.Error("should not arrive with only base house (need tier 2+)")
}
}
func TestPetShouldArrive_AlreadyArrived(t *testing.T) {
char := &AdventureCharacter{HouseTier: 2, PetArrived: true}
if petShouldArrive(char) {
t.Error("should not trigger if pet already arrived")
}
}
func TestPetShouldArrive_ChasedAway(t *testing.T) {
char := &AdventureCharacter{HouseTier: 2, PetChasedAway: true}
if petShouldArrive(char) {
t.Error("should not trigger if pet was chased away (and not reactivated)")
}
}
func TestPetShouldArrive_Reactivated(t *testing.T) {
char := &AdventureCharacter{HouseTier: 2, PetChasedAway: true, PetReactivated: true}
// With reactivation, should always return true
if !petShouldArrive(char) {
t.Error("reactivated pet should always trigger arrival")
}
}
// ── HasPet ─────────────────────────────────────────────────────────────────
func TestHasPet(t *testing.T) {
tests := []struct {
name string
char AdventureCharacter
expected bool
}{
{"no pet", AdventureCharacter{}, false},
{"has pet", AdventureCharacter{PetType: "dog", PetName: "Rex", PetArrived: true}, true},
{"chased away", AdventureCharacter{PetType: "dog", PetName: "Rex", PetArrived: true, PetChasedAway: true}, false},
{"not arrived", AdventureCharacter{PetType: "dog", PetName: "Rex"}, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.char.HasPet() != tt.expected {
t.Errorf("HasPet() = %v, want %v", tt.char.HasPet(), tt.expected)
}
})
}
}
// ── Morning Pet Events ─────────────────────────────────────────────────────
func TestPetMorningEvent_NoPet(t *testing.T) {
char := &AdventureCharacter{}
if petMorningEvent(char) != "" {
t.Error("should return empty string without pet")
}
}
// ── Misty Housing Hint ─────────────────────────────────────────────────────
func TestMistyHousingHint_NoEncounters(t *testing.T) {
char := &AdventureCharacter{MistyEncounterCount: 0}
if mistyHousingHint(char) != "" {
t.Error("should not hint with 0 encounters")
}
}
func TestMistyHousingHint_HasHouse(t *testing.T) {
char := &AdventureCharacter{MistyEncounterCount: 3, HouseTier: 1}
if mistyHousingHint(char) != "" {
t.Error("should not hint if player has house")
}
}
func TestMistyHousingHint_Donated(t *testing.T) {
char := &AdventureCharacter{MistyEncounterCount: 2, MistyDonatedCount: 1}
hint := mistyHousingHint(char)
if !strings.Contains(hint, "house") {
t.Errorf("donated player hint should mention house, got: %q", hint)
}
}
func TestMistyHousingHint_NotDonated(t *testing.T) {
char := &AdventureCharacter{MistyEncounterCount: 2, MistyDonatedCount: 0}
hint := mistyHousingHint(char)
if hint != "Thank you for your time." {
t.Errorf("non-donor hint should be dismissive, got: %q", hint)
}
}
// ── Misty Pet Reactivation ─────────────────────────────────────────────────
func TestMistyReactivatePet_ChasedAway(t *testing.T) {
char := &AdventureCharacter{PetChasedAway: true, PetReactivated: false}
mistyReactivatePet(char)
if !char.PetReactivated {
t.Error("should set PetReactivated")
}
}
func TestMistyReactivatePet_NotChasedAway(t *testing.T) {
char := &AdventureCharacter{PetChasedAway: false, PetReactivated: false}
mistyReactivatePet(char)
if char.PetReactivated {
t.Error("should not set PetReactivated if pet wasn't chased away")
}
}
func TestMistyReactivatePet_AlreadyReactivated(t *testing.T) {
char := &AdventureCharacter{PetChasedAway: true, PetReactivated: true}
mistyReactivatePet(char)
if !char.PetReactivated {
t.Error("should remain reactivated")
}
}
// ── Pet Supply Shop Unlock ─────────────────────────────────────────────────
func TestPetCheckSupplyShopUnlock_NotLevel10(t *testing.T) {
char := &AdventureCharacter{PetLevel10Date: ""}
if petCheckSupplyShopUnlock(char) {
t.Error("should not unlock without level 10 date")
}
}
func TestPetCheckSupplyShopUnlock_AlreadyUnlocked(t *testing.T) {
char := &AdventureCharacter{PetSupplyShopUnlocked: true, PetLevel10Date: "2020-01-01"}
if petCheckSupplyShopUnlock(char) {
t.Error("should not re-trigger if already unlocked")
}
}
func TestPetCheckSupplyShopUnlock_TooSoon(t *testing.T) {
// Set date to today — not 7 days ago
char := &AdventureCharacter{PetLevel10Date: "2099-01-01"}
if petCheckSupplyShopUnlock(char) {
t.Error("should not unlock before 7 days have passed")
}
}
func TestPetCheckSupplyShopUnlock_Ready(t *testing.T) {
char := &AdventureCharacter{PetLevel10Date: "2020-01-01"}
if !petCheckSupplyShopUnlock(char) {
t.Error("should unlock after 7+ days")
}
}
// ── Ditch Recovery Text ────────────────────────────────────────────────────
func TestPetDitchRecoveryGameRoom_NoPet(t *testing.T) {
text := petDitchRecoveryGameRoom("Player1", "", false)
if !strings.Contains(text, "Player1") {
t.Error("should contain player name")
}
if !strings.Contains(text, "stretcher") {
t.Error("should mention stretcher for non-pet version")
}
}
func TestPetDitchRecoveryGameRoom_WithPet(t *testing.T) {
text := petDitchRecoveryGameRoom("Player1", "Rex", true)
if !strings.Contains(text, "Rex") {
t.Error("should contain pet name")
}
if !strings.Contains(text, "intervened") {
t.Error("should mention pet intervention")
}
}
// ── Flavor Pool Coverage ───────────────────────────────────────────────────
func TestPetFlavorPools_NonEmpty(t *testing.T) {
pools := map[string][]string{
"PetDogAttack": PetDogAttack,
"PetCatAttack": PetCatAttack,
"PetDogDeath": PetDogDeath,
"PetCatDeath": PetCatDeath,
"PetDogVictory": PetDogVictory,
"PetCatVictory": PetCatVictory,
"PetDogDeflect": PetDogDeflect,
"PetCatDeflect": PetCatDeflect,
"PetCatOffering": PetCatOffering,
"PetDogSmothering": PetDogSmothering,
}
for name, pool := range pools {
if len(pool) == 0 {
t.Errorf("flavor pool %s is empty", name)
}
}
}
func TestPetAttackFlavor_Placeholders(t *testing.T) {
// All attack lines must have {damage}. {enemy} is optional — some lines
// intentionally omit the enemy name for voice/style reasons (especially cat).
for i, line := range PetDogAttack {
if !strings.Contains(line, "{damage}") {
t.Errorf("PetDogAttack[%d] missing {damage} placeholder", i)
}
}
for i, line := range PetCatAttack {
if !strings.Contains(line, "{damage}") {
t.Errorf("PetCatAttack[%d] missing {damage} placeholder", i)
}
}
}
func TestPetDeflectFlavor_Placeholders(t *testing.T) {
for i, line := range PetDogDeflect {
if !strings.Contains(line, "{enemy}") {
t.Errorf("PetDogDeflect[%d] missing {enemy} placeholder", i)
}
}
for i, line := range PetCatDeflect {
if !strings.Contains(line, "{enemy}") {
t.Errorf("PetCatDeflect[%d] missing {enemy} placeholder", i)
}
}
}

View File

@@ -335,7 +335,8 @@ func renderAdvMorningDM(char *AdventureCharacter, equip map[EquipmentSlot]*AdvEq
sb.WriteString("**5⃣ Shop** — buy/sell gear and loot\n")
sb.WriteString("**6⃣ Blacksmith** — repair damaged equipment\n")
sb.WriteString("**7⃣ Rest** — skip today, bank your luck\n\n")
sb.WriteString("**7⃣ Rest** — skip today, bank your luck\n")
sb.WriteString("**8⃣ Thom** — `!thom` visit Krooke Realty 🏠\n\n")
sb.WriteString("Reply with the number and location, e.g: `1 Soggy Cellar`\n")
sb.WriteString("You have until midnight UTC to choose.")
@@ -605,8 +606,12 @@ func renderAdvDailySummary(date string, tb *TwinBeeResult, tbRewards TwinBeeRewa
sb.WriteString(fmt.Sprintf("⚔️ **%s** — Combat Lv.%d | Mining Lv.%d | Forage Lv.%d | Fishing Lv.%d\n",
p.DisplayName, p.CombatLevel, p.MiningSkill, p.ForagingSkill, p.FishingSkill))
sb.WriteString(fmt.Sprintf(" Went to: %s\n", p.Location))
sb.WriteString(fmt.Sprintf(" Outcome: %s\n\n", p.SummaryLine))
if p.Location != "" {
sb.WriteString(fmt.Sprintf(" Went to: %s\n", p.Location))
sb.WriteString(fmt.Sprintf(" Outcome: %s\n\n", p.SummaryLine))
} else {
sb.WriteString(" Acted today — no log recorded.\n\n")
}
if bestPlayer == nil || p.LootValue > bestPlayer.LootValue {
bestPlayer = p
@@ -723,11 +728,11 @@ func renderAdvLeaderboard(chars []AdventureCharacter) string {
}
var entries []entry
for _, c := range chars {
score := (c.CombatLevel + c.MiningSkill + c.ForagingSkill) * 10
score := (c.CombatLevel + c.MiningSkill + c.ForagingSkill + c.FishingSkill) * 10
entries = append(entries, entry{
Name: c.DisplayName,
Score: score,
Levels: fmt.Sprintf("⚔️%d ⛏️%d 🌿%d", c.CombatLevel, c.MiningSkill, c.ForagingSkill),
Levels: fmt.Sprintf("⚔️%d ⛏️%d 🌿%d 🎣%d", c.CombatLevel, c.MiningSkill, c.ForagingSkill, c.FishingSkill),
Streak: c.CurrentStreak,
})
}

View File

@@ -459,8 +459,11 @@ func (p *AdventurePlugin) resolveRivalRPSRound(ctx MessageContext, interaction *
// Outcome line.
outcomePool := rivalRoundOutcomeWin
outcome := pickRivalFlavor(outcomePool)
if strings.Contains(outcome, "%s") {
switch strings.Count(outcome, "%s") {
case 2:
outcome = fmt.Sprintf(outcome, rpsNames[rivalThrow], rpsNames[playerThrow])
case 1:
outcome = fmt.Sprintf(outcome, rpsNames[rivalThrow])
}
sb.WriteString(outcome + "\n\n")
sb.WriteString(fmt.Sprintf("Score: You %d -- Rival %d\n\n", challenge.PlayerScore, challenge.RivalScore))
@@ -471,8 +474,11 @@ func (p *AdventurePlugin) resolveRivalRPSRound(ctx MessageContext, interaction *
challenge.RivalScore++
outcomePool := rivalRoundOutcomeLoss
outcome := pickRivalFlavor(outcomePool)
if strings.Contains(outcome, "%s") {
switch strings.Count(outcome, "%s") {
case 2:
outcome = fmt.Sprintf(outcome, rpsNames[rivalThrow], rpsNames[playerThrow])
case 1:
outcome = fmt.Sprintf(outcome, rpsNames[rivalThrow])
}
sb.WriteString(outcome + "\n\n")
sb.WriteString(fmt.Sprintf("Score: You %d -- Rival %d\n\n", challenge.PlayerScore, challenge.RivalScore))

View File

@@ -122,7 +122,11 @@ func (p *AdventurePlugin) robbieVisitPlayer(userID id.UserID, displayName string
continue
}
takenItems = append(takenItems, item)
totalPayout += 50
payout := item.Value / 4
if payout < 1 {
payout = 1
}
totalPayout += payout
communityTotal += item.Value
if item.Type == "MasterworkGear" {
masterworkTaken = true
@@ -246,14 +250,18 @@ func renderRobbieDM(userID id.UserID, items []AdvItem, total int64, mwTaken, gav
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) → €50", emoji, item.Name, item.Tier))
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) → €50", emoji, item.Name, item.Tier))
sb.WriteString(fmt.Sprintf(" %s %s (T%d) → €%d", emoji, item.Name, item.Tier, payout))
}
sb.WriteByte('\n')
}

View File

@@ -4,6 +4,8 @@ import (
"fmt"
"log/slog"
"math/rand/v2"
"os"
"strings"
"time"
"gogobee/internal/db"
@@ -102,6 +104,19 @@ func (p *AdventurePlugin) sendMorningDMs() {
continue
}
// Pet arrival check (fires before normal morning DM)
if petShouldArrive(&char) {
p.petArrivalDM(char.UserID)
continue
}
// Morning pet event
petEvent := petMorningEvent(&char)
if petEvent != "" {
char.PetMorningDefense = true
_ = saveAdvCharacter(&char)
}
// Send morning DM with choices
equip, err := loadAdvEquipment(char.UserID)
if err != nil {
@@ -119,6 +134,9 @@ func (p *AdventurePlugin) sendMorningDMs() {
holidayLabel = holName
}
text := renderAdvMorningDM(&char, equip, balance, bonuses, holidayLabel)
if petEvent != "" {
text = fmt.Sprintf("🐾 *%s*\n\n%s", petEvent, text)
}
p.advMarkMenuSent(char.UserID)
if err := p.SendDM(char.UserID, text); err != nil {
slog.Error("adventure: failed to send morning DM", "user", char.UserID, "err", err)
@@ -171,7 +189,11 @@ func (p *AdventurePlugin) postDailySummary() {
return
}
todayLogs, _ := loadAdvTodayLogs()
// Load logs for today and yesterday — players may act across the UTC boundary
now := time.Now().UTC()
todayLogs, _ := loadAdvLogsForDate(now.Format("2006-01-02"))
yesterdayLogs, _ := loadAdvLogsForDate(now.AddDate(0, 0, -1).Format("2006-01-02"))
todayLogs = append(todayLogs, yesterdayLogs...)
// Group logs per user — holiday days may produce 2 entries per user
logsPerUser := make(map[id.UserID][]*AdvDayLog)
for i := range todayLogs {
@@ -394,9 +416,39 @@ func (p *AdventurePlugin) midnightReset() error {
// Check babysitting service expirations
p.checkBabysitExpiry(chars)
// Pet supply shop unlock check
p.petMidnightCheck()
// Reset holdem NPC house balance
resetNPCHouseBalance()
// Daily database backup (async to avoid blocking reset if DM sends are slow)
go func() {
if err := db.Backup(); err != nil {
slog.Error("adventure: daily backup failed", "err", err)
p.notifyAdmins(fmt.Sprintf("⚠️ **Daily backup failed:** %v", err))
}
}()
return nil
}
func (p *AdventurePlugin) notifyAdmins(msg string) {
admins := os.Getenv("ADMIN_USERS")
if admins == "" {
return
}
for _, a := range strings.Split(admins, ",") {
uid := id.UserID(strings.TrimSpace(a))
if uid == "" {
continue
}
if err := p.SendDM(uid, msg); err != nil {
slog.Error("adventure: failed to notify admin", "user", uid, "err", err)
}
}
}
// ── Helper ───────────────────────────────────────────────────────────────────
func (p *AdventurePlugin) registerDMRoom(userID id.UserID) {

View File

@@ -56,7 +56,7 @@ func twinBeeMaxTier() int {
if !c.Alive {
continue
}
combined := c.CombatLevel + c.MiningSkill + c.ForagingSkill
combined := c.CombatLevel + c.MiningSkill + c.ForagingSkill + c.FishingSkill
if combined > bestLevel {
bestLevel = combined
}
@@ -264,12 +264,12 @@ func (p *AdventurePlugin) distributeTwinBeeRewards(result *TwinBeeResult) TwinBe
var players []eligiblePlayer
totalWeight := 0
for _, uid := range eligible {
weight := 3 // minimum (level 1 in all 3 skills)
weight := 4 // minimum (level 1 in all 4 skills)
for _, c := range chars {
if c.UserID == uid {
weight = c.CombatLevel + c.MiningSkill + c.ForagingSkill
if weight < 3 {
weight = 3
weight = c.CombatLevel + c.MiningSkill + c.ForagingSkill + c.FishingSkill
if weight < 4 {
weight = 4
}
break
}

View File

@@ -97,12 +97,14 @@ func (p *HoldemPlugin) OnMessage(ctx MessageContext) error {
}
// If this is a DM, resolve the player's game room so actions route correctly.
// Clear EventID so replies go as plain messages — the room can't resolve DM events.
if isDM {
p.mu.Lock()
gameRoom := p.findGameRoom(ctx.Sender)
p.mu.Unlock()
if gameRoom != "" {
ctx.RoomID = gameRoom
ctx.EventID = ""
}
}
@@ -1216,6 +1218,14 @@ func (p *HoldemPlugin) updateNPCBalance(delta int64) {
delta, p.cfg.NPCName)
}
// resetNPCHouseBalance resets all NPC balances to the configured house balance.
// Called daily at midnight.
func resetNPCHouseBalance() {
balance := int64(envInt("HOLDEM_NPC_HOUSE_BALANCE", 10000))
db.Exec("holdem: reset npc balance",
`UPDATE holdem_npc_balance SET balance = ?`, balance)
}
func (p *HoldemPlugin) recordScores(game *HoldemGame, winnings map[id.UserID]int64) {
for _, pl := range game.Players {
if pl.IsNPC {

View File

@@ -576,10 +576,12 @@ func (b *Base) SendNotice(roomID id.RoomID, text string) error {
// SendReply sends a reply to a specific event.
func (b *Base) SendReply(roomID id.RoomID, eventID id.EventID, text string) error {
content := textContent(text)
content.RelatesTo = &event.RelatesTo{
InReplyTo: &event.InReplyTo{
EventID: eventID,
},
if eventID != "" {
content.RelatesTo = &event.RelatesTo{
InReplyTo: &event.InReplyTo{
EventID: eventID,
},
}
}
_, err := b.Client.SendMessageEvent(context.Background(), roomID, event.EventMessage, content)
if err != nil {

View File

@@ -0,0 +1,125 @@
package plugin
// ── IMPORTANT: DO NOT REWRITE, SUMMARIZE, OR SHORTEN ANY ENTRIES IN THIS FILE ──
//
// This file contains hand-written flavor text. Every entry is the length it is
// on purpose. The voice, pacing, and specific wording are intentional.
// If an entry seems too long: it isn't. Leave it alone.
// If you are tempted to "clean up" or "condense" entries: don't.
// If your context window is too small to fit this file: that is your problem, not ours.
//
// ── THOM KROOKE — MORTGAGE RATE CHANGE NOTIFICATIONS ─────────────────────────
//
// Pulled weekly from FRED API (series: MORTGAGE5US).
// Rate change DM sent to player if rate differs from previous week.
// No change: no DM. Thom does not send neutral news.
//
// Pre-pet: terse, professional, zero warmth.
// Post-pet: still about the money, immediately about the pet.
// The pet is mentioned before you are. You are not mentioned.
// ── RATE INCREASE (NO PET) ────────────────────────────────────────────────────
var ThomRateIncrease = []string{
"Rates adjusted. Your new weekly payment is €{amount}. Market conditions. You understand.",
"Weekly update. Rates went up. Payment is now €{amount}. " +
"This is not personal. It is also not not personal. Pay on time.",
"The Fed moved. Your payment moved. €{amount} weekly going forward. " +
"I don't make the rates. I just apply them promptly and without sympathy.",
"Rate adjustment. €{amount} per week. " +
"I'd say I'm sorry but the contract was quite clear about this possibility. " +
"You signed it.",
"Rates up. €{amount}. " +
"If you'd like to discuss this I am available during business hours. " +
"Business hours are whenever I feel like it.",
}
// ── RATE DECREASE (NO PET) ────────────────────────────────────────────────────
var ThomRateDecrease = []string{
"Rates adjusted down. Your new weekly payment is €{amount}. Don't get used to it.",
"Good news, technically. Rates dropped. €{amount} weekly going forward. " +
"The market giveth. The market will taketh back. Enjoy it briefly.",
"Rate adjustment in your favour this week. €{amount}. " +
"I want to be clear that this has nothing to do with me. " +
"The Fed did this. I merely informed you.",
"Rates down. €{amount} per week. " +
"You're welcome, even though I did nothing. Thom Krooke, Krooke Realty.",
"Weekly update. Rates dropped. Your payment is €{amount}. " +
"I've noted your relief. I've also noted that rates can go back up. " +
"Good week.",
}
// ── RATE INCREASE (WITH PET) ──────────────────────────────────────────────────
var ThomRateIncreasePet = []string{
"Rates went up. Weekly payment is now €{amount}. " +
"You might need to skip a dinner or two and give yours to {pet_name} for a while. " +
"Whether you eat or not is unimportant. Only {pet_name}'s stomach matters here.",
"Rate adjustment. €{amount} per week. " +
"I'd tighten the belt if I were you. " +
"{pet_name} should not feel this. You should feel this. Adjust accordingly.",
"Weekly update. Rates up. €{amount}. " +
"I've been thinking about {pet_name}'s next armor upgrade. " +
"You should also be thinking about that. After you figure out the payment. " +
"Priority order is: {pet_name}, payment, everything else.",
"Rates moved. Not in your favour. €{amount} weekly going forward. " +
"{pet_name} doesn't need to know about this. Keep things normal for {pet_name}. " +
"You'll figure out the rest.",
"The Fed raised rates. Your payment is €{amount}. " +
"Before you panic: {pet_name} is fine. {pet_name} will continue to be fine. " +
"You may need to make some adjustments. {pet_name} will not.",
"Rate increase. €{amount} per week. " +
"I've seen people in your position make difficult choices. " +
"Make sure none of those choices involve {pet_name}'s meals. " +
"I'm watching.",
}
// ── RATE DECREASE (WITH PET) ──────────────────────────────────────────────────
var ThomRateDecreasePet = []string{
"Rates went down. Weekly payment is now €{amount}. " +
"You can buy better things for {pet_name} now. " +
"I'd start with the shop. I may have restocked recently. For {pet_name}.",
"Good news. Rates dropped. €{amount} going forward. " +
"The difference between what you were paying and what you're paying now " +
"should go directly toward {pet_name}. " +
"I'm not telling you what to do. I'm telling you what to do.",
"Rate adjustment in your favour. €{amount} weekly. " +
"I thought of {pet_name} immediately when I saw the numbers. " +
"You probably thought of yourself. " +
"Think about what that says. Then go buy {pet_name} something.",
"Rates down this week. Your payment is €{amount}. " +
"I've already set aside some things in the shop that {pet_name} would benefit from. " +
"The timing is not a coincidence. Come in when you're ready.",
"Weekly update. Rates dropped. €{amount} per week going forward. " +
"{pet_name} deserves something nice this week. " +
"You've been holding back on the shop. I've noticed. " +
"The rate drop is a sign. Take it.",
"The Fed cut rates. Your payment is €{amount}. " +
"I'm genuinely pleased about this, which is unusual for me. " +
"{pet_name} is the reason I'm genuinely pleased about this. " +
"You can draw your own conclusions.",
}
// ── NO CHANGE (NEVER SENT) ────────────────────────────────────────────────────
// Thom does not send neutral news.
// If rate is unchanged week over week: no DM. No notification. Nothing.

View File

@@ -1,7 +1,6 @@
package plugin
import (
"database/sql"
"fmt"
"log/slog"
"os"
@@ -54,17 +53,13 @@ func (p *WordlePlugin) Name() string { return "wordle" }
func (p *WordlePlugin) Commands() []CommandDef {
return []CommandDef{
{Name: "wordle", Description: "Guess today's Wordle", Usage: "!wordle <word>", Category: "Games"},
{Name: "wordle", Description: "Guess the current Wordle puzzle", Usage: "!wordle <word>", Category: "Games"},
}
}
func (p *WordlePlugin) Init() error {
// Rehydrate today's puzzle from DB if it exists.
// Rehydrate any active puzzle from DB if it exists.
p.rehydratePuzzles()
// Start the midnight ticker for auto-posting.
go p.midnightTicker()
return nil
}
@@ -113,26 +108,28 @@ func (p *WordlePlugin) handleHelp(ctx MessageContext) error {
"`!wordle new <5-20>` — New puzzle with specific length (admin)\n"+
"`!wordle new pt` — Portuguese puzzle (admin)\n"+
"`!wordle new fr` — French puzzle (admin)\n"+
"`!wordle skip` — Reveal answer and end puzzle (admin)")
"`!wordle skip` — Reveal answer and end puzzle (admin)\n\n"+
"A new puzzle starts automatically after each solve or failure.")
}
func (p *WordlePlugin) handleGuess(ctx MessageContext, guess string) error {
guess = strings.ToUpper(strings.TrimSpace(guess))
p.mu.Lock()
defer p.mu.Unlock()
puzzle := p.puzzles[ctx.RoomID]
if puzzle == nil {
return p.SendReply(ctx.RoomID, ctx.EventID, "No active puzzle. An admin can start one with `!wordle new`.")
}
if puzzle.Solved || puzzle.Failed {
return p.SendReply(ctx.RoomID, ctx.EventID, "Today's puzzle is already over. A new one starts at midnight UTC!")
if puzzle == nil || puzzle.Solved || puzzle.Failed {
p.mu.Unlock()
// Auto-start a new puzzle.
if err := p.createAndPostPuzzle(ctx.RoomID, p.defaultLength, WordleCategoryEN); err != nil {
return err
}
return p.SendReply(ctx.RoomID, ctx.EventID, "A new puzzle just started! Try your guess again.")
}
// Check length.
if len([]rune(guess)) != puzzle.WordLength {
p.mu.Unlock()
return p.SendReply(ctx.RoomID, ctx.EventID,
fmt.Sprintf("Guesses must be %d letters.", puzzle.WordLength))
}
@@ -140,6 +137,7 @@ func (p *WordlePlugin) handleGuess(ctx MessageContext, guess string) error {
// Check for non-alphabetic characters.
for _, r := range guess {
if r < 'A' || r > 'Z' {
p.mu.Unlock()
return p.SendReply(ctx.RoomID, ctx.EventID, "Guesses must contain only letters.")
}
}
@@ -147,6 +145,7 @@ func (p *WordlePlugin) handleGuess(ctx MessageContext, guess string) error {
// Check duplicate guess.
for _, g := range puzzle.Guesses {
if g.Word == guess {
p.mu.Unlock()
return p.SendReply(ctx.RoomID, ctx.EventID,
fmt.Sprintf("**%s** has already been tried.", guess))
}
@@ -155,10 +154,12 @@ func (p *WordlePlugin) handleGuess(ctx MessageContext, guess string) error {
// Validate word via DreamDict (with caching).
valid, apiErr := p.isValidWord(puzzle.PuzzleID, guess, puzzle.Category)
if apiErr {
p.mu.Unlock()
return p.SendReply(ctx.RoomID, ctx.EventID,
"Word validation is temporarily unavailable. Try again in a moment.")
}
if !valid {
p.mu.Unlock()
return p.SendReply(ctx.RoomID, ctx.EventID,
fmt.Sprintf("❌ **%s** is not a valid word.", guess))
}
@@ -196,8 +197,14 @@ func (p *WordlePlugin) handleGuess(ctx MessageContext, guess string) error {
payouts := p.awardPrize(puzzle)
p.updateStats(puzzle, payouts)
p.markPuzzleDone(puzzle)
p.mu.Unlock()
return p.SendMessage(ctx.RoomID, renderSolvedAnnouncement(puzzle, definition, payouts))
if err := p.SendMessage(ctx.RoomID, renderSolvedAnnouncement(puzzle, definition, payouts)); err != nil {
return err
}
// Auto-start next puzzle.
return p.createAndPostPuzzle(ctx.RoomID, p.defaultLength, WordleCategoryEN)
}
// Check for failure (all guesses used).
@@ -207,11 +214,18 @@ func (p *WordlePlugin) handleGuess(ctx MessageContext, guess string) error {
definition := p.fetchDefinition(puzzle.Answer)
p.updateStats(puzzle, nil)
p.markPuzzleDone(puzzle)
p.mu.Unlock()
return p.SendMessage(ctx.RoomID, renderFailedAnnouncement(puzzle, definition))
if err := p.SendMessage(ctx.RoomID, renderFailedAnnouncement(puzzle, definition)); err != nil {
return err
}
// Auto-start next puzzle.
return p.createAndPostPuzzle(ctx.RoomID, p.defaultLength, WordleCategoryEN)
}
// Post updated grid.
p.mu.Unlock()
return p.SendMessage(ctx.RoomID, renderWordleGrid(puzzle))
}
@@ -220,8 +234,12 @@ func (p *WordlePlugin) handleGrid(ctx MessageContext) error {
puzzle := p.puzzles[ctx.RoomID]
p.mu.Unlock()
if puzzle == nil {
return p.SendReply(ctx.RoomID, ctx.EventID, "No active puzzle.")
if puzzle == nil || puzzle.Solved || puzzle.Failed {
// Auto-start a new puzzle.
if err := p.createAndPostPuzzle(ctx.RoomID, p.defaultLength, WordleCategoryEN); err != nil {
return err
}
return nil
}
if len(puzzle.Guesses) == 0 {
@@ -290,8 +308,13 @@ func (p *WordlePlugin) handleSkip(ctx MessageContext) error {
defLine = fmt.Sprintf("\n📖 *%s*\n", definition)
}
return p.SendMessage(ctx.RoomID,
fmt.Sprintf("⏭️ **Puzzle skipped.**\nThe word was **%s**.%s", puzzle.Answer, defLine))
if err := p.SendMessage(ctx.RoomID,
fmt.Sprintf("⏭️ **Puzzle skipped.**\nThe word was **%s**.%s", puzzle.Answer, defLine)); err != nil {
return err
}
// Auto-start next puzzle.
return p.createAndPostPuzzle(ctx.RoomID, p.defaultLength, WordleCategoryEN)
}
func (p *WordlePlugin) handleStats(ctx MessageContext) error {
@@ -337,11 +360,11 @@ func (p *WordlePlugin) createAndPostPuzzle(roomID id.RoomID, wordLength int, cat
}
puzzleNumber := p.nextPuzzleNumber()
today := time.Now().UTC().Format("2006-01-02")
puzzleID := fmt.Sprintf("%d", puzzleNumber)
now := time.Now().UTC()
puzzle := &WordlePuzzle{
PuzzleID: today,
PuzzleID: puzzleID,
PuzzleNumber: puzzleNumber,
RoomID: roomID,
Answer: word,
@@ -355,26 +378,19 @@ func (p *WordlePlugin) createAndPostPuzzle(roomID id.RoomID, wordLength int, cat
// Persist to DB.
db.Exec("wordle: persist puzzle",
`INSERT INTO wordle_puzzles (puzzle_id, room_id, answer, word_length, category, solved, guess_count, started_at)
VALUES (?, ?, ?, ?, ?, 0, 0, ?)
ON CONFLICT(puzzle_id, room_id) DO UPDATE SET answer = ?, word_length = ?, category = ?, solved = 0, guess_count = 0, started_at = ?`,
today, string(roomID), word, wordLength, string(category), now,
word, wordLength, string(category), now,
)
// Clear any stale guesses from a previous puzzle on the same day (e.g. after skip+new).
db.Exec("wordle: clear old guesses",
`DELETE FROM wordle_guesses WHERE puzzle_id = ? AND room_id = ?`,
today, string(roomID),
VALUES (?, ?, ?, ?, ?, 0, 0, ?)`,
puzzleID, string(roomID), word, wordLength, string(category), now,
)
p.mu.Lock()
p.puzzles[roomID] = puzzle
// Evict stale cache entries from previous days.
// Evict old cache entries — keep only current puzzle.
for key := range p.validCache {
if key != today {
if key != puzzleID {
delete(p.validCache, key)
}
}
p.validCache[today] = make(map[string]bool)
p.validCache[puzzleID] = make(map[string]bool)
p.mu.Unlock()
hint := wordleCategoryHint(category)
@@ -444,47 +460,7 @@ func wordleCategoryHint(category WordleCategory) string {
return ""
}
// expireUnsolved marks an active puzzle as failed and posts an announcement.
// Must be called without holding p.mu.
func (p *WordlePlugin) expireUnsolved(roomID id.RoomID) {
p.mu.Lock()
existing := p.puzzles[roomID]
if existing == nil || existing.Solved || existing.Failed {
p.mu.Unlock()
return
}
existing.Failed = true
p.markPuzzleDone(existing)
p.updateStats(existing, nil)
p.mu.Unlock()
definition := p.fetchDefinition(existing.Answer)
defLine := ""
if definition != "" {
defLine = fmt.Sprintf("\n📖 *%s*\n", definition)
}
p.SendMessage(roomID,
fmt.Sprintf("⏰ **Time's up!** Yesterday's puzzle expired.\nThe word was **%s**.%s", existing.Answer, defLine))
}
// PostDailyPuzzle is called by the scheduler to post today's puzzle.
func (p *WordlePlugin) PostDailyPuzzle(roomID id.RoomID) error {
today := time.Now().UTC().Format("2006-01-02")
// Check if already posted today.
p.mu.Lock()
existing := p.puzzles[roomID]
if existing != nil && existing.PuzzleID == today {
p.mu.Unlock()
return nil // already posted
}
p.mu.Unlock()
// Announce expiry of yesterday's unsolved puzzle before creating the new one.
p.expireUnsolved(roomID)
return p.createAndPostPuzzle(roomID, p.defaultLength, WordleCategoryEN)
}
// isValidWord checks if a word is valid, using the in-memory cache first,
// then the custom allow-list, then DreamDict.
@@ -705,7 +681,7 @@ func (p *WordlePlugin) awardPrize(puzzle *WordlePuzzle) []WordlePayout {
func (p *WordlePlugin) communityStreak() int {
d := db.Get()
rows, err := d.Query(
`SELECT puzzle_id, solved FROM wordle_puzzles ORDER BY puzzle_id DESC LIMIT 100`)
`SELECT puzzle_id, solved FROM wordle_puzzles ORDER BY started_at DESC LIMIT 100`)
if err != nil {
return 0
}
@@ -728,7 +704,6 @@ func (p *WordlePlugin) communityStreak() int {
}
func (p *WordlePlugin) rehydratePuzzles() {
today := time.Now().UTC().Format("2006-01-02")
d := db.Get()
// Collect rows first, then close the cursor before doing any nested queries.
@@ -743,14 +718,16 @@ func (p *WordlePlugin) rehydratePuzzles() {
startedAt time.Time
}
// Find the most recent unsolved puzzle per room.
rows, err := d.Query(
`SELECT puzzle_id, room_id, answer, word_length, COALESCE(category, ''), solved, guess_count, started_at
FROM wordle_puzzles WHERE puzzle_id = ?`, today)
FROM wordle_puzzles WHERE solved = 0 ORDER BY started_at DESC`)
if err != nil {
slog.Warn("wordle: rehydrate query failed", "err", err)
return
}
seen := make(map[string]bool) // track rooms already handled
var pending []puzzleRow
for rows.Next() {
var pid, roomStr, answer, category string
@@ -760,10 +737,16 @@ func (p *WordlePlugin) rehydratePuzzles() {
continue
}
if solved == 1 || guessCount >= wordleMaxGuesses(wordLength) {
if guessCount >= wordleMaxGuesses(wordLength) {
continue // already done
}
// Only take the most recent unsolved puzzle per room.
if seen[roomStr] {
continue
}
seen[roomStr] = true
pending = append(pending, puzzleRow{pid, roomStr, answer, wordLength, category, startedAt})
}
rows.Close()
@@ -835,52 +818,3 @@ func (p *WordlePlugin) rehydratePuzzles() {
}
}
// midnightTicker checks every minute if it's time to post a new daily puzzle.
func (p *WordlePlugin) midnightTicker() {
// Check immediately on startup.
p.checkAndPostDaily()
ticker := time.NewTicker(1 * time.Minute)
defer ticker.Stop()
lastDate := time.Now().UTC().Format("2006-01-02")
for range ticker.C {
today := time.Now().UTC().Format("2006-01-02")
if today != lastDate {
lastDate = today
p.checkAndPostDaily()
}
}
}
func (p *WordlePlugin) checkAndPostDaily() {
gr := gamesRoom()
if gr == "" {
return
}
today := time.Now().UTC().Format("2006-01-02")
d := db.Get()
// Check if today's puzzle already exists for this room.
var exists int
err := d.QueryRow(
`SELECT 1 FROM wordle_puzzles WHERE puzzle_id = ? AND room_id = ?`,
today, string(gr),
).Scan(&exists)
if err == nil {
return // already exists
}
if err != sql.ErrNoRows {
slog.Error("wordle: check daily puzzle", "err", err)
return
}
// Announce expiry of yesterday's unsolved puzzle before creating the new one.
p.expireUnsolved(gr)
slog.Info("wordle: posting daily puzzle", "room", gr)
if err := p.createAndPostPuzzle(gr, p.defaultLength, WordleCategoryEN); err != nil {
slog.Error("wordle: daily puzzle failed", "err", err)
}
}

View File

@@ -74,7 +74,7 @@ func renderKeyboard(states map[rune]LetterResult) string {
// renderWordleStartAnnouncement renders the puzzle start message.
func renderWordleStartAnnouncement(puzzleNumber, wordLength int, hint string) string {
base := fmt.Sprintf(
"🟩 **Daily Wordle #%d**\nA new %d-letter puzzle is ready! Work together — %d guesses shared.",
"🟩 **Wordle #%d**\nA new %d-letter puzzle is ready! Work together — %d guesses shared.",
puzzleNumber, wordLength, wordleMaxGuesses(wordLength),
)
if hint != "" {