diff --git a/internal/db/db.go b/internal/db/db.go index 3323071..e15434b 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -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() { diff --git a/internal/plugin/adventure.go b/internal/plugin/adventure.go index e2fb776..cc8e019 100644 --- a/internal/plugin/adventure.go +++ b/internal/plugin/adventure.go @@ -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 `" + ` — 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.") +} + diff --git a/internal/plugin/adventure_activities.go b/internal/plugin/adventure_activities.go index c478d18..97d4400 100644 --- a/internal/plugin/adventure_activities.go +++ b/internal/plugin/adventure_activities.go @@ -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 } diff --git a/internal/plugin/adventure_arena.go b/internal/plugin/adventure_arena.go index e0daa79..1a8b62c 100644 --- a/internal/plugin/adventure_arena.go +++ b/internal/plugin/adventure_arena.go @@ -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)) } diff --git a/internal/plugin/adventure_babysit.go b/internal/plugin/adventure_babysit.go index f811f31..d69faf9 100644 --- a/internal/plugin/adventure_babysit.go +++ b/internal/plugin/adventure_babysit.go @@ -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": diff --git a/internal/plugin/adventure_character.go b/internal/plugin/adventure_character.go index 18966d7..6d6a756 100644 --- a/internal/plugin/adventure_character.go +++ b/internal/plugin/adventure_character.go @@ -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 } diff --git a/internal/plugin/adventure_flavor_fishing.go b/internal/plugin/adventure_flavor_fishing.go index 5d93171..aad66ce 100644 --- a/internal/plugin/adventure_flavor_fishing.go +++ b/internal/plugin/adventure_flavor_fishing.go @@ -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 diff --git a/internal/plugin/adventure_flavor_pets.go b/internal/plugin/adventure_flavor_pets.go new file mode 100644 index 0000000..5e7484c --- /dev/null +++ b/internal/plugin/adventure_flavor_pets.go @@ -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.", +} diff --git a/internal/plugin/adventure_housing.go b/internal/plugin/adventure_housing.go new file mode 100644 index 0000000..cc9c83f --- /dev/null +++ b/internal/plugin/adventure_housing.go @@ -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 ` 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 ` — 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 ` (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*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)) +} diff --git a/internal/plugin/adventure_housing_test.go b/internal/plugin/adventure_housing_test.go new file mode 100644 index 0000000..ebf56f2 --- /dev/null +++ b/internal/plugin/adventure_housing_test.go @@ -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) + } + } +} diff --git a/internal/plugin/adventure_mortgage.go b/internal/plugin/adventure_mortgage.go new file mode 100644 index 0000000..9f89cb3 --- /dev/null +++ b/internal/plugin/adventure_mortgage.go @@ -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)) +} diff --git a/internal/plugin/adventure_npcs.go b/internal/plugin/adventure_npcs.go index 2ade20a..42d196d 100644 --- a/internal/plugin/adventure_npcs.go +++ b/internal/plugin/adventure_npcs.go @@ -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)) } diff --git a/internal/plugin/adventure_pets.go b/internal/plugin/adventure_pets.go new file mode 100644 index 0000000..fc568cb --- /dev/null +++ b/internal/plugin/adventure_pets.go @@ -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 +} diff --git a/internal/plugin/adventure_pets_test.go b/internal/plugin/adventure_pets_test.go new file mode 100644 index 0000000..5f618f7 --- /dev/null +++ b/internal/plugin/adventure_pets_test.go @@ -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) + } + } +} diff --git a/internal/plugin/adventure_render.go b/internal/plugin/adventure_render.go index c537562..c52a311 100644 --- a/internal/plugin/adventure_render.go +++ b/internal/plugin/adventure_render.go @@ -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, }) } diff --git a/internal/plugin/adventure_rival.go b/internal/plugin/adventure_rival.go index 4c153fd..4a3e9a9 100644 --- a/internal/plugin/adventure_rival.go +++ b/internal/plugin/adventure_rival.go @@ -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)) diff --git a/internal/plugin/adventure_robbie.go b/internal/plugin/adventure_robbie.go index e38e692..50f9221 100644 --- a/internal/plugin/adventure_robbie.go +++ b/internal/plugin/adventure_robbie.go @@ -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') } diff --git a/internal/plugin/adventure_scheduler.go b/internal/plugin/adventure_scheduler.go index 51f016e..0005676 100644 --- a/internal/plugin/adventure_scheduler.go +++ b/internal/plugin/adventure_scheduler.go @@ -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) { diff --git a/internal/plugin/adventure_twinbee.go b/internal/plugin/adventure_twinbee.go index 2a2a6e8..06862ac 100644 --- a/internal/plugin/adventure_twinbee.go +++ b/internal/plugin/adventure_twinbee.go @@ -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 } diff --git a/internal/plugin/holdem.go b/internal/plugin/holdem.go index 85fe7d7..40a4c45 100644 --- a/internal/plugin/holdem.go +++ b/internal/plugin/holdem.go @@ -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 { diff --git a/internal/plugin/plugin.go b/internal/plugin/plugin.go index db3ece0..8e1d6bc 100644 --- a/internal/plugin/plugin.go +++ b/internal/plugin/plugin.go @@ -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 { diff --git a/internal/plugin/thom_krooke_mortgage_flavor.go b/internal/plugin/thom_krooke_mortgage_flavor.go new file mode 100644 index 0000000..568876c --- /dev/null +++ b/internal/plugin/thom_krooke_mortgage_flavor.go @@ -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. diff --git a/internal/plugin/wordle.go b/internal/plugin/wordle.go index d7d70f6..11e0703 100644 --- a/internal/plugin/wordle.go +++ b/internal/plugin/wordle.go @@ -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 ", Category: "Games"}, + {Name: "wordle", Description: "Guess the current Wordle puzzle", Usage: "!wordle ", 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) - } -} diff --git a/internal/plugin/wordle_render.go b/internal/plugin/wordle_render.go index 769c613..f7bdeaf 100644 --- a/internal/plugin/wordle_render.go +++ b/internal/plugin/wordle_render.go @@ -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 != "" {