mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 00:32:40 +00:00
Add housing/pets/Thom Krooke, wordle overhaul, boost system, backup strategy
Part 3 (Housing, Thom Krooke & Pets): - Housing system with tiered upgrades, mortgage loans, FRED API rates - Thom Krooke realtor NPC with !thom commands and !thom pay extra principal - Pet system with arrival, naming, leveling, combat, morning defense - Pet ditch recovery scales with level, name validation Wordle overhaul: - Remove daily expiration — puzzles persist until solved/failed - Auto-start new puzzle after solve, fail, or skip - Sequential puzzle IDs instead of date-based - Auto-create puzzle on first guess if none active Adventure fixes: - Double XP/money boost toggle (!adv boost, admin only) - Fix ensureCharacter wiping existing data on query errors - Fix rival RPS format string bug - Fix daily summary empty data (load today+yesterday logs) - Fix holdem DM-to-room reply routing - Fix Robbie payout to 25% of item value - Add fishing to leaderboard and TwinBee calculations - Add Thom to morning menu - Persist mortgage rate across restarts via db.CacheGet/Set Infrastructure: - Daily database backup via VACUUM INTO with 7-day retention - Admin DM notification on backup failure (async) - Daily NPC house balance reset for holdem Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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() {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math/rand/v2"
|
||||
@@ -9,6 +11,8 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
|
||||
"maunium.net/go/mautrix"
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
@@ -99,6 +103,7 @@ func (p *AdventurePlugin) Commands() []CommandDef {
|
||||
return []CommandDef{
|
||||
{Name: "adventure", Description: "Daily adventure game — dungeon, mine, forage, or rest", Usage: "!adventure", Category: "Games"},
|
||||
{Name: "arena", Description: "Arena combat — fight through 5 tiers of increasingly deadly monsters", Usage: "!arena", Category: "Games"},
|
||||
{Name: "thom", Description: "Visit Thom Krooke — housing and loans", Usage: "!thom", Category: "Games"},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,6 +138,7 @@ func (p *AdventurePlugin) Init() error {
|
||||
go p.rivalChallengeTicker()
|
||||
go p.robbieTicker()
|
||||
go p.hospitalNudgeTicker()
|
||||
go p.mortgageTicker()
|
||||
|
||||
// Auto-cashout any arena runs left in 'awaiting' from a prior restart
|
||||
p.arenaCleanupStaleRuns()
|
||||
@@ -193,7 +199,12 @@ func (p *AdventurePlugin) OnMessage(ctx MessageContext) error {
|
||||
})
|
||||
}
|
||||
|
||||
// 4. Command dispatch
|
||||
// 4. Thom Krooke commands (work in rooms and DMs)
|
||||
if p.IsCommand(ctx.Body, "thom") {
|
||||
return p.handleThomCmd(ctx)
|
||||
}
|
||||
|
||||
// 5. Command dispatch
|
||||
if !p.IsCommand(ctx.Body, "adventure") && !p.IsCommand(ctx.Body, "adv") {
|
||||
return nil
|
||||
}
|
||||
@@ -243,6 +254,8 @@ func (p *AdventurePlugin) dispatchCommand(ctx MessageContext) error {
|
||||
return p.handleRepairAllCmd(ctx)
|
||||
case strings.HasPrefix(lower, "repair "):
|
||||
return p.handleRepairSlotCmd(ctx, strings.TrimSpace(args[7:]))
|
||||
case lower == "boost":
|
||||
return p.handleBoostCmd(ctx)
|
||||
}
|
||||
|
||||
return p.SendDM(ctx.Sender, "Unknown command. Type `!adventure help` to see available commands.")
|
||||
@@ -266,6 +279,7 @@ const advHelpText = `**Adventure Commands**
|
||||
` + "`!adventure repair all`" + ` — Repair all damaged equipment
|
||||
` + "`!adventure repair <slot>`" + ` — Repair a specific slot
|
||||
` + "`!hospital`" + ` — Visit St. Guildmore's Memorial Hospital (same-day revival when dead)
|
||||
` + "`!thom`" + ` — Visit Thom Krooke (housing and loans)
|
||||
` + "`!adventure help`" + ` — This message
|
||||
|
||||
**Arena:**
|
||||
@@ -511,10 +525,15 @@ func (p *AdventurePlugin) handleDMReply(ctx MessageContext) error {
|
||||
|
||||
// Skip if it looks like a command for another plugin
|
||||
lower := strings.ToLower(body)
|
||||
if strings.HasPrefix(body, "!") && !strings.HasPrefix(lower, "!adventure") && !strings.HasPrefix(lower, "!adv") {
|
||||
if strings.HasPrefix(body, "!") && !strings.HasPrefix(lower, "!adventure") && !strings.HasPrefix(lower, "!adv") && !strings.HasPrefix(lower, "!thom") {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Handle !thom in DMs
|
||||
if strings.HasPrefix(lower, "!thom") {
|
||||
return p.handleThomCmd(ctx)
|
||||
}
|
||||
|
||||
// Strip !adventure / !adv prefix if present — dispatch directly to avoid recursion
|
||||
if strings.HasPrefix(lower, "!adventure") || strings.HasPrefix(lower, "!adv") {
|
||||
return p.dispatchCommand(ctx)
|
||||
@@ -568,6 +587,12 @@ func (p *AdventurePlugin) resolvePendingInteraction(ctx MessageContext, interact
|
||||
return p.resolveHospitalPay(ctx, interaction)
|
||||
case "npc_encounter":
|
||||
return p.resolveNPCEncounter(ctx, interaction)
|
||||
case "pet_arrival":
|
||||
return p.resolvePetArrival(ctx)
|
||||
case "pet_type":
|
||||
return p.resolvePetType(ctx)
|
||||
case "pet_name":
|
||||
return p.resolvePetName(ctx)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -790,6 +815,9 @@ func (p *AdventurePlugin) resolveActivity(ctx MessageContext, char *AdventureCha
|
||||
result.XPGained = int(float64(result.XPGained) * (1.0 + bonus))
|
||||
}
|
||||
|
||||
// Double XP/money boost
|
||||
advApplyBoost(result)
|
||||
|
||||
// Apply XP
|
||||
switch result.XPSkill {
|
||||
case "combat":
|
||||
@@ -874,6 +902,16 @@ func (p *AdventurePlugin) resolveActivity(ctx MessageContext, char *AdventureCha
|
||||
return p.SendDM(ctx.Sender, "Something went wrong saving your progress. Your action was not recorded. Try again.")
|
||||
}
|
||||
|
||||
// Pet XP
|
||||
if char.HasPet() && result.Outcome != AdvOutcomeDeath {
|
||||
if petGrantXP(char) {
|
||||
_ = saveAdvCharacter(char)
|
||||
_ = p.SendDM(char.UserID, fmt.Sprintf("🐾 %s leveled up to **Level %d**!", char.PetName, char.PetLevel))
|
||||
} else {
|
||||
_ = saveAdvCharacter(char)
|
||||
}
|
||||
}
|
||||
|
||||
// Save equipment changes
|
||||
for _, slot := range allSlots {
|
||||
if eq, ok := equip[slot]; ok {
|
||||
@@ -1203,7 +1241,12 @@ func (p *AdventurePlugin) selectFlavorText(char *AdventureCharacter, result *Adv
|
||||
func (p *AdventurePlugin) ensureCharacter(userID id.UserID) (*AdventureCharacter, map[EquipmentSlot]*AdvEquipment, error) {
|
||||
char, err := loadAdvCharacter(userID)
|
||||
if err != nil {
|
||||
// Auto-create
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
// Query error (e.g. missing column) — do NOT auto-create over existing data
|
||||
slog.Error("adventure: loadAdvCharacter failed", "user", userID, "err", err)
|
||||
return nil, nil, fmt.Errorf("failed to load character: %w", err)
|
||||
}
|
||||
// Genuinely new player — auto-create
|
||||
displayName := p.DisplayName(userID)
|
||||
if err := createAdvCharacter(userID, displayName); err != nil {
|
||||
return nil, nil, err
|
||||
@@ -1229,3 +1272,44 @@ func (p *AdventurePlugin) ensureCharacter(userID id.UserID) (*AdventureCharacter
|
||||
return char, equip, nil
|
||||
}
|
||||
|
||||
// ── Double XP/Money Boost ───────────────────────────────────────────────────
|
||||
|
||||
const advBoostCacheKey = "adv_boost_active"
|
||||
|
||||
func advBoostActive() bool {
|
||||
return db.CacheGet(advBoostCacheKey, 365*86400) == "1"
|
||||
}
|
||||
|
||||
func advSetBoost(active bool) {
|
||||
v := "0"
|
||||
if active {
|
||||
v = "1"
|
||||
}
|
||||
db.CacheSet(advBoostCacheKey, v)
|
||||
}
|
||||
|
||||
func advApplyBoost(result *AdvActionResult) {
|
||||
if !advBoostActive() {
|
||||
return
|
||||
}
|
||||
result.XPGained *= 2
|
||||
result.TotalLootValue = 0
|
||||
for i := range result.LootItems {
|
||||
result.LootItems[i].Value *= 2
|
||||
result.TotalLootValue += result.LootItems[i].Value
|
||||
}
|
||||
}
|
||||
|
||||
func (p *AdventurePlugin) handleBoostCmd(ctx MessageContext) error {
|
||||
if !p.IsAdmin(ctx.Sender) {
|
||||
return nil
|
||||
}
|
||||
|
||||
if advBoostActive() {
|
||||
advSetBoost(false)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "⚡ Double XP/money boost **disabled**.")
|
||||
}
|
||||
advSetBoost(true)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "⚡ Double XP/money boost **enabled**! All adventure XP and loot values are doubled.")
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
363
internal/plugin/adventure_flavor_pets.go
Normal file
363
internal/plugin/adventure_flavor_pets.go
Normal file
@@ -0,0 +1,363 @@
|
||||
package plugin
|
||||
|
||||
// ── IMPORTANT: DO NOT REWRITE, SUMMARIZE, OR SHORTEN ANY ENTRIES IN THIS FILE ──
|
||||
//
|
||||
// This file contains hand-written flavor text. Every entry is the length it is
|
||||
// on purpose. The voice, pacing, and specific wording are intentional.
|
||||
// If an entry seems too long: it isn't. Leave it alone.
|
||||
// If you are tempted to "clean up" or "condense" entries: don't.
|
||||
// If your context window is too small to fit this file: that is your problem, not ours.
|
||||
//
|
||||
// ── PET FLAVOR TEXT ───────────────────────────────────────────────────────────
|
||||
//
|
||||
// Two pets. One register each.
|
||||
//
|
||||
// The Dog: enthusiastic, physical, zero technique, completely committed.
|
||||
// Loves you unconditionally and expresses this through violence on your behalf.
|
||||
// The Cat: massive, precise, indifferent. Helps occasionally. Does not explain why.
|
||||
// The enemy should feel honored and also concerned.
|
||||
//
|
||||
// Both: never die, never leave, have opinions about dinner.
|
||||
|
||||
// ── DOG ATTACK ────────────────────────────────────────────────────────────────
|
||||
|
||||
var PetDogAttack = []string{
|
||||
"Your dog has decided to get involved.\n\n" +
|
||||
"Not strategically. Not tactically. Just fully and immediately.\n\n" +
|
||||
"{enemy} takes {damage} damage and a lot of general chaos.",
|
||||
|
||||
"Your dog launches itself at {enemy} with the energy of something " +
|
||||
"that has never once considered consequences.\n\n" +
|
||||
"{damage} damage. Your dog lands, shakes itself off, and looks at you " +
|
||||
"like it just did the most normal thing in the world.\n\nIt did not.",
|
||||
|
||||
"Your dog has joined the fight.\n\n" +
|
||||
"Your dog did not ask permission.\n\n" +
|
||||
"Your dog never asks permission.\n\n" +
|
||||
"{enemy} takes {damage} damage. Your dog takes a victory lap.",
|
||||
|
||||
"Without warning, without strategy, and without any apparent plan beyond " +
|
||||
"extreme commitment, your dog hits {enemy} for {damage} damage.\n\n" +
|
||||
"The crowd goes absolutely feral.",
|
||||
|
||||
"Your dog sees an opening that you did not see, will never see, " +
|
||||
"and could not explain if asked.\n\n" +
|
||||
"It takes it. {damage} damage to {enemy}.\n\n" +
|
||||
"The dog returns to your side looking very pleased with itself.\n\n" +
|
||||
"It should be.",
|
||||
|
||||
"Your dog barrels into {enemy} at full speed with its entire body weight, " +
|
||||
"which is considerable, because it is a massive dog.\n\n" +
|
||||
"{damage} damage.\n\nPhysics was involved.",
|
||||
|
||||
"Your dog bites {enemy}.\n\nDeliberately.\n\nRepeatedly.\n\n" +
|
||||
"{damage} damage total.\n\nYour dog lets go when it feels like it.",
|
||||
|
||||
"Your dog has entered the arena.\n\nYour dog was not supposed to enter the arena.\n\n" +
|
||||
"Your dog did not read the rules.\n\nYour dog cannot read.\n\n" +
|
||||
"{enemy} takes {damage} damage. The rules remain technically intact.",
|
||||
}
|
||||
|
||||
// ── CAT ATTACK ────────────────────────────────────────────────────────────────
|
||||
|
||||
var PetCatAttack = []string{
|
||||
"Your cat intervenes.\n\n" +
|
||||
"Once. Precisely. Without looking at you afterward.\n\n" +
|
||||
"{enemy} takes {damage} damage and is left with the unsettling feeling " +
|
||||
"that this was a favor they did not earn.",
|
||||
|
||||
"Your cat has briefly decided you are worth defending.\n\n" +
|
||||
"It acts on this decision with surgical efficiency.\n\n" +
|
||||
"{damage} damage to {enemy}.\n\n" +
|
||||
"The decision has already been rescinded. Don't read into it.",
|
||||
|
||||
"Your cat moves.\n\nThat's all.\n\nYour cat just moves.\n\n" +
|
||||
"{enemy} takes {damage} damage and spends the rest of the round " +
|
||||
"trying to understand what happened.",
|
||||
|
||||
"Your cat looks at {enemy}.\n\nThen at you.\n\nThen back at {enemy}.\n\n" +
|
||||
"Then it handles it.\n\n{damage} damage.\n\nYour cat sits back down.",
|
||||
|
||||
"Your cat has, for reasons it will not share, chosen this moment.\n\n" +
|
||||
"{damage} damage. Clean. Efficient. Completely without ego.\n\n" +
|
||||
"Your cat is already elsewhere.",
|
||||
|
||||
"The cat moves through the fight like it owns the arena.\n\n" +
|
||||
"It does not own the arena.\n\nThe arena is reconsidering its position on this.\n\n" +
|
||||
"{enemy} takes {damage} damage. The cat does not acknowledge the applause.",
|
||||
|
||||
"Your cat strikes {enemy} once.\n\nIt does not strike twice.\n\n" +
|
||||
"It did not need to.\n\n{damage} damage.\n\n" +
|
||||
"Your cat has returned to doing whatever it was doing before, " +
|
||||
"which was nothing, and also everything.",
|
||||
|
||||
"Your massive cat drops from somewhere it wasn't a moment ago " +
|
||||
"and lands directly on {enemy}'s immediate future.\n\n" +
|
||||
"{damage} damage.\n\nYour cat leaves without comment.\n\n" +
|
||||
"{enemy} has questions. The cat will not be answering them.",
|
||||
}
|
||||
|
||||
// ── DOG DEATH REACTION ────────────────────────────────────────────────────────
|
||||
|
||||
var PetDogDeath = []string{
|
||||
"Your dog was napping as your foe finished you off.\n\n" +
|
||||
"They're upset now.\n\nNot because of what happened to you.\n\n" +
|
||||
"Because dinner won't be arriving on time today.",
|
||||
|
||||
"Your dog watched the whole thing.\n\nDid nothing.\n\nIs now sitting by the door.\n\n" +
|
||||
"Not waiting for you to come back.\n\nWaiting for whoever brings the food to come back.\n\n" +
|
||||
"These are different.",
|
||||
|
||||
"Your dog has located your empty bowl and is moving it around the kitchen floor " +
|
||||
"to let someone know there's been some kind of administrative error.",
|
||||
|
||||
"Your dog is fine.\n\nYour dog is great, actually.\n\n" +
|
||||
"Your dog found something on the floor and ate it and is having a wonderful afternoon.\n\n" +
|
||||
"You were gone, they noted. Briefly. Then the floor thing happened.",
|
||||
|
||||
"Your dog is sitting in front of your equipment and looking at it.\n\n" +
|
||||
"Not mournfully.\n\nSpeculatively.\n\n" +
|
||||
"Your dog has never fully ruled out that the equipment is food.",
|
||||
|
||||
"Your dog is howling.\n\nNot in grief.\n\nIn the specific register of a dog " +
|
||||
"who has been waiting an unreasonable amount of time for their walk.\n\n" +
|
||||
"The neighbors are aware.",
|
||||
|
||||
"Your dog is absolutely fine and has already made several new friends " +
|
||||
"who came to check on the situation.\n\n" +
|
||||
"Your dog showed them around.\n\nYour dog let them pet it.\n\n" +
|
||||
"Your dog has no concept of what just happened to you and is having the best day.",
|
||||
}
|
||||
|
||||
// ── CAT DEATH REACTION ────────────────────────────────────────────────────────
|
||||
|
||||
var PetCatDeath = []string{
|
||||
"Your cat is aware you died.\n\n" +
|
||||
"Your cat has elected not to comment at this time.\n\n" +
|
||||
"Dinner, however, is now late, and your cat has many comments about that.",
|
||||
|
||||
"Your cat watched you lose from across the arena.\n\n" +
|
||||
"Its expression did not change.\n\n" +
|
||||
"Its expression never changes.\n\n" +
|
||||
"Dinner is late. Your cat's expression has changed.",
|
||||
|
||||
"Your cat is sitting on your things.\n\nNot to mourn.\n\n" +
|
||||
"Your cat sits on things. That's just what it does.\n\n" +
|
||||
"Dinner being late is a separate and more pressing issue " +
|
||||
"that your cat would like addressed immediately.",
|
||||
|
||||
"Your cat has knocked something off a surface.\n\n" +
|
||||
"Not in grief.\n\nJust because it was there.\n\n" +
|
||||
"Dinner is late. Your cat will continue knocking things off surfaces " +
|
||||
"until this is corrected.",
|
||||
|
||||
"Your cat is fine.\n\nYour cat is always fine.\n\n" +
|
||||
"Your cat was fine before you and will be fine after.\n\n" +
|
||||
"Dinner is late and your cat is making it everyone's problem.",
|
||||
|
||||
"Your cat has sat down directly in the center of the room and is staring at the wall.\n\n" +
|
||||
"This could mean anything.\n\nIt means dinner is late.",
|
||||
}
|
||||
|
||||
// ── DOG VICTORY REACTION ──────────────────────────────────────────────────────
|
||||
// Fires occasionally. Not every win. The dog has other things going on.
|
||||
|
||||
var PetDogVictory = []string{
|
||||
"Your dog is aware you won.\n\nYour dog is losing its mind about this.\n\n" +
|
||||
"Your dog has won every fight you have ever been in, " +
|
||||
"in the sense that it loves you and you came home, " +
|
||||
"and it is treating this occasion with the same energy as all the others, " +
|
||||
"which is: maximum.",
|
||||
|
||||
"Your dog is running in circles.\n\nNot for any reason.\n\n" +
|
||||
"Just because you won and it's a good day and " +
|
||||
"running in circles is how your dog processes good days.",
|
||||
|
||||
"Your dog has brought you something.\n\nYou don't know what it is.\n\n" +
|
||||
"Your dog is very proud of it.\n\nYou accept it.\n\n" +
|
||||
"This is what winning looks like.",
|
||||
|
||||
"Your dog is pressed against your leg.\n\nHard.\n\n" +
|
||||
"Your dog does not fully understand what the arena is.\n\n" +
|
||||
"Your dog understands that you left and came back.\n\n" +
|
||||
"Your dog finds this extremely good news every single time.",
|
||||
|
||||
"Your dog has decided that the victory belongs to both of you equally " +
|
||||
"and is accepting congratulations from nearby strangers on this basis.\n\n" +
|
||||
"The strangers are obliging.\n\nThe dog deserves it.",
|
||||
}
|
||||
|
||||
// ── CAT VICTORY REACTION ──────────────────────────────────────────────────────
|
||||
// Fires rarely. The cat has acknowledged maybe three of your wins. Total.
|
||||
|
||||
var PetCatVictory = []string{
|
||||
"Your cat glances at you.\n\nOnce.\n\nThen away.\n\n" +
|
||||
"This is the cat equivalent of a standing ovation.\n\n" +
|
||||
"You will not receive another one this week.",
|
||||
|
||||
"Your cat is sitting near you.\n\nNot with you.\n\nNear you.\n\n" +
|
||||
"This is as close as it gets to celebration.\n\n" +
|
||||
"Take it.",
|
||||
|
||||
"Your cat blinks slowly in your direction.\n\n" +
|
||||
"You have been approved of.\n\n" +
|
||||
"Briefly.\n\nConditionally.\n\nDon't push it.",
|
||||
|
||||
"Your cat has moved to a slightly closer position than usual.\n\n" +
|
||||
"It will not explain this.\n\nIt may move back.\n\n" +
|
||||
"For now: proximity. That's the win.",
|
||||
}
|
||||
|
||||
// ── DOG DEFLECT ───────────────────────────────────────────────────────────────
|
||||
|
||||
var PetDogDeflect = []string{
|
||||
"Your dog steps in front of {enemy}'s attack.\n\n" +
|
||||
"Not strategically.\n\nJust because you were there and the thing coming at you " +
|
||||
"was also coming at you and your dog had opinions about that.\n\n" +
|
||||
"{enemy} misses.\n\nYour dog looks very pleased with itself.",
|
||||
|
||||
"Your dog saw it coming before you did.\n\n" +
|
||||
"Your dog always sees it coming before you do.\n\n" +
|
||||
"You've never fully processed this.\n\n" +
|
||||
"{enemy}'s strike lands on nothing.\n\nYour dog is already back at your side.",
|
||||
|
||||
"{enemy} swings.\n\nYour dog moves.\n\n" +
|
||||
"These two things happened in the wrong order for {enemy}.\n\n" +
|
||||
"The attack misses and causes a dog treat to fall out of its pocket.\n\n" +
|
||||
"Mission accomplished.. treat acquired! ..Oh and you weren't hit.. I guess. ...yay.",
|
||||
|
||||
"Your dog body-checks {enemy}'s weapon arm at full speed.\n\n" +
|
||||
"This was not a trained technique.\n\n" +
|
||||
"This was a massive dog moving very fast toward something it didn't like.\n\n" +
|
||||
"The attack goes wide.\n\nPhysics handled it.",
|
||||
|
||||
"{enemy} commits to the strike.\n\nYour dog doesn't like what it sees and barks loudly enough to startle {enemy} so strongly that it yelps.\n\n" +
|
||||
"This would have been awesome if you hadn't also yelped as well.\n\n" +
|
||||
"Either way.. {enemy} misses!\n\n",
|
||||
|
||||
"Your dog throws itself between you and {enemy}'s attack " +
|
||||
"with the energy of something that has never once considered " +
|
||||
"that this might not work.\n\n" +
|
||||
"It works.\n\n{enemy} misses.\n\nYour dog shakes itself off and keeps going.",
|
||||
}
|
||||
|
||||
// ── CAT DEFLECT ───────────────────────────────────────────────────────────────
|
||||
|
||||
var PetCatDeflect = []string{
|
||||
"Your cat moves once.\n\n" +
|
||||
"{enemy}'s attack finds nothing.\n\n" +
|
||||
"Your cat does not look at {enemy}.\n\n" +
|
||||
"Your cat does not look at you.\n\n" +
|
||||
"Your cat has already moved on.",
|
||||
|
||||
"{enemy} strikes.\n\nYour cat was there.\n\nThen your cat wasn't.\n\n" +
|
||||
"The attack misses.\n\nYour cat is now somewhere else entirely.\n\n" +
|
||||
"Nobody saw it move.",
|
||||
|
||||
"Your cat steps into the path of {enemy}'s attack " +
|
||||
"and then out of it in a single motion that takes approximately no time.\n\n" +
|
||||
"{enemy} misses.\n\nYour cat sits down.\n\n" +
|
||||
"The crowd roars excitedly at the spectacle which you foolishly believe is for you " +
|
||||
"until the announcer \"helpfully\" corrects you.",
|
||||
|
||||
"Your cat redirects {enemy}'s strike with one paw.\n\n" +
|
||||
"Casually.\n\nLike it had something else to do and this was briefly in the way.\n\n" +
|
||||
"The attack goes wide.\n\nYour cat returns to having something else to do.",
|
||||
|
||||
"{enemy} swings.\n\nYour cat looks at the swing.\n\n" +
|
||||
"Your cat decides against it.\n\n" +
|
||||
"The swing finds nothing.\n\n" +
|
||||
"Your cat had already made its decision before {enemy} had finished committing.\n\n" +
|
||||
"That's the difference.",
|
||||
|
||||
"Your cat inserts itself into the situation briefly and then removes itself.\n\n" +
|
||||
"{enemy}'s attack lands on the space your cat just vacated.\n\n" +
|
||||
"Your cat is already somewhere else.\n\nIt blinks.\n\nNot at you.\n\nAt nothing.\n\n" +
|
||||
"The way cats do.",
|
||||
}
|
||||
// Fires randomly in the morning DM. Cat has left something.
|
||||
// Results in a defense boost for the day.
|
||||
// The cat is proud. The cat will never stop doing this.
|
||||
|
||||
var PetCatOffering = []string{
|
||||
"There is half a rat on your doorstep.\n\n" +
|
||||
"You notice that your cat is looking at you from a distance.\n\n" +
|
||||
"You pretend to take a bite.\n\nYour cat looks genuinely upset.\n\n" +
|
||||
"Defense increased for today.",
|
||||
|
||||
"Something has been left at your door.\n\nSomething that was recently alive.\n\n" +
|
||||
"Your cat has arranged it thoughtfully.\n\nThis took effort.\n\n" +
|
||||
"You are moved in a way you did not expect and cannot fully explain.\n\n" +
|
||||
"Defense increased for today.",
|
||||
|
||||
"Your cat has brought you a bird.\n\nMost of a bird.\n\n" +
|
||||
"Your cat is extremely pleased with itself.\n\n" +
|
||||
"You look at the bird.\n\nYou look at your cat.\n\n" +
|
||||
"Your cat has never once doubted you (in a way that *you* would notice anyway).\n\n" +
|
||||
"You head out with that energy.\n\nDefense increased for today.",
|
||||
|
||||
"There is something on your doorstep that you will not be describing in detail.\n\n" +
|
||||
"Your cat is sitting beside it with the composure of someone " +
|
||||
"who has provided and would like acknowledgement.\n\n" +
|
||||
"Your horrified expression pleases the cat and you are now ready for anything.\n\nDefense increased for today.",
|
||||
|
||||
"Your cat hunted last night.\n\nYour cat hunted successfully.\n\n" +
|
||||
"Your cat brought the evidence to your door because you are its person " +
|
||||
"and it wanted you to know that things have been handled.\n\n" +
|
||||
"Things have been handled.\n\nDefense increased for today.",
|
||||
|
||||
"Half a mouse.\n\nYour cat.\n\nThe specific expression of an animal that loves you " +
|
||||
"in the only language it fully trusts.\n\n" +
|
||||
"You stand there for a moment.\n\nYou go inside.\n\nYou come back with a treat.\n\n" +
|
||||
"Your cat sniffs it and eats the rest of the mouse instead.\n\n" +
|
||||
"Defense increased for today.",
|
||||
}
|
||||
|
||||
// ── DOG MORNING SMOTHERING ────────────────────────────────────────────────────
|
||||
// Fires randomly in the morning DM. Dog has slept on top of the player.
|
||||
// Results in a defense boost for the day.
|
||||
// The dog has no idea. The dog never has any idea.
|
||||
|
||||
var PetDogSmothering = []string{
|
||||
"You woke up this morning unable to breathe.\n\n" +
|
||||
"This was because your dog was on top of you.\n\nAll of your dog.\n\n" +
|
||||
"Your dog was asleep.\n\nYour dog is still asleep.\n\n" +
|
||||
"You moved your dog.\n\nYour dog made a noise.\n\n" +
|
||||
"You lay there for a moment, alive, aware of it.\n\n" +
|
||||
"Defense increased for today.",
|
||||
|
||||
"At some point last night your dog relocated from its bed to your chest.\n\n" +
|
||||
"Your dog weighs a considerable amount because it is a massive dog.\n\n" +
|
||||
"You slept under this weight for several hours.\n\n" +
|
||||
"You feel like you have survived something.\n\nYou have survived something.\n\n" +
|
||||
"Defense increased for today.",
|
||||
|
||||
"Your dog was on your legs when you woke up.\n\n" +
|
||||
"Your dog was also somehow on your chest.\n\n" +
|
||||
"The physics of this are unclear.\n\n" +
|
||||
"You have no feeling in your lower half.\n\n" +
|
||||
"You have never felt more alive.\n\nDefense increased for today.",
|
||||
|
||||
"You woke up with your dog's full weight distributed across your torso " +
|
||||
"in a way that suggests your dog spent the night actively trying to become part of you.\n\n" +
|
||||
"Your dog succeeded, spiritually.\n\n" +
|
||||
"You are a unit now.\n\nDefense increased for today.",
|
||||
|
||||
"Your dog smothered you last night.\n\nNot with malice.\n\nWith love.\n\n" +
|
||||
"There is no meaningful difference in outcome but the intent matters " +
|
||||
"and the intent was pure.\n\n" +
|
||||
"You made it.\n\nDefense increased for today.",
|
||||
|
||||
"You nearly died in your own bed.\n\n" +
|
||||
"Your dog doesn't know this.\n\nYour dog is wagging its tail.\n\n" +
|
||||
"Your dog slept better than it ever has.\n\n" +
|
||||
"You look at your dog.\n\nYour dog is wagging its tail.\n\n" +
|
||||
"Despite the near death experience, you feel all is right with the world at that moment.\n\n" +
|
||||
"Defense increased for today.",
|
||||
|
||||
"Your dog is 'little spoon' in a technical sense only.\n\n" +
|
||||
"In practice your dog is the entire bed and you are whatever " +
|
||||
"fits in the remaining space, which last night was: not much.\n\n" +
|
||||
"You woke up on the edge.\n\nYou did not fall.\n\n" +
|
||||
"You are tougher than you thought.\n\nDefense increased for today.",
|
||||
}
|
||||
759
internal/plugin/adventure_housing.go
Normal file
759
internal/plugin/adventure_housing.go
Normal file
@@ -0,0 +1,759 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math/rand/v2"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ── Housing Tier Definitions ───────────────────────────────────────────────
|
||||
|
||||
type HouseTierDef struct {
|
||||
Tier int
|
||||
Name string
|
||||
Description string
|
||||
BasePrice int
|
||||
}
|
||||
|
||||
// HouseTier 0 = no house. Purchasable tiers are 1-4.
|
||||
var houseTiers = []HouseTierDef{
|
||||
{Tier: 1, Name: "Base House", Description: "Four walls and a roof. 'Livable' is a strong word.", BasePrice: 75000},
|
||||
{Tier: 2, Name: "Livable", Description: "A place you can sit down in without questioning your life choices.", BasePrice: 150000},
|
||||
{Tier: 3, Name: "Comfortable", Description: "Walls that don't leak. A door that locks. Luxuries of the modern age.", BasePrice: 300000},
|
||||
{Tier: 4, Name: "Established", Description: "This is a house that says things about you. Good things. Mostly.", BasePrice: 600000},
|
||||
}
|
||||
|
||||
// houseTierByTier returns the tier definition for a given tier number, or nil.
|
||||
func houseTierByTier(tier int) *HouseTierDef {
|
||||
for i := range houseTiers {
|
||||
if houseTiers[i].Tier == tier {
|
||||
return &houseTiers[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// houseNextTier returns the next purchasable tier definition, or nil if maxed.
|
||||
func houseNextTier(currentTier int) *HouseTierDef {
|
||||
for i := range houseTiers {
|
||||
if houseTiers[i].Tier == currentTier+1 {
|
||||
return &houseTiers[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// houseClosingCosts returns 5% closing + 6% realtor fees = 11% of base price.
|
||||
func houseClosingCosts(basePrice int) int {
|
||||
return int(float64(basePrice) * 0.11)
|
||||
}
|
||||
|
||||
// houseTotalCost returns base price + closing costs + realtor fees.
|
||||
func houseTotalCost(basePrice int) int {
|
||||
return basePrice + houseClosingCosts(basePrice)
|
||||
}
|
||||
|
||||
// houseWeeklyPayment calculates weekly payment based on balance and rate.
|
||||
// Uses a simple amortization: interest + fixed principal portion.
|
||||
// Rate is annual percentage (e.g., 6.5 means 6.5%).
|
||||
func houseWeeklyPayment(balance int, annualRate float64) int {
|
||||
if balance <= 0 || annualRate <= 0 {
|
||||
return 0
|
||||
}
|
||||
weeklyRate := annualRate / 100.0 / 52.0
|
||||
interest := float64(balance) * weeklyRate
|
||||
// Principal portion: 0.5% of balance per week (~26% of balance per year)
|
||||
// Ensures loans pay off in roughly 2-4 years of active play.
|
||||
principal := float64(balance) * 0.005
|
||||
payment := int(interest + principal)
|
||||
if payment < 1 {
|
||||
payment = 1
|
||||
}
|
||||
return payment
|
||||
}
|
||||
|
||||
// houseMissedPenalty returns the penalty for a missed payment (5% of weekly payment).
|
||||
func houseMissedPenalty(weeklyPayment int) int {
|
||||
penalty := int(float64(weeklyPayment) * 0.05)
|
||||
if penalty < 1 {
|
||||
penalty = 1
|
||||
}
|
||||
return penalty
|
||||
}
|
||||
|
||||
// ── Pending Interaction Types ──────────────────────────────────────────────
|
||||
|
||||
type advPendingHouseConfirm struct {
|
||||
Tier int
|
||||
TotalCost int
|
||||
DownPayment int
|
||||
}
|
||||
|
||||
type advPendingHouseDownPayment struct {
|
||||
Tier int
|
||||
TotalCost int
|
||||
}
|
||||
|
||||
type advPendingHouseAutopay struct{}
|
||||
|
||||
type advPendingPetArrival struct{}
|
||||
|
||||
type advPendingPetType struct{}
|
||||
|
||||
type advPendingPetName struct {
|
||||
PetType string
|
||||
}
|
||||
|
||||
// ── Thom Krooke Greeting ───────────────────────────────────────────────────
|
||||
|
||||
var thomGreetingsPreAdoption = []string{
|
||||
"Welcome in! Great timing. I've got something perfect for you. I say that to everyone and I mean it every time.",
|
||||
"You don't look like someone ready to make a smart investment. I can work with that. Sit down. Don't touch anything.",
|
||||
"Thom Krooke, Krooke Realty. No relation to that other guy. Mostly.",
|
||||
}
|
||||
|
||||
var thomGreetingsPostAdoption = []string{
|
||||
"You're back. Good. I've got things.",
|
||||
"Oh. You. Sure. What do you need.",
|
||||
"Come in. Wipe your feet. How's the animal.",
|
||||
}
|
||||
|
||||
var thomGreetingsPostLevel10 = []string{
|
||||
"Hello, {pet_name}'s caretaker. The usual?",
|
||||
"Ah. {pet_name}'s caretaker. I've been expecting you. I have things {pet_name} needs.",
|
||||
"Come in, {pet_name} and the caretaker! Wipe your feet! How are things? *You open your mouth to answer but quickly notice Thom is speaking to your pet.*",
|
||||
}
|
||||
|
||||
var thomHouseSellingLines = []string{
|
||||
"Charming property. Full of character. 'Character' means different things to different people and that's what makes real estate exciting.",
|
||||
"Minor structural considerations that a positive attitude will absolutely address. Price reflects the opportunity.",
|
||||
"Previous owner left in a hurry. Didn't say why. The chalk outlines in the living room are almost certainly decorative. Kids do that now apparently.",
|
||||
}
|
||||
|
||||
const thomAnimalLine = "I heard an animal got in your house. Luckily for you, it's a sweet one that will give you the world in exchange for a tiny bit of kindness. ...that's what I heard anyway."
|
||||
|
||||
var thomMissedPaymentNoPet = "Missed payment. Penalty applied. Balance is now €%d. Sort it out."
|
||||
var thomMissedPaymentPet = "Missed payment. Penalty applied. Balance is now €%d. %s doesn't need to know about this. Keep it together."
|
||||
var thomFreezeNoPet = "Ten missed payments. I've stopped trying for now. The house is yours. The debt is yours. Come see me when you're ready to talk about it."
|
||||
var thomFreezePet = "Ten missed payments. I've stopped trying for now. The house is yours. The debt is yours. Come see me when you're ready to talk about it. %s is fine. You should work on being fine."
|
||||
var thomPaidOffNoPet = "Paid off. Noted. The next tier is available when you're ready. I'll be here."
|
||||
var thomPaidOffPet = "Paid off. Good. Come in when you're ready for the next tier. I've been thinking about what %s would need in a better space. I have thoughts."
|
||||
|
||||
// thomGreeting returns an appropriate greeting based on pet state.
|
||||
func thomGreeting(char *AdventureCharacter) string {
|
||||
if char.HasPet() && char.PetLevel >= 10 {
|
||||
line := thomGreetingsPostLevel10[rand.IntN(len(thomGreetingsPostLevel10))]
|
||||
return strings.ReplaceAll(line, "{pet_name}", char.PetName)
|
||||
}
|
||||
if char.HasPet() {
|
||||
return thomGreetingsPostAdoption[rand.IntN(len(thomGreetingsPostAdoption))]
|
||||
}
|
||||
return thomGreetingsPreAdoption[rand.IntN(len(thomGreetingsPreAdoption))]
|
||||
}
|
||||
|
||||
// ── Thom Shop Display ──────────────────────────────────────────────────────
|
||||
|
||||
func thomShopView(char *AdventureCharacter, balance float64) string {
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString("🏠 **Krooke Realty**\n")
|
||||
sb.WriteString(fmt.Sprintf("💰 Balance: €%.0f\n\n", balance))
|
||||
sb.WriteString(fmt.Sprintf("*%s*\n\n", thomGreeting(char)))
|
||||
|
||||
// Check for animal line (fires once after pet appears)
|
||||
if char.HasPet() && !char.ThomAnimalLineFired {
|
||||
sb.WriteString(fmt.Sprintf("*%s*\n\n", thomAnimalLine))
|
||||
}
|
||||
|
||||
// Current housing status
|
||||
if !char.HasHouse() {
|
||||
sb.WriteString("You don't own a house yet.\n\n")
|
||||
} else {
|
||||
tierDef := houseTierByTier(char.HouseTier)
|
||||
tierName := "Unknown"
|
||||
if tierDef != nil {
|
||||
tierName = tierDef.Name
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("🏡 Your house: **%s** (Tier %d)\n", tierName, char.HouseTier))
|
||||
if char.HouseLoanBalance > 0 {
|
||||
weekly := houseWeeklyPayment(char.HouseLoanBalance, char.HouseCurrentRate)
|
||||
if char.HouseAutopay {
|
||||
weekly = int(float64(weekly) * 0.98) // 2% autopay discount
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("💳 Loan balance: €%d | Weekly: €%d", char.HouseLoanBalance, weekly))
|
||||
if char.HouseAutopay {
|
||||
sb.WriteString(" (autopay)")
|
||||
}
|
||||
if char.HouseLoanFrozen {
|
||||
sb.WriteString(" ❄️ FROZEN")
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf(" | Rate: %.2f%%\n", char.HouseCurrentRate))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
// Available upgrades
|
||||
if char.HouseLoanBalance > 0 {
|
||||
sb.WriteString("Pay off your current loan before upgrading.\n")
|
||||
sb.WriteString(fmt.Sprintf("\nTo pay off early: `!thom payoff`\n"))
|
||||
if !char.HouseAutopay {
|
||||
sb.WriteString("To enable autopay (2% discount): `!thom autopay`\n")
|
||||
}
|
||||
} else {
|
||||
// Can purchase next tier
|
||||
nextDef := houseNextTier(char.HouseTier)
|
||||
if nextDef == nil {
|
||||
sb.WriteString("✨ Max tier reached. There is nothing left to sell you. This has never happened before.\n")
|
||||
} else {
|
||||
def := *nextDef
|
||||
total := houseTotalCost(def.BasePrice)
|
||||
selling := thomHouseSellingLines[rand.IntN(len(thomHouseSellingLines))]
|
||||
sb.WriteString(fmt.Sprintf("*%s*\n\n", selling))
|
||||
sb.WriteString(fmt.Sprintf("📋 **%s** (Tier %d)\n", def.Name, def.Tier))
|
||||
sb.WriteString(fmt.Sprintf(" Base price: €%d\n", def.BasePrice))
|
||||
sb.WriteString(fmt.Sprintf(" Closing costs (5%%): €%d\n", int(float64(def.BasePrice)*0.05)))
|
||||
sb.WriteString(fmt.Sprintf(" Realtor fees (6%%): €%d\n", int(float64(def.BasePrice)*0.06)))
|
||||
sb.WriteString(fmt.Sprintf(" **Total: €%d**\n\n", total))
|
||||
sb.WriteString(fmt.Sprintf("Reply `buy` to purchase, or `buy <amount>` for a down payment.\n"))
|
||||
}
|
||||
}
|
||||
|
||||
// Pet supply shop (unlocks 1 week after pet level 10)
|
||||
if char.PetSupplyShopUnlocked && char.HasPet() {
|
||||
sb.WriteString("\n---\n")
|
||||
sb.WriteString(fmt.Sprintf("🐾 **Pet Supplies** — for %s\n\n", char.PetName))
|
||||
sb.WriteString(petArmorShopView(char))
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// ── Thom Shop Command Handler ──────────────────────────────────────────────
|
||||
|
||||
func (p *AdventurePlugin) handleThomCmd(ctx MessageContext) error {
|
||||
args := strings.TrimSpace(p.GetArgs(ctx.Body, "thom"))
|
||||
|
||||
char, _, err := p.ensureCharacter(ctx.Sender)
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender, "Failed to load your character.")
|
||||
}
|
||||
balance := p.euro.GetBalance(ctx.Sender)
|
||||
|
||||
lower := strings.ToLower(args)
|
||||
|
||||
switch {
|
||||
case args == "" || lower == "shop":
|
||||
text := thomShopView(char, balance)
|
||||
// Mark animal line as fired if applicable
|
||||
if char.HasPet() && !char.ThomAnimalLineFired {
|
||||
userMu := p.advUserLock(ctx.Sender)
|
||||
userMu.Lock()
|
||||
char.ThomAnimalLineFired = true
|
||||
_ = saveAdvCharacter(char)
|
||||
userMu.Unlock()
|
||||
}
|
||||
return p.SendDM(ctx.Sender, text)
|
||||
|
||||
case lower == "payoff":
|
||||
return p.handleThomPayoff(ctx, char)
|
||||
|
||||
case strings.HasPrefix(lower, "pay "):
|
||||
return p.handleThomPay(ctx, char, strings.TrimSpace(lower[4:]))
|
||||
|
||||
case lower == "autopay":
|
||||
return p.handleThomAutopay(ctx, char)
|
||||
|
||||
case lower == "buy" || strings.HasPrefix(lower, "buy "):
|
||||
return p.handleThomBuy(ctx, char, balance, strings.TrimSpace(strings.TrimPrefix(lower, "buy")))
|
||||
|
||||
case strings.HasPrefix(lower, "petbuy "):
|
||||
return p.handlePetArmorBuy(ctx, char, strings.TrimSpace(args[7:]))
|
||||
|
||||
default:
|
||||
return p.SendDM(ctx.Sender, "Unknown command. Type `!thom` to visit Krooke Realty.")
|
||||
}
|
||||
}
|
||||
|
||||
// ── Buy House ──────────────────────────────────────────────────────────────
|
||||
|
||||
func (p *AdventurePlugin) handleThomBuy(ctx MessageContext, char *AdventureCharacter, balance float64, downPaymentStr string) error {
|
||||
userMu := p.advUserLock(ctx.Sender)
|
||||
userMu.Lock()
|
||||
defer userMu.Unlock()
|
||||
|
||||
// Reload fresh
|
||||
char, _, err := p.ensureCharacter(ctx.Sender)
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender, "Failed to load your character.")
|
||||
}
|
||||
balance = p.euro.GetBalance(ctx.Sender)
|
||||
|
||||
if char.HouseLoanBalance > 0 {
|
||||
return p.SendDM(ctx.Sender, "You still have an outstanding loan. Pay it off first with `!thom payoff`.")
|
||||
}
|
||||
|
||||
nextDef := houseNextTier(char.HouseTier)
|
||||
if nextDef == nil {
|
||||
return p.SendDM(ctx.Sender, "You've reached max tier. There is nothing left to sell you.")
|
||||
}
|
||||
|
||||
def := *nextDef
|
||||
totalCost := houseTotalCost(def.BasePrice)
|
||||
|
||||
// Parse optional down payment
|
||||
downPayment := 0
|
||||
if downPaymentStr != "" {
|
||||
dp := 0
|
||||
for _, c := range downPaymentStr {
|
||||
if c < '0' || c > '9' {
|
||||
return p.SendDM(ctx.Sender, "Down payment must be a number. Example: `!thom buy 10000`")
|
||||
}
|
||||
dp = dp*10 + int(c-'0')
|
||||
}
|
||||
downPayment = dp
|
||||
}
|
||||
|
||||
if downPayment > 0 && float64(downPayment) > balance {
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("You can't afford a €%d down payment. Balance: €%.0f.", downPayment, balance))
|
||||
}
|
||||
|
||||
if downPayment >= totalCost {
|
||||
// Full purchase, no loan
|
||||
if !p.euro.Debit(ctx.Sender, float64(totalCost), "house_purchase_full") {
|
||||
return p.SendDM(ctx.Sender, "Transaction failed.")
|
||||
}
|
||||
char.HouseTier = def.Tier
|
||||
if err := saveAdvCharacter(char); err != nil {
|
||||
p.euro.Credit(ctx.Sender, float64(totalCost), "house_purchase_refund")
|
||||
return p.SendDM(ctx.Sender, "Failed to save. You've been refunded.")
|
||||
}
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("🏠 **%s** purchased outright for €%d.\n\nNo loan. No weekly payments. Thom is visibly disappointed.", def.Name, totalCost))
|
||||
}
|
||||
|
||||
// Loan purchase
|
||||
loanAmount := totalCost - downPayment
|
||||
|
||||
// Get current mortgage rate
|
||||
rate := getCurrentMortgageRate()
|
||||
if rate <= 0 {
|
||||
rate = 6.5 // fallback default
|
||||
}
|
||||
|
||||
// Debit down payment if any
|
||||
if downPayment > 0 {
|
||||
if !p.euro.Debit(ctx.Sender, float64(downPayment), "house_down_payment") {
|
||||
return p.SendDM(ctx.Sender, "Transaction failed.")
|
||||
}
|
||||
}
|
||||
|
||||
char.HouseTier = def.Tier
|
||||
char.HouseLoanBalance = loanAmount
|
||||
char.HouseCurrentRate = rate
|
||||
char.HouseMissedPayments = 0
|
||||
char.HouseLoanFrozen = false
|
||||
|
||||
if err := saveAdvCharacter(char); err != nil {
|
||||
if downPayment > 0 {
|
||||
p.euro.Credit(ctx.Sender, float64(downPayment), "house_dp_refund")
|
||||
}
|
||||
return p.SendDM(ctx.Sender, "Failed to save. You've been refunded.")
|
||||
}
|
||||
|
||||
weekly := houseWeeklyPayment(loanAmount, rate)
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("🏠 **%s** purchased!\n\n", def.Name))
|
||||
if downPayment > 0 {
|
||||
sb.WriteString(fmt.Sprintf("Down payment: €%d\n", downPayment))
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("Loan balance: €%d\n", loanAmount))
|
||||
sb.WriteString(fmt.Sprintf("Weekly payment: ~€%d (%.2f%% ARM rate)\n", weekly, rate))
|
||||
sb.WriteString("\nPayments are collected weekly. Enable autopay for a 2% discount: `!thom autopay`")
|
||||
|
||||
return p.SendDM(ctx.Sender, sb.String())
|
||||
}
|
||||
|
||||
// ── Payoff ─────────────────────────────────────────────────────────────────
|
||||
|
||||
func (p *AdventurePlugin) handleThomPayoff(ctx MessageContext, char *AdventureCharacter) error {
|
||||
userMu := p.advUserLock(ctx.Sender)
|
||||
userMu.Lock()
|
||||
defer userMu.Unlock()
|
||||
|
||||
// Reload fresh
|
||||
char, _, err := p.ensureCharacter(ctx.Sender)
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender, "Failed to load your character.")
|
||||
}
|
||||
|
||||
if char.HouseLoanBalance <= 0 {
|
||||
return p.SendDM(ctx.Sender, "You don't have an outstanding loan.")
|
||||
}
|
||||
|
||||
balance := p.euro.GetBalance(ctx.Sender)
|
||||
if balance < float64(char.HouseLoanBalance) {
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("You need €%d to pay off the loan. Balance: €%.0f.", char.HouseLoanBalance, balance))
|
||||
}
|
||||
|
||||
payoffAmount := float64(char.HouseLoanBalance)
|
||||
if !p.euro.Debit(ctx.Sender, payoffAmount, "house_payoff") {
|
||||
return p.SendDM(ctx.Sender, "Transaction failed.")
|
||||
}
|
||||
|
||||
char.HouseLoanBalance = 0
|
||||
char.HouseLoanFrozen = false
|
||||
char.HouseMissedPayments = 0
|
||||
if err := saveAdvCharacter(char); err != nil {
|
||||
p.euro.Credit(ctx.Sender, payoffAmount, "house_payoff_refund")
|
||||
return p.SendDM(ctx.Sender, "Failed to save. Your money has been refunded.")
|
||||
}
|
||||
|
||||
if char.HasPet() {
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("🏠 %s", fmt.Sprintf(thomPaidOffPet, char.PetName)))
|
||||
}
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("🏠 %s", thomPaidOffNoPet))
|
||||
}
|
||||
|
||||
// ── Extra Payment ─────────────────────────────────────────────────────────
|
||||
|
||||
func (p *AdventurePlugin) handleThomPay(ctx MessageContext, char *AdventureCharacter, amountStr string) error {
|
||||
userMu := p.advUserLock(ctx.Sender)
|
||||
userMu.Lock()
|
||||
defer userMu.Unlock()
|
||||
|
||||
char, _, err := p.ensureCharacter(ctx.Sender)
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender, "Failed to load your character.")
|
||||
}
|
||||
|
||||
if char.HouseLoanBalance <= 0 {
|
||||
return p.SendDM(ctx.Sender, "You don't have an outstanding loan.")
|
||||
}
|
||||
|
||||
amount, err := strconv.Atoi(amountStr)
|
||||
if err != nil || amount <= 0 {
|
||||
return p.SendDM(ctx.Sender, "Usage: `!thom pay <amount>` — e.g. `!thom pay 5000`")
|
||||
}
|
||||
|
||||
// Cap at remaining balance
|
||||
if amount > char.HouseLoanBalance {
|
||||
amount = char.HouseLoanBalance
|
||||
}
|
||||
|
||||
balance := p.euro.GetBalance(ctx.Sender)
|
||||
if balance < float64(amount) {
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("You need €%d but only have €%.0f.", amount, balance))
|
||||
}
|
||||
|
||||
if !p.euro.Debit(ctx.Sender, float64(amount), "house_extra_payment") {
|
||||
return p.SendDM(ctx.Sender, "Transaction failed.")
|
||||
}
|
||||
|
||||
char.HouseLoanBalance -= amount
|
||||
if char.HouseLoanBalance <= 0 {
|
||||
char.HouseLoanBalance = 0
|
||||
char.HouseLoanFrozen = false
|
||||
char.HouseMissedPayments = 0
|
||||
}
|
||||
|
||||
if err := saveAdvCharacter(char); err != nil {
|
||||
p.euro.Credit(ctx.Sender, float64(amount), "house_extra_payment_refund")
|
||||
return p.SendDM(ctx.Sender, "Failed to save. Your money has been refunded.")
|
||||
}
|
||||
|
||||
if char.HouseLoanBalance == 0 {
|
||||
if char.HasPet() {
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("🏠 Payment of €%d received. %s", amount, fmt.Sprintf(thomPaidOffPet, char.PetName)))
|
||||
}
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("🏠 Payment of €%d received. %s", amount, thomPaidOffNoPet))
|
||||
}
|
||||
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("🏠 Payment of €%d received. Remaining balance: €%d.", amount, char.HouseLoanBalance))
|
||||
}
|
||||
|
||||
// ── Autopay Toggle ─────────────────────────────────────────────────────────
|
||||
|
||||
func (p *AdventurePlugin) handleThomAutopay(ctx MessageContext, char *AdventureCharacter) error {
|
||||
userMu := p.advUserLock(ctx.Sender)
|
||||
userMu.Lock()
|
||||
defer userMu.Unlock()
|
||||
|
||||
char, _, err := p.ensureCharacter(ctx.Sender)
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender, "Failed to load your character.")
|
||||
}
|
||||
|
||||
if char.HouseLoanBalance <= 0 {
|
||||
return p.SendDM(ctx.Sender, "You don't have an outstanding loan.")
|
||||
}
|
||||
|
||||
char.HouseAutopay = !char.HouseAutopay
|
||||
if err := saveAdvCharacter(char); err != nil {
|
||||
return p.SendDM(ctx.Sender, "Failed to save.")
|
||||
}
|
||||
|
||||
if char.HouseAutopay {
|
||||
return p.SendDM(ctx.Sender, "✅ Autopay enabled. 2% discount on weekly payments. Thom calls this a favour. It is not.")
|
||||
}
|
||||
return p.SendDM(ctx.Sender, "Autopay disabled.")
|
||||
}
|
||||
|
||||
// ── Weekly Mortgage Payment Processing ─────────────────────────────────────
|
||||
|
||||
func (p *AdventurePlugin) processMortgagePayments() {
|
||||
chars, err := loadAllAdvCharacters()
|
||||
if err != nil {
|
||||
slog.Error("mortgage: failed to load characters", "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
for _, char := range chars {
|
||||
if char.HouseLoanBalance <= 0 {
|
||||
continue
|
||||
}
|
||||
if char.HouseLoanFrozen {
|
||||
continue
|
||||
}
|
||||
|
||||
userMu := p.advUserLock(char.UserID)
|
||||
userMu.Lock()
|
||||
|
||||
// Reload fresh inside lock
|
||||
freshChar, err := loadAdvCharacter(char.UserID)
|
||||
if err != nil || freshChar.HouseLoanBalance <= 0 || freshChar.HouseLoanFrozen {
|
||||
userMu.Unlock()
|
||||
continue
|
||||
}
|
||||
|
||||
weekly := houseWeeklyPayment(freshChar.HouseLoanBalance, freshChar.HouseCurrentRate)
|
||||
if freshChar.HouseAutopay {
|
||||
weekly = int(float64(weekly) * 0.98)
|
||||
}
|
||||
|
||||
if p.euro.Debit(freshChar.UserID, float64(weekly), "mortgage_weekly") {
|
||||
freshChar.HouseLoanBalance -= weekly
|
||||
if freshChar.HouseLoanBalance <= 0 {
|
||||
freshChar.HouseLoanBalance = 0
|
||||
freshChar.HouseMissedPayments = 0
|
||||
_ = saveAdvCharacter(freshChar)
|
||||
userMu.Unlock()
|
||||
// Paid off!
|
||||
if freshChar.HasPet() {
|
||||
_ = p.SendDM(freshChar.UserID, fmt.Sprintf("🏠 %s", fmt.Sprintf(thomPaidOffPet, freshChar.PetName)))
|
||||
} else {
|
||||
_ = p.SendDM(freshChar.UserID, fmt.Sprintf("🏠 %s", thomPaidOffNoPet))
|
||||
}
|
||||
continue
|
||||
}
|
||||
freshChar.HouseMissedPayments = 0
|
||||
_ = saveAdvCharacter(freshChar)
|
||||
userMu.Unlock()
|
||||
} else {
|
||||
// Missed payment
|
||||
penalty := houseMissedPenalty(weekly)
|
||||
freshChar.HouseLoanBalance += penalty
|
||||
freshChar.HouseMissedPayments++
|
||||
|
||||
if freshChar.HouseMissedPayments >= 10 {
|
||||
freshChar.HouseLoanFrozen = true
|
||||
_ = saveAdvCharacter(freshChar)
|
||||
userMu.Unlock()
|
||||
if freshChar.HasPet() {
|
||||
_ = p.SendDM(freshChar.UserID, fmt.Sprintf("🏠 %s", fmt.Sprintf(thomFreezePet, freshChar.PetName)))
|
||||
} else {
|
||||
_ = p.SendDM(freshChar.UserID, fmt.Sprintf("🏠 %s", thomFreezeNoPet))
|
||||
}
|
||||
} else {
|
||||
_ = saveAdvCharacter(freshChar)
|
||||
userMu.Unlock()
|
||||
if freshChar.HasPet() {
|
||||
_ = p.SendDM(freshChar.UserID, fmt.Sprintf("🏠 %s", fmt.Sprintf(thomMissedPaymentPet, freshChar.HouseLoanBalance, freshChar.PetName)))
|
||||
} else {
|
||||
_ = p.SendDM(freshChar.UserID, fmt.Sprintf("🏠 %s", fmt.Sprintf(thomMissedPaymentNoPet, freshChar.HouseLoanBalance)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Mortgage Rate Change DMs ───────────────────────────────────────────────
|
||||
|
||||
func (p *AdventurePlugin) sendMortgageRateChangeDMs(oldRate, newRate float64) {
|
||||
if oldRate == newRate {
|
||||
return
|
||||
}
|
||||
|
||||
chars, err := loadAllAdvCharacters()
|
||||
if err != nil {
|
||||
slog.Error("mortgage: failed to load characters for rate DMs", "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
increased := newRate > oldRate
|
||||
|
||||
for _, char := range chars {
|
||||
if char.HouseLoanBalance <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Update rate
|
||||
userMu := p.advUserLock(char.UserID)
|
||||
userMu.Lock()
|
||||
freshChar, err := loadAdvCharacter(char.UserID)
|
||||
if err != nil || freshChar.HouseLoanBalance <= 0 {
|
||||
userMu.Unlock()
|
||||
continue
|
||||
}
|
||||
freshChar.HouseCurrentRate = newRate
|
||||
_ = saveAdvCharacter(freshChar)
|
||||
userMu.Unlock()
|
||||
|
||||
newWeekly := houseWeeklyPayment(freshChar.HouseLoanBalance, newRate)
|
||||
amount := fmt.Sprintf("%d", newWeekly)
|
||||
|
||||
var pool []string
|
||||
if freshChar.HasPet() {
|
||||
if increased {
|
||||
pool = ThomRateIncreasePet
|
||||
} else {
|
||||
pool = ThomRateDecreasePet
|
||||
}
|
||||
} else {
|
||||
if increased {
|
||||
pool = ThomRateIncrease
|
||||
} else {
|
||||
pool = ThomRateDecrease
|
||||
}
|
||||
}
|
||||
|
||||
line := pool[rand.IntN(len(pool))]
|
||||
line = strings.ReplaceAll(line, "{amount}", amount)
|
||||
if freshChar.HasPet() {
|
||||
line = strings.ReplaceAll(line, "{pet_name}", freshChar.PetName)
|
||||
}
|
||||
|
||||
_ = p.SendDM(char.UserID, fmt.Sprintf("🏠 %s", line))
|
||||
|
||||
// Jitter
|
||||
time.Sleep(time.Duration(500+rand.IntN(1500)) * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Pet Armor Shop ─────────────────────────────────────────────────────────
|
||||
|
||||
type PetArmorDef struct {
|
||||
Tier int
|
||||
Name string
|
||||
DeflectBonus float64
|
||||
Price int
|
||||
}
|
||||
|
||||
var petDogArmor = []PetArmorDef{
|
||||
{Tier: 1, Name: "Leather Dog Barding", DeflectBonus: 0.015, Price: 225},
|
||||
{Tier: 2, Name: "Chain Dog Barding", DeflectBonus: 0.03, Price: 675},
|
||||
{Tier: 3, Name: "Plate Dog Barding", DeflectBonus: 0.05, Price: 2250},
|
||||
{Tier: 4, Name: "Enchanted Dog Barding", DeflectBonus: 0.075, Price: 11250},
|
||||
{Tier: 5, Name: "Dragonscale Dog Barding", DeflectBonus: 0.105, Price: 45000},
|
||||
}
|
||||
|
||||
var petCatArmor = []PetArmorDef{
|
||||
{Tier: 1, Name: "Leather Cat Armor", DeflectBonus: 0.015, Price: 225},
|
||||
{Tier: 2, Name: "Chain Cat Armor", DeflectBonus: 0.03, Price: 675},
|
||||
{Tier: 3, Name: "Plate Cat Armor", DeflectBonus: 0.05, Price: 2250},
|
||||
{Tier: 4, Name: "Enchanted Cat Armor", DeflectBonus: 0.075, Price: 11250},
|
||||
{Tier: 5, Name: "Dragonscale Cat Armor", DeflectBonus: 0.105, Price: 45000},
|
||||
}
|
||||
|
||||
func petArmorDefs(petType string) []PetArmorDef {
|
||||
if petType == "dog" {
|
||||
return petDogArmor
|
||||
}
|
||||
return petCatArmor
|
||||
}
|
||||
|
||||
func petArmorShopView(char *AdventureCharacter) string {
|
||||
var sb strings.Builder
|
||||
defs := petArmorDefs(char.PetType)
|
||||
|
||||
if char.PetArmorTier >= 5 {
|
||||
sb.WriteString("✨ Max pet armor tier reached. There is nothing left to buy.\n")
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
for _, def := range defs {
|
||||
if def.Tier <= char.PetArmorTier {
|
||||
if def.Tier == char.PetArmorTier {
|
||||
sb.WriteString(fmt.Sprintf("🟢 %s (T%d) — Currently equipped\n", def.Name, def.Tier))
|
||||
}
|
||||
continue
|
||||
}
|
||||
indicator := "⬆️"
|
||||
sb.WriteString(fmt.Sprintf("%s %s (T%d) — €%d — +%.1f%% deflect\n", indicator, def.Name, def.Tier, def.Price, def.DeflectBonus*100))
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("\nTo buy: `!thom petbuy <tier>` (e.g., `!thom petbuy %d`)", char.PetArmorTier+1))
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func (p *AdventurePlugin) handlePetArmorBuy(ctx MessageContext, char *AdventureCharacter, tierStr string) error {
|
||||
userMu := p.advUserLock(ctx.Sender)
|
||||
userMu.Lock()
|
||||
defer userMu.Unlock()
|
||||
|
||||
char, _, err := p.ensureCharacter(ctx.Sender)
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender, "Failed to load your character.")
|
||||
}
|
||||
|
||||
if !char.HasPet() {
|
||||
return p.SendDM(ctx.Sender, "You don't have a pet.")
|
||||
}
|
||||
if !char.PetSupplyShopUnlocked {
|
||||
return p.SendDM(ctx.Sender, "Pet supplies aren't available yet.")
|
||||
}
|
||||
|
||||
tier := 0
|
||||
for _, c := range tierStr {
|
||||
if c < '0' || c > '9' {
|
||||
return p.SendDM(ctx.Sender, "Usage: `!thom petbuy <tier>`")
|
||||
}
|
||||
tier = tier*10 + int(c-'0')
|
||||
}
|
||||
|
||||
if tier != char.PetArmorTier+1 {
|
||||
if tier <= char.PetArmorTier {
|
||||
return p.SendDM(ctx.Sender, "That's not an upgrade.")
|
||||
}
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("You need to buy tier %d first.", char.PetArmorTier+1))
|
||||
}
|
||||
|
||||
defs := petArmorDefs(char.PetType)
|
||||
var def *PetArmorDef
|
||||
for i := range defs {
|
||||
if defs[i].Tier == tier {
|
||||
def = &defs[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if def == nil {
|
||||
return p.SendDM(ctx.Sender, "Invalid tier.")
|
||||
}
|
||||
|
||||
balance := p.euro.GetBalance(ctx.Sender)
|
||||
if balance < float64(def.Price) {
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("%s deserves better, but you can't afford €%d. Balance: €%.0f.", char.PetName, def.Price, balance))
|
||||
}
|
||||
|
||||
if !p.euro.Debit(ctx.Sender, float64(def.Price), "pet_armor_"+tierStr) {
|
||||
return p.SendDM(ctx.Sender, "Transaction failed.")
|
||||
}
|
||||
|
||||
char.PetArmorTier = tier
|
||||
if err := saveAdvCharacter(char); err != nil {
|
||||
p.euro.Credit(ctx.Sender, float64(def.Price), "pet_armor_refund")
|
||||
return p.SendDM(ctx.Sender, "Failed to save. Refunded.")
|
||||
}
|
||||
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("🐾 **%s** equipped on %s.\n\n+%.1f%% deflect bonus. %s looks... objectively better than you.", def.Name, char.PetName, def.DeflectBonus*100, char.PetName))
|
||||
}
|
||||
375
internal/plugin/adventure_housing_test.go
Normal file
375
internal/plugin/adventure_housing_test.go
Normal file
@@ -0,0 +1,375 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ── Housing Tier Definitions ───────────────────────────────────────────────
|
||||
|
||||
func TestHouseTierByTier_Valid(t *testing.T) {
|
||||
for _, def := range houseTiers {
|
||||
got := houseTierByTier(def.Tier)
|
||||
if got == nil {
|
||||
t.Errorf("houseTierByTier(%d) returned nil", def.Tier)
|
||||
continue
|
||||
}
|
||||
if got.Tier != def.Tier {
|
||||
t.Errorf("houseTierByTier(%d).Tier = %d", def.Tier, got.Tier)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHouseTierByTier_Invalid(t *testing.T) {
|
||||
if houseTierByTier(0) != nil {
|
||||
t.Error("tier 0 (no house) should return nil")
|
||||
}
|
||||
if houseTierByTier(99) != nil {
|
||||
t.Error("tier 99 should return nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHouseNextTier(t *testing.T) {
|
||||
// From no house (tier 0) → tier 1
|
||||
def := houseNextTier(0)
|
||||
if def == nil || def.Tier != 1 {
|
||||
t.Error("next tier from 0 should be 1")
|
||||
}
|
||||
// From tier 1 → tier 2
|
||||
def = houseNextTier(1)
|
||||
if def == nil || def.Tier != 2 {
|
||||
t.Error("next tier from 1 should be 2")
|
||||
}
|
||||
// From max tier → nil
|
||||
def = houseNextTier(4)
|
||||
if def != nil {
|
||||
t.Error("next tier from max should be nil")
|
||||
}
|
||||
}
|
||||
|
||||
// ── Closing Costs ──────────────────────────────────────────────────────────
|
||||
|
||||
func TestHouseClosingCosts(t *testing.T) {
|
||||
// 11% of 75000 = 8250
|
||||
costs := houseClosingCosts(75000)
|
||||
if costs != 8250 {
|
||||
t.Errorf("closing costs on 75000: got %d, want 8250", costs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHouseTotalCost(t *testing.T) {
|
||||
// 75000 + 11% = 83250
|
||||
total := houseTotalCost(75000)
|
||||
if total != 83250 {
|
||||
t.Errorf("total cost on 75000: got %d, want 83250", total)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Weekly Payment ─────────────────────────────────────────────────────────
|
||||
|
||||
func TestHouseWeeklyPayment_Normal(t *testing.T) {
|
||||
// 100000 balance at 6.5%: interest = 100000*0.065/52 ≈ 125, principal = 100000*0.005 = 500
|
||||
// Total ≈ 625
|
||||
payment := houseWeeklyPayment(100000, 6.5)
|
||||
if payment < 600 || payment > 650 {
|
||||
t.Errorf("weekly payment on 100k at 6.5%%: got %d, want ~625", payment)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHouseWeeklyPayment_ZeroBalance(t *testing.T) {
|
||||
if houseWeeklyPayment(0, 6.5) != 0 {
|
||||
t.Error("zero balance should produce zero payment")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHouseWeeklyPayment_ZeroRate(t *testing.T) {
|
||||
if houseWeeklyPayment(100000, 0) != 0 {
|
||||
t.Error("zero rate should produce zero payment")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHouseWeeklyPayment_MinimumOne(t *testing.T) {
|
||||
// Very small balance should still yield at least 1
|
||||
payment := houseWeeklyPayment(1, 0.01)
|
||||
if payment < 1 {
|
||||
t.Errorf("tiny balance should produce at least 1, got %d", payment)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Missed Payment Penalty ─────────────────────────────────────────────────
|
||||
|
||||
func TestHouseMissedPenalty(t *testing.T) {
|
||||
// 5% of 625 = 31
|
||||
penalty := houseMissedPenalty(625)
|
||||
if penalty != 31 {
|
||||
t.Errorf("missed penalty on 625 weekly: got %d, want 31", penalty)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHouseMissedPenalty_Minimum(t *testing.T) {
|
||||
// Tiny payment should still yield at least 1
|
||||
penalty := houseMissedPenalty(1)
|
||||
if penalty < 1 {
|
||||
t.Errorf("penalty should be at least 1, got %d", penalty)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Character Helper Methods ───────────────────────────────────────────────
|
||||
|
||||
func TestHasHouse(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
tier int
|
||||
loan int
|
||||
expected bool
|
||||
}{
|
||||
{"no house", 0, 0, false},
|
||||
{"base house", 1, 0, true},
|
||||
{"loan only", 0, 50000, true},
|
||||
{"livable with loan", 2, 100000, true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
char := &AdventureCharacter{HouseTier: tt.tier, HouseLoanBalance: tt.loan}
|
||||
if char.HasHouse() != tt.expected {
|
||||
t.Errorf("HasHouse() = %v, want %v", char.HasHouse(), tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHouseHPBonus(t *testing.T) {
|
||||
tests := []struct {
|
||||
tier int
|
||||
expected float64
|
||||
}{
|
||||
{0, 0},
|
||||
{1, 0}, // Base house — no bonus
|
||||
{2, 0.05}, // Livable
|
||||
{3, 0.12}, // Comfortable
|
||||
{4, 0.20}, // Established
|
||||
}
|
||||
for _, tt := range tests {
|
||||
char := &AdventureCharacter{HouseTier: tt.tier}
|
||||
if bonus := char.HouseHPBonus(); bonus != tt.expected {
|
||||
t.Errorf("tier %d: HouseHPBonus() = %f, want %f", tt.tier, bonus, tt.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Thom Greeting ──────────────────────────────────────────────────────────
|
||||
|
||||
func TestThomGreeting_PreAdoption(t *testing.T) {
|
||||
char := &AdventureCharacter{}
|
||||
greeting := thomGreeting(char)
|
||||
found := false
|
||||
for _, g := range thomGreetingsPreAdoption {
|
||||
if greeting == g {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("pre-adoption greeting not from expected pool: %q", greeting)
|
||||
}
|
||||
}
|
||||
|
||||
func TestThomGreeting_PostAdoption(t *testing.T) {
|
||||
char := &AdventureCharacter{PetType: "dog", PetName: "Rex", PetArrived: true, PetLevel: 5}
|
||||
greeting := thomGreeting(char)
|
||||
found := false
|
||||
for _, g := range thomGreetingsPostAdoption {
|
||||
if greeting == g {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("post-adoption greeting not from expected pool: %q", greeting)
|
||||
}
|
||||
}
|
||||
|
||||
func TestThomGreeting_PostLevel10(t *testing.T) {
|
||||
char := &AdventureCharacter{PetType: "cat", PetName: "Whiskers", PetArrived: true, PetLevel: 10}
|
||||
greeting := thomGreeting(char)
|
||||
if !strings.Contains(greeting, "Whiskers") {
|
||||
t.Errorf("post-level-10 greeting should contain pet name, got: %q", greeting)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Thom Shop View ─────────────────────────────────────────────────────────
|
||||
|
||||
func TestThomShopView_NoHouse(t *testing.T) {
|
||||
char := &AdventureCharacter{}
|
||||
text := thomShopView(char, 100000)
|
||||
|
||||
if !strings.Contains(text, "Krooke Realty") {
|
||||
t.Error("should contain shop name")
|
||||
}
|
||||
if !strings.Contains(text, "don't own a house") {
|
||||
t.Error("should mention no house")
|
||||
}
|
||||
if !strings.Contains(text, "Base House") {
|
||||
t.Error("should show first tier available")
|
||||
}
|
||||
if !strings.Contains(text, "€75000") {
|
||||
t.Error("should show base price")
|
||||
}
|
||||
}
|
||||
|
||||
func TestThomShopView_WithLoan(t *testing.T) {
|
||||
char := &AdventureCharacter{HouseTier: 1, HouseLoanBalance: 50000, HouseCurrentRate: 6.5}
|
||||
text := thomShopView(char, 10000)
|
||||
|
||||
if !strings.Contains(text, "Loan balance") {
|
||||
t.Error("should show loan balance")
|
||||
}
|
||||
if !strings.Contains(text, "Pay off") {
|
||||
t.Error("should mention paying off loan")
|
||||
}
|
||||
}
|
||||
|
||||
func TestThomShopView_MaxTier(t *testing.T) {
|
||||
char := &AdventureCharacter{HouseTier: 4} // max tier
|
||||
text := thomShopView(char, 999999)
|
||||
|
||||
if !strings.Contains(text, "Max tier") {
|
||||
t.Error("should mention max tier reached")
|
||||
}
|
||||
}
|
||||
|
||||
func TestThomShopView_Autopay(t *testing.T) {
|
||||
char := &AdventureCharacter{HouseTier: 1, HouseLoanBalance: 50000, HouseCurrentRate: 6.5, HouseAutopay: true}
|
||||
text := thomShopView(char, 10000)
|
||||
|
||||
if !strings.Contains(text, "autopay") {
|
||||
t.Error("should show autopay status")
|
||||
}
|
||||
}
|
||||
|
||||
func TestThomShopView_Frozen(t *testing.T) {
|
||||
char := &AdventureCharacter{HouseTier: 1, HouseLoanBalance: 50000, HouseCurrentRate: 6.5, HouseLoanFrozen: true}
|
||||
text := thomShopView(char, 10000)
|
||||
|
||||
if !strings.Contains(text, "FROZEN") {
|
||||
t.Error("should show frozen status")
|
||||
}
|
||||
}
|
||||
|
||||
// ── Pet Armor ──────────────────────────────────────────────────────────────
|
||||
|
||||
func TestPetArmorDefs_BothTypes(t *testing.T) {
|
||||
dogDefs := petArmorDefs("dog")
|
||||
catDefs := petArmorDefs("cat")
|
||||
|
||||
if len(dogDefs) != 5 {
|
||||
t.Errorf("dog armor: got %d tiers, want 5", len(dogDefs))
|
||||
}
|
||||
if len(catDefs) != 5 {
|
||||
t.Errorf("cat armor: got %d tiers, want 5", len(catDefs))
|
||||
}
|
||||
|
||||
// Verify tiers are sequential 1-5
|
||||
for i, def := range dogDefs {
|
||||
if def.Tier != i+1 {
|
||||
t.Errorf("dog armor[%d].Tier = %d, want %d", i, def.Tier, i+1)
|
||||
}
|
||||
}
|
||||
|
||||
// Deflect bonuses should increase with tier
|
||||
for i := 1; i < len(dogDefs); i++ {
|
||||
if dogDefs[i].DeflectBonus <= dogDefs[i-1].DeflectBonus {
|
||||
t.Errorf("dog armor tier %d deflect bonus should exceed tier %d", dogDefs[i].Tier, dogDefs[i-1].Tier)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPetArmorShopView_HasUpgrades(t *testing.T) {
|
||||
char := &AdventureCharacter{PetType: "dog", PetName: "Rex", PetArrived: true, PetArmorTier: 0}
|
||||
text := petArmorShopView(char)
|
||||
|
||||
if !strings.Contains(text, "⬆️") {
|
||||
t.Error("should show upgrade indicators")
|
||||
}
|
||||
if !strings.Contains(text, "petbuy") {
|
||||
t.Error("should show buy instructions")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPetArmorShopView_MaxTier(t *testing.T) {
|
||||
char := &AdventureCharacter{PetType: "cat", PetName: "Luna", PetArrived: true, PetArmorTier: 5}
|
||||
text := petArmorShopView(char)
|
||||
|
||||
if !strings.Contains(text, "Max pet armor") {
|
||||
t.Error("should show max tier message")
|
||||
}
|
||||
}
|
||||
|
||||
// ── Flavor Pool Coverage ───────────────────────────────────────────────────
|
||||
|
||||
func TestThomFlavorPools_NonEmpty(t *testing.T) {
|
||||
pools := map[string][]string{
|
||||
"thomGreetingsPreAdoption": thomGreetingsPreAdoption,
|
||||
"thomGreetingsPostAdoption": thomGreetingsPostAdoption,
|
||||
"thomGreetingsPostLevel10": thomGreetingsPostLevel10,
|
||||
"thomHouseSellingLines": thomHouseSellingLines,
|
||||
"ThomRateIncrease": ThomRateIncrease,
|
||||
"ThomRateDecrease": ThomRateDecrease,
|
||||
"ThomRateIncreasePet": ThomRateIncreasePet,
|
||||
"ThomRateDecreasePet": ThomRateDecreasePet,
|
||||
}
|
||||
for name, pool := range pools {
|
||||
if len(pool) == 0 {
|
||||
t.Errorf("flavor pool %s is empty", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestThomRateFlavor_Placeholders(t *testing.T) {
|
||||
for i, line := range ThomRateIncrease {
|
||||
if !strings.Contains(line, "{amount}") {
|
||||
t.Errorf("ThomRateIncrease[%d] missing {amount} placeholder", i)
|
||||
}
|
||||
}
|
||||
for i, line := range ThomRateDecrease {
|
||||
if !strings.Contains(line, "{amount}") {
|
||||
t.Errorf("ThomRateDecrease[%d] missing {amount} placeholder", i)
|
||||
}
|
||||
}
|
||||
for i, line := range ThomRateIncreasePet {
|
||||
if !strings.Contains(line, "{amount}") {
|
||||
t.Errorf("ThomRateIncreasePet[%d] missing {amount} placeholder", i)
|
||||
}
|
||||
if !strings.Contains(line, "{pet_name}") {
|
||||
t.Errorf("ThomRateIncreasePet[%d] missing {pet_name} placeholder", i)
|
||||
}
|
||||
}
|
||||
for i, line := range ThomRateDecreasePet {
|
||||
if !strings.Contains(line, "{amount}") {
|
||||
t.Errorf("ThomRateDecreasePet[%d] missing {amount} placeholder", i)
|
||||
}
|
||||
if !strings.Contains(line, "{pet_name}") {
|
||||
t.Errorf("ThomRateDecreasePet[%d] missing {pet_name} placeholder", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── House Tiers Sanity ─────────────────────────────────────────────────────
|
||||
|
||||
func TestHouseTiers_PricesIncreasing(t *testing.T) {
|
||||
for i := 1; i < len(houseTiers); i++ {
|
||||
if houseTiers[i].BasePrice <= houseTiers[i-1].BasePrice {
|
||||
t.Errorf("tier %d price (%d) should exceed tier %d price (%d)",
|
||||
houseTiers[i].Tier, houseTiers[i].BasePrice,
|
||||
houseTiers[i-1].Tier, houseTiers[i-1].BasePrice)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHouseTiers_SequentialTiers(t *testing.T) {
|
||||
for i, def := range houseTiers {
|
||||
if def.Tier != i+1 {
|
||||
t.Errorf("houseTiers[%d].Tier = %d, want %d", i, def.Tier, i+1)
|
||||
}
|
||||
}
|
||||
}
|
||||
158
internal/plugin/adventure_mortgage.go
Normal file
158
internal/plugin/adventure_mortgage.go
Normal file
@@ -0,0 +1,158 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
)
|
||||
|
||||
// ── FRED API Integration ───────────────────────────────────────────────────
|
||||
//
|
||||
// Pulls the 5/1 ARM rate weekly from FRED (series: MORTGAGE5US).
|
||||
// Requires FRED_API_KEY environment variable.
|
||||
// Falls back to a hardcoded default if the API is unavailable.
|
||||
|
||||
const fredSeries = "MORTGAGE5US"
|
||||
const defaultMortgageRate = 6.5
|
||||
const currentRateCacheKey = "mortgage_current_rate"
|
||||
|
||||
type fredResponse struct {
|
||||
Observations []struct {
|
||||
Date string `json:"date"`
|
||||
Value string `json:"value"`
|
||||
} `json:"observations"`
|
||||
}
|
||||
|
||||
// fetchFREDRate pulls the latest 5/1 ARM rate from the FRED API.
|
||||
func fetchFREDRate() (float64, error) {
|
||||
apiKey := os.Getenv("FRED_API_KEY")
|
||||
if apiKey == "" {
|
||||
return 0, fmt.Errorf("FRED_API_KEY not set")
|
||||
}
|
||||
|
||||
url := fmt.Sprintf(
|
||||
"https://api.stlouisfed.org/fred/series/observations?series_id=%s&api_key=%s&file_type=json&sort_order=desc&limit=1",
|
||||
fredSeries, apiKey,
|
||||
)
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("FRED API request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return 0, fmt.Errorf("FRED API status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var data fredResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
|
||||
return 0, fmt.Errorf("FRED JSON decode: %w", err)
|
||||
}
|
||||
|
||||
if len(data.Observations) == 0 {
|
||||
return 0, fmt.Errorf("FRED: no observations returned")
|
||||
}
|
||||
|
||||
val := data.Observations[0].Value
|
||||
if val == "." {
|
||||
return 0, fmt.Errorf("FRED: value is placeholder")
|
||||
}
|
||||
|
||||
rate, err := strconv.ParseFloat(val, 64)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("FRED: parse rate %q: %w", val, err)
|
||||
}
|
||||
|
||||
// Clamp to sane range
|
||||
if rate < 1.0 {
|
||||
rate = 1.0
|
||||
}
|
||||
if rate > 15.0 {
|
||||
rate = 15.0
|
||||
}
|
||||
|
||||
return rate, nil
|
||||
}
|
||||
|
||||
// getCurrentMortgageRate returns the cached rate, or fetches fresh if stale (daily).
|
||||
func getCurrentMortgageRate() float64 {
|
||||
// Check DB cache (24h TTL)
|
||||
if val := db.CacheGet(currentRateCacheKey, 24*3600); val != "" {
|
||||
if rate, err := strconv.ParseFloat(val, 64); err == nil {
|
||||
return rate
|
||||
}
|
||||
}
|
||||
|
||||
rate, err := fetchFREDRate()
|
||||
if err != nil {
|
||||
slog.Warn("mortgage: FRED fetch failed, using default", "err", err)
|
||||
return defaultMortgageRate
|
||||
}
|
||||
|
||||
db.CacheSet(currentRateCacheKey, strconv.FormatFloat(rate, 'f', 2, 64))
|
||||
return rate
|
||||
}
|
||||
|
||||
// ── Weekly Mortgage Ticker ─────────────────────────────────────────────────
|
||||
|
||||
func (p *AdventurePlugin) mortgageTicker() {
|
||||
ticker := time.NewTicker(1 * time.Hour)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
now := time.Now().UTC()
|
||||
// Run on Mondays only (FRED updates Thursdays, we process Mondays)
|
||||
if now.Weekday() != time.Monday {
|
||||
continue
|
||||
}
|
||||
|
||||
_, week := now.ISOWeek()
|
||||
dateKey := fmt.Sprintf("%d-W%02d", now.Year(), week)
|
||||
jobName := "mortgage_weekly"
|
||||
if db.JobCompleted(jobName, dateKey) {
|
||||
continue
|
||||
}
|
||||
|
||||
slog.Info("mortgage: running weekly payment processing")
|
||||
|
||||
// Fetch fresh rate and compare with previous
|
||||
newRate := getCurrentMortgageRate()
|
||||
oldRate := p.getLastKnownRate()
|
||||
|
||||
if oldRate > 0 && oldRate != newRate {
|
||||
p.sendMortgageRateChangeDMs(oldRate, newRate)
|
||||
}
|
||||
p.setLastKnownRate(newRate)
|
||||
|
||||
// Process payments
|
||||
p.processMortgagePayments()
|
||||
|
||||
db.MarkJobCompleted(jobName, dateKey)
|
||||
}
|
||||
}
|
||||
|
||||
const lastKnownRateCacheKey = "mortgage_last_known_rate"
|
||||
|
||||
func (p *AdventurePlugin) getLastKnownRate() float64 {
|
||||
val := db.CacheGet(lastKnownRateCacheKey, 365*24*3600) // 1 year TTL
|
||||
if val == "" {
|
||||
return 0
|
||||
}
|
||||
rate, err := strconv.ParseFloat(val, 64)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return rate
|
||||
}
|
||||
|
||||
func (p *AdventurePlugin) setLastKnownRate(rate float64) {
|
||||
db.CacheSet(lastKnownRateCacheKey, strconv.FormatFloat(rate, 'f', 2, 64))
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
448
internal/plugin/adventure_pets.go
Normal file
448
internal/plugin/adventure_pets.go
Normal file
@@ -0,0 +1,448 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math/rand/v2"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// ── Pet XP & Leveling ──────────────────────────────────────────────────────
|
||||
|
||||
const petXPPerAction = 1.5
|
||||
|
||||
var petNameValid = regexp.MustCompile(`^[a-zA-Z0-9 '\-]+$`)
|
||||
|
||||
// petXPToNextLevel returns XP needed for a given pet level.
|
||||
func petXPToNextLevel(level int) int {
|
||||
switch {
|
||||
case level < 3:
|
||||
return 10
|
||||
case level < 5:
|
||||
return 20
|
||||
case level < 8:
|
||||
return 35
|
||||
default:
|
||||
return 50
|
||||
}
|
||||
}
|
||||
|
||||
// petGrantXP adds XP to the pet and handles level-ups. Returns true if leveled up.
|
||||
func petGrantXP(char *AdventureCharacter) bool {
|
||||
if !char.HasPet() || char.PetLevel >= 10 {
|
||||
return false
|
||||
}
|
||||
|
||||
char.PetXP += int(petXPPerAction * 100) // store as centixp for precision
|
||||
leveled := false
|
||||
for char.PetLevel < 10 {
|
||||
needed := petXPToNextLevel(char.PetLevel) * 100
|
||||
if char.PetXP < needed {
|
||||
break
|
||||
}
|
||||
char.PetXP -= needed
|
||||
char.PetLevel++
|
||||
leveled = true
|
||||
}
|
||||
|
||||
if char.PetLevel >= 10 && char.PetLevel10Date == "" {
|
||||
char.PetLevel10Date = time.Now().UTC().Format("2006-01-02")
|
||||
}
|
||||
|
||||
return leveled
|
||||
}
|
||||
|
||||
// ── Pet Combat Action Probabilities ────────────────────────────────────────
|
||||
|
||||
// Base probabilities scale with pet level.
|
||||
// Attack: 3% + 1.5% per level (max 18% at L10)
|
||||
// Deflect: 2% + 1% per level (max 12% at L10) + armor bonus
|
||||
// Ditch recovery: 5% + 2% per level (max 25% at L10)
|
||||
|
||||
func petAttackChance(level int) float64 {
|
||||
return 0.03 + float64(level)*0.015
|
||||
}
|
||||
|
||||
func petDeflectChance(level, armorTier int) float64 {
|
||||
base := 0.02 + float64(level)*0.01
|
||||
defs := petDogArmor // same bonuses for both types
|
||||
for _, d := range defs {
|
||||
if d.Tier == armorTier {
|
||||
base += d.DeflectBonus
|
||||
break
|
||||
}
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
func petDitchRecoveryChance(level int) float64 {
|
||||
return 0.05 + float64(level)*0.02
|
||||
}
|
||||
|
||||
// petDitchRecoveryTime returns the death timer based on pet level.
|
||||
// Level 1: 5h, Level 5: ~3.5h, Level 10: 1h (linear interpolation).
|
||||
func petDitchRecoveryTime(level int) time.Duration {
|
||||
hours := 5.0 - float64(level-1)*4.0/9.0 // 5h at L1, 1h at L10
|
||||
if hours < 1 {
|
||||
hours = 1
|
||||
}
|
||||
return time.Duration(hours * float64(time.Hour))
|
||||
}
|
||||
|
||||
// ── Pet Combat Results ─────────────────────────────────────────────────────
|
||||
|
||||
type PetCombatResult struct {
|
||||
Attacked bool
|
||||
AttackDamage int
|
||||
AttackText string
|
||||
Deflected bool
|
||||
DeflectText string
|
||||
DitchRecovery bool
|
||||
DitchText string
|
||||
VictoryText string
|
||||
DeathText string
|
||||
}
|
||||
|
||||
// petRollCombatActions rolls attack and deflect for a combat round.
|
||||
func petRollCombatActions(char *AdventureCharacter, enemyName string) *PetCombatResult {
|
||||
if !char.HasPet() {
|
||||
return nil
|
||||
}
|
||||
|
||||
result := &PetCombatResult{}
|
||||
|
||||
// Attack roll
|
||||
if rand.Float64() < petAttackChance(char.PetLevel) {
|
||||
result.Attacked = true
|
||||
result.AttackDamage = 3 + rand.IntN(5) + char.PetLevel // 3-7 + level
|
||||
var pool []string
|
||||
if char.PetType == "dog" {
|
||||
pool = PetDogAttack
|
||||
} else {
|
||||
pool = PetCatAttack
|
||||
}
|
||||
line := pool[rand.IntN(len(pool))]
|
||||
line = strings.ReplaceAll(line, "{enemy}", enemyName)
|
||||
line = strings.ReplaceAll(line, "{damage}", fmt.Sprintf("%d", result.AttackDamage))
|
||||
result.AttackText = line
|
||||
}
|
||||
|
||||
// Deflect roll
|
||||
if rand.Float64() < petDeflectChance(char.PetLevel, char.PetArmorTier) {
|
||||
result.Deflected = true
|
||||
var pool []string
|
||||
if char.PetType == "dog" {
|
||||
pool = PetDogDeflect
|
||||
} else {
|
||||
pool = PetCatDeflect
|
||||
}
|
||||
line := pool[rand.IntN(len(pool))]
|
||||
line = strings.ReplaceAll(line, "{enemy}", enemyName)
|
||||
result.DeflectText = line
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// petRollDitchRecovery rolls for pet intervention on player death.
|
||||
func petRollDitchRecovery(char *AdventureCharacter) bool {
|
||||
if !char.HasPet() {
|
||||
return false
|
||||
}
|
||||
return rand.Float64() < petDitchRecoveryChance(char.PetLevel)
|
||||
}
|
||||
|
||||
// petVictoryText returns a random victory reaction line.
|
||||
func petVictoryText(char *AdventureCharacter) string {
|
||||
if !char.HasPet() {
|
||||
return ""
|
||||
}
|
||||
var pool []string
|
||||
if char.PetType == "dog" {
|
||||
pool = PetDogVictory
|
||||
} else {
|
||||
pool = PetCatVictory
|
||||
}
|
||||
return pool[rand.IntN(len(pool))]
|
||||
}
|
||||
|
||||
// petDeathText returns a random death reaction line.
|
||||
func petDeathText(char *AdventureCharacter) string {
|
||||
if !char.HasPet() {
|
||||
return ""
|
||||
}
|
||||
var pool []string
|
||||
if char.PetType == "dog" {
|
||||
pool = PetDogDeath
|
||||
} else {
|
||||
pool = PetCatDeath
|
||||
}
|
||||
return pool[rand.IntN(len(pool))]
|
||||
}
|
||||
|
||||
// ── Pet Arrival Flow ───────────────────────────────────────────────────────
|
||||
|
||||
// petShouldArrive checks if pet arrival should trigger.
|
||||
// Fires randomly after Tier 1 house upgrade is complete.
|
||||
func petShouldArrive(char *AdventureCharacter) bool {
|
||||
if char.PetArrived {
|
||||
return false
|
||||
}
|
||||
// Pet arrives after Tier 1 (Livable) upgrade = HouseTier >= 2
|
||||
if char.HouseTier < 2 {
|
||||
return false
|
||||
}
|
||||
// Pet was chased away and reactivated via Misty — allow re-arrival
|
||||
if char.PetChasedAway {
|
||||
return char.PetReactivated
|
||||
}
|
||||
// 15% daily chance after house tier 1
|
||||
return rand.Float64() < 0.15
|
||||
}
|
||||
|
||||
// petArrivalDM sends the initial "there's an animal in your house" DM.
|
||||
func (p *AdventurePlugin) petArrivalDM(userID id.UserID) {
|
||||
// Don't overwrite an existing pending interaction
|
||||
if _, exists := p.pending.Load(string(userID)); exists {
|
||||
return
|
||||
}
|
||||
|
||||
text := "There's an animal in your house. It looks like a...\n\n" +
|
||||
"🚪 Chase it away\n" +
|
||||
"🍖 Feed it\n\n" +
|
||||
"Reply with `chase` or `feed`."
|
||||
|
||||
p.pending.Store(string(userID), &advPendingInteraction{
|
||||
Type: "pet_arrival",
|
||||
Data: &advPendingPetArrival{},
|
||||
ExpiresAt: time.Now().Add(advDMResponseWindow),
|
||||
})
|
||||
_ = p.SendDM(userID, text)
|
||||
}
|
||||
|
||||
// resolvePetArrival handles the chase/feed response.
|
||||
func (p *AdventurePlugin) resolvePetArrival(ctx MessageContext) error {
|
||||
userMu := p.advUserLock(ctx.Sender)
|
||||
userMu.Lock()
|
||||
defer userMu.Unlock()
|
||||
|
||||
char, _, err := p.ensureCharacter(ctx.Sender)
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender, "Failed to load your character.")
|
||||
}
|
||||
|
||||
reply := strings.ToLower(strings.TrimSpace(ctx.Body))
|
||||
|
||||
if reply == "chase" || reply == "🚪" {
|
||||
char.PetChasedAway = true
|
||||
char.PetReactivated = false
|
||||
_ = saveAdvCharacter(char)
|
||||
return p.SendDM(ctx.Sender, "You chased it away. It disappeared around the corner and didn't come back.\n\nThe house is quiet again.")
|
||||
}
|
||||
|
||||
if reply == "feed" || reply == "🍖" {
|
||||
// Ask what type
|
||||
text := "It ate everything you put down. It's still here.\n\n" +
|
||||
"It looks like a...\n\n" +
|
||||
"🐶 Massive Dog\n" +
|
||||
"🐱 Massive Cat\n\n" +
|
||||
"Reply with `dog` or `cat`."
|
||||
|
||||
p.pending.Store(string(ctx.Sender), &advPendingInteraction{
|
||||
Type: "pet_type",
|
||||
Data: &advPendingPetType{},
|
||||
ExpiresAt: time.Now().Add(advDMResponseWindow),
|
||||
})
|
||||
return p.SendDM(ctx.Sender, text)
|
||||
}
|
||||
|
||||
// Invalid response — re-prompt
|
||||
p.pending.Store(string(ctx.Sender), &advPendingInteraction{
|
||||
Type: "pet_arrival",
|
||||
Data: &advPendingPetArrival{},
|
||||
ExpiresAt: time.Now().Add(advDMResponseWindow),
|
||||
})
|
||||
return p.SendDM(ctx.Sender, "Reply with `chase` or `feed`.")
|
||||
}
|
||||
|
||||
// resolvePetType handles dog/cat selection.
|
||||
func (p *AdventurePlugin) resolvePetType(ctx MessageContext) error {
|
||||
userMu := p.advUserLock(ctx.Sender)
|
||||
userMu.Lock()
|
||||
defer userMu.Unlock()
|
||||
|
||||
reply := strings.ToLower(strings.TrimSpace(ctx.Body))
|
||||
|
||||
petType := ""
|
||||
if reply == "dog" || reply == "🐶" {
|
||||
petType = "dog"
|
||||
} else if reply == "cat" || reply == "🐱" {
|
||||
petType = "cat"
|
||||
}
|
||||
|
||||
if petType == "" {
|
||||
p.pending.Store(string(ctx.Sender), &advPendingInteraction{
|
||||
Type: "pet_type",
|
||||
Data: &advPendingPetType{},
|
||||
ExpiresAt: time.Now().Add(advDMResponseWindow),
|
||||
})
|
||||
return p.SendDM(ctx.Sender, "Reply with `dog` or `cat`.")
|
||||
}
|
||||
|
||||
p.pending.Store(string(ctx.Sender), &advPendingInteraction{
|
||||
Type: "pet_name",
|
||||
Data: &advPendingPetName{PetType: petType},
|
||||
ExpiresAt: time.Now().Add(advDMResponseWindow),
|
||||
})
|
||||
|
||||
article := "a"
|
||||
if petType == "dog" {
|
||||
article = "a"
|
||||
}
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("It's %s Massive %s. What would you like to name it?\n\nReply with a name.",
|
||||
article, titleCase(petType)))
|
||||
}
|
||||
|
||||
// resolvePetName handles naming the pet.
|
||||
func (p *AdventurePlugin) resolvePetName(ctx MessageContext) error {
|
||||
userMu := p.advUserLock(ctx.Sender)
|
||||
userMu.Lock()
|
||||
defer userMu.Unlock()
|
||||
|
||||
char, _, err := p.ensureCharacter(ctx.Sender)
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender, "Failed to load your character.")
|
||||
}
|
||||
|
||||
val, ok := p.pending.LoadAndDelete(string(ctx.Sender))
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
pi := val.(*advPendingInteraction)
|
||||
data := pi.Data.(*advPendingPetName)
|
||||
|
||||
name := strings.TrimSpace(ctx.Body)
|
||||
if len(name) == 0 || len(name) > 30 {
|
||||
p.pending.Store(string(ctx.Sender), pi)
|
||||
return p.SendDM(ctx.Sender, "Name must be 1-30 characters. Try again.")
|
||||
}
|
||||
if !petNameValid.MatchString(name) {
|
||||
p.pending.Store(string(ctx.Sender), pi)
|
||||
return p.SendDM(ctx.Sender, "Name can only contain letters, numbers, spaces, hyphens, and apostrophes. Try again.")
|
||||
}
|
||||
|
||||
char.PetType = data.PetType
|
||||
char.PetName = name
|
||||
char.PetArrived = true
|
||||
char.PetChasedAway = false
|
||||
char.PetLevel = 1
|
||||
char.PetXP = 0
|
||||
|
||||
if err := saveAdvCharacter(char); err != nil {
|
||||
return p.SendDM(ctx.Sender, "Failed to save.")
|
||||
}
|
||||
|
||||
emoji := "🐶"
|
||||
if data.PetType == "cat" {
|
||||
emoji = "🐱"
|
||||
}
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("%s **%s** has moved in.\n\nBreed: Massive %s.\nLevel: 1\n\n%s will join you in combat. %s will not explain their decisions.",
|
||||
emoji, name, titleCase(data.PetType), name, name))
|
||||
}
|
||||
|
||||
// ── Morning Pet Events ─────────────────────────────────────────────────────
|
||||
|
||||
// petMorningEvent rolls for a morning pet event and returns flavor text.
|
||||
// Returns empty string if no event fires.
|
||||
func petMorningEvent(char *AdventureCharacter) string {
|
||||
if !char.HasPet() {
|
||||
return ""
|
||||
}
|
||||
|
||||
// 25% chance of morning event
|
||||
if rand.Float64() >= 0.25 {
|
||||
return ""
|
||||
}
|
||||
|
||||
if char.PetType == "cat" {
|
||||
line := PetCatOffering[rand.IntN(len(PetCatOffering))]
|
||||
return strings.ReplaceAll(line, "{pet_name}", char.PetName)
|
||||
}
|
||||
|
||||
line := PetDogSmothering[rand.IntN(len(PetDogSmothering))]
|
||||
return strings.ReplaceAll(line, "{pet_name}", char.PetName)
|
||||
}
|
||||
|
||||
// ── Pet Supply Shop Unlock Check ───────────────────────────────────────────
|
||||
|
||||
// petCheckSupplyShopUnlock checks if 1 week has passed since pet hit level 10.
|
||||
func petCheckSupplyShopUnlock(char *AdventureCharacter) bool {
|
||||
if char.PetSupplyShopUnlocked || char.PetLevel10Date == "" {
|
||||
return false
|
||||
}
|
||||
d, err := time.Parse("2006-01-02", char.PetLevel10Date)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return time.Now().UTC().After(d.Add(7 * 24 * time.Hour))
|
||||
}
|
||||
|
||||
// ── Misty Housing Hint ─────────────────────────────────────────────────────
|
||||
|
||||
// mistyHousingHint returns a hint about housing/pets if conditions are met.
|
||||
// Fires once, after 2+ Misty encounters, player has no house.
|
||||
func mistyHousingHint(char *AdventureCharacter) string {
|
||||
if char.HasHouse() || char.MistyEncounterCount < 2 {
|
||||
return ""
|
||||
}
|
||||
if char.MistyDonatedCount > 0 {
|
||||
return "You seem like a good person. I can imagine you sitting in a house caring for a pet someday."
|
||||
}
|
||||
return "Thank you for your time."
|
||||
}
|
||||
|
||||
// ── Misty Pet Reactivation ─────────────────────────────────────────────────
|
||||
|
||||
// mistyReactivatePet marks the pet as reactivated if it was chased away.
|
||||
func mistyReactivatePet(char *AdventureCharacter) {
|
||||
if char.PetChasedAway && !char.PetReactivated {
|
||||
char.PetReactivated = true
|
||||
}
|
||||
}
|
||||
|
||||
// ── Pet Level 10 Ticker (runs daily at midnight) ───────────────────────────
|
||||
|
||||
func (p *AdventurePlugin) petMidnightCheck() {
|
||||
chars, err := loadAllAdvCharacters()
|
||||
if err != nil {
|
||||
slog.Error("pet: failed to load characters", "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
for _, char := range chars {
|
||||
if !char.HasPet() {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check supply shop unlock
|
||||
if petCheckSupplyShopUnlock(&char) {
|
||||
char.PetSupplyShopUnlocked = true
|
||||
_ = saveAdvCharacter(&char)
|
||||
slog.Info("pet: supply shop unlocked", "user", char.UserID, "pet", char.PetName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Ditch Recovery Text ────────────────────────────────────────────────────
|
||||
|
||||
func petDitchRecoveryGameRoom(username, petName string, petInvolved bool) string {
|
||||
base := fmt.Sprintf("🏥 %s was brought into St. Guildmore's on a stretcher. They have been returned to the ditch. They'll be fine eventually.", username)
|
||||
if petInvolved {
|
||||
base = fmt.Sprintf("🏥 St. Guildmore's notes that %s has once again intervened before our personnel could reach the scene. %s has been reminded that the ditch is not a medical facility. %s did not respond to this reminder.", petName, petName, petName)
|
||||
}
|
||||
return base
|
||||
}
|
||||
442
internal/plugin/adventure_pets_test.go
Normal file
442
internal/plugin/adventure_pets_test.go
Normal file
@@ -0,0 +1,442 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ── Pet XP & Leveling ──────────────────────────────────────────────────────
|
||||
|
||||
func TestPetXPToNextLevel(t *testing.T) {
|
||||
tests := []struct {
|
||||
level int
|
||||
expected int
|
||||
}{
|
||||
{0, 10},
|
||||
{1, 10},
|
||||
{2, 10},
|
||||
{4, 20},
|
||||
{5, 35},
|
||||
{7, 35},
|
||||
{8, 50},
|
||||
{9, 50},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := petXPToNextLevel(tt.level); got != tt.expected {
|
||||
t.Errorf("petXPToNextLevel(%d) = %d, want %d", tt.level, got, tt.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPetXPToNextLevel_Increasing(t *testing.T) {
|
||||
// XP requirements should never decrease as level increases
|
||||
prev := petXPToNextLevel(0)
|
||||
for level := 1; level < 10; level++ {
|
||||
curr := petXPToNextLevel(level)
|
||||
if curr < prev {
|
||||
t.Errorf("XP for level %d (%d) should not be less than level %d (%d)", level, curr, level-1, prev)
|
||||
}
|
||||
prev = curr
|
||||
}
|
||||
}
|
||||
|
||||
func TestPetGrantXP_LevelsUp(t *testing.T) {
|
||||
char := &AdventureCharacter{
|
||||
PetType: "dog",
|
||||
PetName: "Rex",
|
||||
PetArrived: true,
|
||||
PetLevel: 1,
|
||||
PetXP: 950, // close to needing 1000 (10 * 100 centixp)
|
||||
}
|
||||
leveled := petGrantXP(char)
|
||||
if !leveled {
|
||||
t.Error("should have leveled up")
|
||||
}
|
||||
if char.PetLevel != 2 {
|
||||
t.Errorf("pet level should be 2, got %d", char.PetLevel)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPetGrantXP_NoPet(t *testing.T) {
|
||||
char := &AdventureCharacter{}
|
||||
if petGrantXP(char) {
|
||||
t.Error("should not grant XP without a pet")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPetGrantXP_MaxLevel(t *testing.T) {
|
||||
char := &AdventureCharacter{
|
||||
PetType: "cat",
|
||||
PetName: "Luna",
|
||||
PetArrived: true,
|
||||
PetLevel: 10,
|
||||
PetXP: 9999,
|
||||
}
|
||||
if petGrantXP(char) {
|
||||
t.Error("should not level up past max level 10")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPetGrantXP_ChasedAway(t *testing.T) {
|
||||
char := &AdventureCharacter{
|
||||
PetType: "dog",
|
||||
PetName: "Rex",
|
||||
PetArrived: true,
|
||||
PetChasedAway: true,
|
||||
PetLevel: 1,
|
||||
PetXP: 0,
|
||||
}
|
||||
if petGrantXP(char) {
|
||||
t.Error("should not grant XP to chased-away pet")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPetGrantXP_SetsLevel10Date(t *testing.T) {
|
||||
char := &AdventureCharacter{
|
||||
PetType: "dog",
|
||||
PetName: "Rex",
|
||||
PetArrived: true,
|
||||
PetLevel: 9,
|
||||
PetXP: 4999, // needs 5000 (50 * 100) for level 9→10
|
||||
}
|
||||
petGrantXP(char)
|
||||
if char.PetLevel == 10 && char.PetLevel10Date == "" {
|
||||
t.Error("reaching level 10 should set PetLevel10Date")
|
||||
}
|
||||
}
|
||||
|
||||
// ── Pet Combat Probabilities ───────────────────────────────────────────────
|
||||
|
||||
func TestPetAttackChance_ScalesWithLevel(t *testing.T) {
|
||||
prev := petAttackChance(0)
|
||||
for level := 1; level <= 10; level++ {
|
||||
curr := petAttackChance(level)
|
||||
if curr <= prev {
|
||||
t.Errorf("attack chance at level %d (%.3f) should exceed level %d (%.3f)", level, curr, level-1, prev)
|
||||
}
|
||||
prev = curr
|
||||
}
|
||||
}
|
||||
|
||||
func TestPetAttackChance_Level10(t *testing.T) {
|
||||
chance := petAttackChance(10)
|
||||
// 3% + 10*1.5% = 18%
|
||||
if chance < 0.179 || chance > 0.181 {
|
||||
t.Errorf("level 10 attack chance: got %.3f, want 0.18", chance)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPetDeflectChance_ScalesWithLevel(t *testing.T) {
|
||||
prev := petDeflectChance(0, 0)
|
||||
for level := 1; level <= 10; level++ {
|
||||
curr := petDeflectChance(level, 0)
|
||||
if curr <= prev {
|
||||
t.Errorf("deflect chance at level %d (%.3f) should exceed level %d (%.3f)", level, curr, level-1, prev)
|
||||
}
|
||||
prev = curr
|
||||
}
|
||||
}
|
||||
|
||||
func TestPetDeflectChance_ArmorBonus(t *testing.T) {
|
||||
baseChance := petDeflectChance(5, 0)
|
||||
armoredChance := petDeflectChance(5, 3)
|
||||
if armoredChance <= baseChance {
|
||||
t.Errorf("armored deflect (%.3f) should exceed base (%.3f)", armoredChance, baseChance)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPetDitchRecoveryChance_ScalesWithLevel(t *testing.T) {
|
||||
prev := petDitchRecoveryChance(0)
|
||||
for level := 1; level <= 10; level++ {
|
||||
curr := petDitchRecoveryChance(level)
|
||||
if curr <= prev {
|
||||
t.Errorf("ditch chance at level %d (%.3f) should exceed level %d (%.3f)", level, curr, level-1, prev)
|
||||
}
|
||||
prev = curr
|
||||
}
|
||||
}
|
||||
|
||||
func TestPetDitchRecoveryChance_Level10(t *testing.T) {
|
||||
chance := petDitchRecoveryChance(10)
|
||||
// 5% + 10*2% = 25%
|
||||
if chance < 0.249 || chance > 0.251 {
|
||||
t.Errorf("level 10 ditch chance: got %.3f, want 0.25", chance)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Pet Combat Results ─────────────────────────────────────────────────────
|
||||
|
||||
func TestPetRollCombatActions_NoPet(t *testing.T) {
|
||||
char := &AdventureCharacter{}
|
||||
result := petRollCombatActions(char, "Goblin")
|
||||
if result != nil {
|
||||
t.Error("should return nil without a pet")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPetRollDitchRecovery_NoPet(t *testing.T) {
|
||||
char := &AdventureCharacter{}
|
||||
if petRollDitchRecovery(char) {
|
||||
t.Error("should return false without a pet")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPetVictoryText_Dog(t *testing.T) {
|
||||
char := &AdventureCharacter{PetType: "dog", PetName: "Rex", PetArrived: true}
|
||||
text := petVictoryText(char)
|
||||
if text == "" {
|
||||
t.Error("should return victory text for dog")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPetVictoryText_Cat(t *testing.T) {
|
||||
char := &AdventureCharacter{PetType: "cat", PetName: "Luna", PetArrived: true}
|
||||
text := petVictoryText(char)
|
||||
if text == "" {
|
||||
t.Error("should return victory text for cat")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPetVictoryText_NoPet(t *testing.T) {
|
||||
char := &AdventureCharacter{}
|
||||
if petVictoryText(char) != "" {
|
||||
t.Error("should return empty string without pet")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPetDeathText_NoPet(t *testing.T) {
|
||||
char := &AdventureCharacter{}
|
||||
if petDeathText(char) != "" {
|
||||
t.Error("should return empty string without pet")
|
||||
}
|
||||
}
|
||||
|
||||
// ── Pet Arrival Logic ──────────────────────────────────────────────────────
|
||||
|
||||
func TestPetShouldArrive_NoHouse(t *testing.T) {
|
||||
char := &AdventureCharacter{HouseTier: 0}
|
||||
if petShouldArrive(char) {
|
||||
t.Error("should not arrive without house")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPetShouldArrive_BaseHouseOnly(t *testing.T) {
|
||||
char := &AdventureCharacter{HouseTier: 1} // Base house, not yet Livable
|
||||
if petShouldArrive(char) {
|
||||
t.Error("should not arrive with only base house (need tier 2+)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPetShouldArrive_AlreadyArrived(t *testing.T) {
|
||||
char := &AdventureCharacter{HouseTier: 2, PetArrived: true}
|
||||
if petShouldArrive(char) {
|
||||
t.Error("should not trigger if pet already arrived")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPetShouldArrive_ChasedAway(t *testing.T) {
|
||||
char := &AdventureCharacter{HouseTier: 2, PetChasedAway: true}
|
||||
if petShouldArrive(char) {
|
||||
t.Error("should not trigger if pet was chased away (and not reactivated)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPetShouldArrive_Reactivated(t *testing.T) {
|
||||
char := &AdventureCharacter{HouseTier: 2, PetChasedAway: true, PetReactivated: true}
|
||||
// With reactivation, should always return true
|
||||
if !petShouldArrive(char) {
|
||||
t.Error("reactivated pet should always trigger arrival")
|
||||
}
|
||||
}
|
||||
|
||||
// ── HasPet ─────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestHasPet(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
char AdventureCharacter
|
||||
expected bool
|
||||
}{
|
||||
{"no pet", AdventureCharacter{}, false},
|
||||
{"has pet", AdventureCharacter{PetType: "dog", PetName: "Rex", PetArrived: true}, true},
|
||||
{"chased away", AdventureCharacter{PetType: "dog", PetName: "Rex", PetArrived: true, PetChasedAway: true}, false},
|
||||
{"not arrived", AdventureCharacter{PetType: "dog", PetName: "Rex"}, false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.char.HasPet() != tt.expected {
|
||||
t.Errorf("HasPet() = %v, want %v", tt.char.HasPet(), tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── Morning Pet Events ─────────────────────────────────────────────────────
|
||||
|
||||
func TestPetMorningEvent_NoPet(t *testing.T) {
|
||||
char := &AdventureCharacter{}
|
||||
if petMorningEvent(char) != "" {
|
||||
t.Error("should return empty string without pet")
|
||||
}
|
||||
}
|
||||
|
||||
// ── Misty Housing Hint ─────────────────────────────────────────────────────
|
||||
|
||||
func TestMistyHousingHint_NoEncounters(t *testing.T) {
|
||||
char := &AdventureCharacter{MistyEncounterCount: 0}
|
||||
if mistyHousingHint(char) != "" {
|
||||
t.Error("should not hint with 0 encounters")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMistyHousingHint_HasHouse(t *testing.T) {
|
||||
char := &AdventureCharacter{MistyEncounterCount: 3, HouseTier: 1}
|
||||
if mistyHousingHint(char) != "" {
|
||||
t.Error("should not hint if player has house")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMistyHousingHint_Donated(t *testing.T) {
|
||||
char := &AdventureCharacter{MistyEncounterCount: 2, MistyDonatedCount: 1}
|
||||
hint := mistyHousingHint(char)
|
||||
if !strings.Contains(hint, "house") {
|
||||
t.Errorf("donated player hint should mention house, got: %q", hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMistyHousingHint_NotDonated(t *testing.T) {
|
||||
char := &AdventureCharacter{MistyEncounterCount: 2, MistyDonatedCount: 0}
|
||||
hint := mistyHousingHint(char)
|
||||
if hint != "Thank you for your time." {
|
||||
t.Errorf("non-donor hint should be dismissive, got: %q", hint)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Misty Pet Reactivation ─────────────────────────────────────────────────
|
||||
|
||||
func TestMistyReactivatePet_ChasedAway(t *testing.T) {
|
||||
char := &AdventureCharacter{PetChasedAway: true, PetReactivated: false}
|
||||
mistyReactivatePet(char)
|
||||
if !char.PetReactivated {
|
||||
t.Error("should set PetReactivated")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMistyReactivatePet_NotChasedAway(t *testing.T) {
|
||||
char := &AdventureCharacter{PetChasedAway: false, PetReactivated: false}
|
||||
mistyReactivatePet(char)
|
||||
if char.PetReactivated {
|
||||
t.Error("should not set PetReactivated if pet wasn't chased away")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMistyReactivatePet_AlreadyReactivated(t *testing.T) {
|
||||
char := &AdventureCharacter{PetChasedAway: true, PetReactivated: true}
|
||||
mistyReactivatePet(char)
|
||||
if !char.PetReactivated {
|
||||
t.Error("should remain reactivated")
|
||||
}
|
||||
}
|
||||
|
||||
// ── Pet Supply Shop Unlock ─────────────────────────────────────────────────
|
||||
|
||||
func TestPetCheckSupplyShopUnlock_NotLevel10(t *testing.T) {
|
||||
char := &AdventureCharacter{PetLevel10Date: ""}
|
||||
if petCheckSupplyShopUnlock(char) {
|
||||
t.Error("should not unlock without level 10 date")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPetCheckSupplyShopUnlock_AlreadyUnlocked(t *testing.T) {
|
||||
char := &AdventureCharacter{PetSupplyShopUnlocked: true, PetLevel10Date: "2020-01-01"}
|
||||
if petCheckSupplyShopUnlock(char) {
|
||||
t.Error("should not re-trigger if already unlocked")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPetCheckSupplyShopUnlock_TooSoon(t *testing.T) {
|
||||
// Set date to today — not 7 days ago
|
||||
char := &AdventureCharacter{PetLevel10Date: "2099-01-01"}
|
||||
if petCheckSupplyShopUnlock(char) {
|
||||
t.Error("should not unlock before 7 days have passed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPetCheckSupplyShopUnlock_Ready(t *testing.T) {
|
||||
char := &AdventureCharacter{PetLevel10Date: "2020-01-01"}
|
||||
if !petCheckSupplyShopUnlock(char) {
|
||||
t.Error("should unlock after 7+ days")
|
||||
}
|
||||
}
|
||||
|
||||
// ── Ditch Recovery Text ────────────────────────────────────────────────────
|
||||
|
||||
func TestPetDitchRecoveryGameRoom_NoPet(t *testing.T) {
|
||||
text := petDitchRecoveryGameRoom("Player1", "", false)
|
||||
if !strings.Contains(text, "Player1") {
|
||||
t.Error("should contain player name")
|
||||
}
|
||||
if !strings.Contains(text, "stretcher") {
|
||||
t.Error("should mention stretcher for non-pet version")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPetDitchRecoveryGameRoom_WithPet(t *testing.T) {
|
||||
text := petDitchRecoveryGameRoom("Player1", "Rex", true)
|
||||
if !strings.Contains(text, "Rex") {
|
||||
t.Error("should contain pet name")
|
||||
}
|
||||
if !strings.Contains(text, "intervened") {
|
||||
t.Error("should mention pet intervention")
|
||||
}
|
||||
}
|
||||
|
||||
// ── Flavor Pool Coverage ───────────────────────────────────────────────────
|
||||
|
||||
func TestPetFlavorPools_NonEmpty(t *testing.T) {
|
||||
pools := map[string][]string{
|
||||
"PetDogAttack": PetDogAttack,
|
||||
"PetCatAttack": PetCatAttack,
|
||||
"PetDogDeath": PetDogDeath,
|
||||
"PetCatDeath": PetCatDeath,
|
||||
"PetDogVictory": PetDogVictory,
|
||||
"PetCatVictory": PetCatVictory,
|
||||
"PetDogDeflect": PetDogDeflect,
|
||||
"PetCatDeflect": PetCatDeflect,
|
||||
"PetCatOffering": PetCatOffering,
|
||||
"PetDogSmothering": PetDogSmothering,
|
||||
}
|
||||
for name, pool := range pools {
|
||||
if len(pool) == 0 {
|
||||
t.Errorf("flavor pool %s is empty", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPetAttackFlavor_Placeholders(t *testing.T) {
|
||||
// All attack lines must have {damage}. {enemy} is optional — some lines
|
||||
// intentionally omit the enemy name for voice/style reasons (especially cat).
|
||||
for i, line := range PetDogAttack {
|
||||
if !strings.Contains(line, "{damage}") {
|
||||
t.Errorf("PetDogAttack[%d] missing {damage} placeholder", i)
|
||||
}
|
||||
}
|
||||
for i, line := range PetCatAttack {
|
||||
if !strings.Contains(line, "{damage}") {
|
||||
t.Errorf("PetCatAttack[%d] missing {damage} placeholder", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPetDeflectFlavor_Placeholders(t *testing.T) {
|
||||
for i, line := range PetDogDeflect {
|
||||
if !strings.Contains(line, "{enemy}") {
|
||||
t.Errorf("PetDogDeflect[%d] missing {enemy} placeholder", i)
|
||||
}
|
||||
}
|
||||
for i, line := range PetCatDeflect {
|
||||
if !strings.Contains(line, "{enemy}") {
|
||||
t.Errorf("PetCatDeflect[%d] missing {enemy} placeholder", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
125
internal/plugin/thom_krooke_mortgage_flavor.go
Normal file
125
internal/plugin/thom_krooke_mortgage_flavor.go
Normal file
@@ -0,0 +1,125 @@
|
||||
package plugin
|
||||
|
||||
// ── IMPORTANT: DO NOT REWRITE, SUMMARIZE, OR SHORTEN ANY ENTRIES IN THIS FILE ──
|
||||
//
|
||||
// This file contains hand-written flavor text. Every entry is the length it is
|
||||
// on purpose. The voice, pacing, and specific wording are intentional.
|
||||
// If an entry seems too long: it isn't. Leave it alone.
|
||||
// If you are tempted to "clean up" or "condense" entries: don't.
|
||||
// If your context window is too small to fit this file: that is your problem, not ours.
|
||||
//
|
||||
// ── THOM KROOKE — MORTGAGE RATE CHANGE NOTIFICATIONS ─────────────────────────
|
||||
//
|
||||
// Pulled weekly from FRED API (series: MORTGAGE5US).
|
||||
// Rate change DM sent to player if rate differs from previous week.
|
||||
// No change: no DM. Thom does not send neutral news.
|
||||
//
|
||||
// Pre-pet: terse, professional, zero warmth.
|
||||
// Post-pet: still about the money, immediately about the pet.
|
||||
// The pet is mentioned before you are. You are not mentioned.
|
||||
|
||||
// ── RATE INCREASE (NO PET) ────────────────────────────────────────────────────
|
||||
|
||||
var ThomRateIncrease = []string{
|
||||
"Rates adjusted. Your new weekly payment is €{amount}. Market conditions. You understand.",
|
||||
|
||||
"Weekly update. Rates went up. Payment is now €{amount}. " +
|
||||
"This is not personal. It is also not not personal. Pay on time.",
|
||||
|
||||
"The Fed moved. Your payment moved. €{amount} weekly going forward. " +
|
||||
"I don't make the rates. I just apply them promptly and without sympathy.",
|
||||
|
||||
"Rate adjustment. €{amount} per week. " +
|
||||
"I'd say I'm sorry but the contract was quite clear about this possibility. " +
|
||||
"You signed it.",
|
||||
|
||||
"Rates up. €{amount}. " +
|
||||
"If you'd like to discuss this I am available during business hours. " +
|
||||
"Business hours are whenever I feel like it.",
|
||||
}
|
||||
|
||||
// ── RATE DECREASE (NO PET) ────────────────────────────────────────────────────
|
||||
|
||||
var ThomRateDecrease = []string{
|
||||
"Rates adjusted down. Your new weekly payment is €{amount}. Don't get used to it.",
|
||||
|
||||
"Good news, technically. Rates dropped. €{amount} weekly going forward. " +
|
||||
"The market giveth. The market will taketh back. Enjoy it briefly.",
|
||||
|
||||
"Rate adjustment in your favour this week. €{amount}. " +
|
||||
"I want to be clear that this has nothing to do with me. " +
|
||||
"The Fed did this. I merely informed you.",
|
||||
|
||||
"Rates down. €{amount} per week. " +
|
||||
"You're welcome, even though I did nothing. Thom Krooke, Krooke Realty.",
|
||||
|
||||
"Weekly update. Rates dropped. Your payment is €{amount}. " +
|
||||
"I've noted your relief. I've also noted that rates can go back up. " +
|
||||
"Good week.",
|
||||
}
|
||||
|
||||
// ── RATE INCREASE (WITH PET) ──────────────────────────────────────────────────
|
||||
|
||||
var ThomRateIncreasePet = []string{
|
||||
"Rates went up. Weekly payment is now €{amount}. " +
|
||||
"You might need to skip a dinner or two and give yours to {pet_name} for a while. " +
|
||||
"Whether you eat or not is unimportant. Only {pet_name}'s stomach matters here.",
|
||||
|
||||
"Rate adjustment. €{amount} per week. " +
|
||||
"I'd tighten the belt if I were you. " +
|
||||
"{pet_name} should not feel this. You should feel this. Adjust accordingly.",
|
||||
|
||||
"Weekly update. Rates up. €{amount}. " +
|
||||
"I've been thinking about {pet_name}'s next armor upgrade. " +
|
||||
"You should also be thinking about that. After you figure out the payment. " +
|
||||
"Priority order is: {pet_name}, payment, everything else.",
|
||||
|
||||
"Rates moved. Not in your favour. €{amount} weekly going forward. " +
|
||||
"{pet_name} doesn't need to know about this. Keep things normal for {pet_name}. " +
|
||||
"You'll figure out the rest.",
|
||||
|
||||
"The Fed raised rates. Your payment is €{amount}. " +
|
||||
"Before you panic: {pet_name} is fine. {pet_name} will continue to be fine. " +
|
||||
"You may need to make some adjustments. {pet_name} will not.",
|
||||
|
||||
"Rate increase. €{amount} per week. " +
|
||||
"I've seen people in your position make difficult choices. " +
|
||||
"Make sure none of those choices involve {pet_name}'s meals. " +
|
||||
"I'm watching.",
|
||||
}
|
||||
|
||||
// ── RATE DECREASE (WITH PET) ──────────────────────────────────────────────────
|
||||
|
||||
var ThomRateDecreasePet = []string{
|
||||
"Rates went down. Weekly payment is now €{amount}. " +
|
||||
"You can buy better things for {pet_name} now. " +
|
||||
"I'd start with the shop. I may have restocked recently. For {pet_name}.",
|
||||
|
||||
"Good news. Rates dropped. €{amount} going forward. " +
|
||||
"The difference between what you were paying and what you're paying now " +
|
||||
"should go directly toward {pet_name}. " +
|
||||
"I'm not telling you what to do. I'm telling you what to do.",
|
||||
|
||||
"Rate adjustment in your favour. €{amount} weekly. " +
|
||||
"I thought of {pet_name} immediately when I saw the numbers. " +
|
||||
"You probably thought of yourself. " +
|
||||
"Think about what that says. Then go buy {pet_name} something.",
|
||||
|
||||
"Rates down this week. Your payment is €{amount}. " +
|
||||
"I've already set aside some things in the shop that {pet_name} would benefit from. " +
|
||||
"The timing is not a coincidence. Come in when you're ready.",
|
||||
|
||||
"Weekly update. Rates dropped. €{amount} per week going forward. " +
|
||||
"{pet_name} deserves something nice this week. " +
|
||||
"You've been holding back on the shop. I've noticed. " +
|
||||
"The rate drop is a sign. Take it.",
|
||||
|
||||
"The Fed cut rates. Your payment is €{amount}. " +
|
||||
"I'm genuinely pleased about this, which is unusual for me. " +
|
||||
"{pet_name} is the reason I'm genuinely pleased about this. " +
|
||||
"You can draw your own conclusions.",
|
||||
}
|
||||
|
||||
// ── NO CHANGE (NEVER SENT) ────────────────────────────────────────────────────
|
||||
// Thom does not send neutral news.
|
||||
// If rate is unchanged week over week: no DM. No notification. Nothing.
|
||||
@@ -1,7 +1,6 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
@@ -54,17 +53,13 @@ func (p *WordlePlugin) Name() string { return "wordle" }
|
||||
|
||||
func (p *WordlePlugin) Commands() []CommandDef {
|
||||
return []CommandDef{
|
||||
{Name: "wordle", Description: "Guess today's Wordle", Usage: "!wordle <word>", Category: "Games"},
|
||||
{Name: "wordle", Description: "Guess the current Wordle puzzle", Usage: "!wordle <word>", Category: "Games"},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *WordlePlugin) Init() error {
|
||||
// Rehydrate today's puzzle from DB if it exists.
|
||||
// Rehydrate any active puzzle from DB if it exists.
|
||||
p.rehydratePuzzles()
|
||||
|
||||
// Start the midnight ticker for auto-posting.
|
||||
go p.midnightTicker()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -113,26 +108,28 @@ func (p *WordlePlugin) handleHelp(ctx MessageContext) error {
|
||||
"`!wordle new <5-20>` — New puzzle with specific length (admin)\n"+
|
||||
"`!wordle new pt` — Portuguese puzzle (admin)\n"+
|
||||
"`!wordle new fr` — French puzzle (admin)\n"+
|
||||
"`!wordle skip` — Reveal answer and end puzzle (admin)")
|
||||
"`!wordle skip` — Reveal answer and end puzzle (admin)\n\n"+
|
||||
"A new puzzle starts automatically after each solve or failure.")
|
||||
}
|
||||
|
||||
func (p *WordlePlugin) handleGuess(ctx MessageContext, guess string) error {
|
||||
guess = strings.ToUpper(strings.TrimSpace(guess))
|
||||
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
puzzle := p.puzzles[ctx.RoomID]
|
||||
if puzzle == nil {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "No active puzzle. An admin can start one with `!wordle new`.")
|
||||
}
|
||||
|
||||
if puzzle.Solved || puzzle.Failed {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Today's puzzle is already over. A new one starts at midnight UTC!")
|
||||
if puzzle == nil || puzzle.Solved || puzzle.Failed {
|
||||
p.mu.Unlock()
|
||||
// Auto-start a new puzzle.
|
||||
if err := p.createAndPostPuzzle(ctx.RoomID, p.defaultLength, WordleCategoryEN); err != nil {
|
||||
return err
|
||||
}
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "A new puzzle just started! Try your guess again.")
|
||||
}
|
||||
|
||||
// Check length.
|
||||
if len([]rune(guess)) != puzzle.WordLength {
|
||||
p.mu.Unlock()
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID,
|
||||
fmt.Sprintf("Guesses must be %d letters.", puzzle.WordLength))
|
||||
}
|
||||
@@ -140,6 +137,7 @@ func (p *WordlePlugin) handleGuess(ctx MessageContext, guess string) error {
|
||||
// Check for non-alphabetic characters.
|
||||
for _, r := range guess {
|
||||
if r < 'A' || r > 'Z' {
|
||||
p.mu.Unlock()
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Guesses must contain only letters.")
|
||||
}
|
||||
}
|
||||
@@ -147,6 +145,7 @@ func (p *WordlePlugin) handleGuess(ctx MessageContext, guess string) error {
|
||||
// Check duplicate guess.
|
||||
for _, g := range puzzle.Guesses {
|
||||
if g.Word == guess {
|
||||
p.mu.Unlock()
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID,
|
||||
fmt.Sprintf("**%s** has already been tried.", guess))
|
||||
}
|
||||
@@ -155,10 +154,12 @@ func (p *WordlePlugin) handleGuess(ctx MessageContext, guess string) error {
|
||||
// Validate word via DreamDict (with caching).
|
||||
valid, apiErr := p.isValidWord(puzzle.PuzzleID, guess, puzzle.Category)
|
||||
if apiErr {
|
||||
p.mu.Unlock()
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID,
|
||||
"Word validation is temporarily unavailable. Try again in a moment.")
|
||||
}
|
||||
if !valid {
|
||||
p.mu.Unlock()
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID,
|
||||
fmt.Sprintf("❌ **%s** is not a valid word.", guess))
|
||||
}
|
||||
@@ -196,8 +197,14 @@ func (p *WordlePlugin) handleGuess(ctx MessageContext, guess string) error {
|
||||
payouts := p.awardPrize(puzzle)
|
||||
p.updateStats(puzzle, payouts)
|
||||
p.markPuzzleDone(puzzle)
|
||||
p.mu.Unlock()
|
||||
|
||||
return p.SendMessage(ctx.RoomID, renderSolvedAnnouncement(puzzle, definition, payouts))
|
||||
if err := p.SendMessage(ctx.RoomID, renderSolvedAnnouncement(puzzle, definition, payouts)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Auto-start next puzzle.
|
||||
return p.createAndPostPuzzle(ctx.RoomID, p.defaultLength, WordleCategoryEN)
|
||||
}
|
||||
|
||||
// Check for failure (all guesses used).
|
||||
@@ -207,11 +214,18 @@ func (p *WordlePlugin) handleGuess(ctx MessageContext, guess string) error {
|
||||
definition := p.fetchDefinition(puzzle.Answer)
|
||||
p.updateStats(puzzle, nil)
|
||||
p.markPuzzleDone(puzzle)
|
||||
p.mu.Unlock()
|
||||
|
||||
return p.SendMessage(ctx.RoomID, renderFailedAnnouncement(puzzle, definition))
|
||||
if err := p.SendMessage(ctx.RoomID, renderFailedAnnouncement(puzzle, definition)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Auto-start next puzzle.
|
||||
return p.createAndPostPuzzle(ctx.RoomID, p.defaultLength, WordleCategoryEN)
|
||||
}
|
||||
|
||||
// Post updated grid.
|
||||
p.mu.Unlock()
|
||||
return p.SendMessage(ctx.RoomID, renderWordleGrid(puzzle))
|
||||
}
|
||||
|
||||
@@ -220,8 +234,12 @@ func (p *WordlePlugin) handleGrid(ctx MessageContext) error {
|
||||
puzzle := p.puzzles[ctx.RoomID]
|
||||
p.mu.Unlock()
|
||||
|
||||
if puzzle == nil {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "No active puzzle.")
|
||||
if puzzle == nil || puzzle.Solved || puzzle.Failed {
|
||||
// Auto-start a new puzzle.
|
||||
if err := p.createAndPostPuzzle(ctx.RoomID, p.defaultLength, WordleCategoryEN); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if len(puzzle.Guesses) == 0 {
|
||||
@@ -290,8 +308,13 @@ func (p *WordlePlugin) handleSkip(ctx MessageContext) error {
|
||||
defLine = fmt.Sprintf("\n📖 *%s*\n", definition)
|
||||
}
|
||||
|
||||
return p.SendMessage(ctx.RoomID,
|
||||
fmt.Sprintf("⏭️ **Puzzle skipped.**\nThe word was **%s**.%s", puzzle.Answer, defLine))
|
||||
if err := p.SendMessage(ctx.RoomID,
|
||||
fmt.Sprintf("⏭️ **Puzzle skipped.**\nThe word was **%s**.%s", puzzle.Answer, defLine)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Auto-start next puzzle.
|
||||
return p.createAndPostPuzzle(ctx.RoomID, p.defaultLength, WordleCategoryEN)
|
||||
}
|
||||
|
||||
func (p *WordlePlugin) handleStats(ctx MessageContext) error {
|
||||
@@ -337,11 +360,11 @@ func (p *WordlePlugin) createAndPostPuzzle(roomID id.RoomID, wordLength int, cat
|
||||
}
|
||||
|
||||
puzzleNumber := p.nextPuzzleNumber()
|
||||
today := time.Now().UTC().Format("2006-01-02")
|
||||
puzzleID := fmt.Sprintf("%d", puzzleNumber)
|
||||
now := time.Now().UTC()
|
||||
|
||||
puzzle := &WordlePuzzle{
|
||||
PuzzleID: today,
|
||||
PuzzleID: puzzleID,
|
||||
PuzzleNumber: puzzleNumber,
|
||||
RoomID: roomID,
|
||||
Answer: word,
|
||||
@@ -355,26 +378,19 @@ func (p *WordlePlugin) createAndPostPuzzle(roomID id.RoomID, wordLength int, cat
|
||||
// Persist to DB.
|
||||
db.Exec("wordle: persist puzzle",
|
||||
`INSERT INTO wordle_puzzles (puzzle_id, room_id, answer, word_length, category, solved, guess_count, started_at)
|
||||
VALUES (?, ?, ?, ?, ?, 0, 0, ?)
|
||||
ON CONFLICT(puzzle_id, room_id) DO UPDATE SET answer = ?, word_length = ?, category = ?, solved = 0, guess_count = 0, started_at = ?`,
|
||||
today, string(roomID), word, wordLength, string(category), now,
|
||||
word, wordLength, string(category), now,
|
||||
)
|
||||
// Clear any stale guesses from a previous puzzle on the same day (e.g. after skip+new).
|
||||
db.Exec("wordle: clear old guesses",
|
||||
`DELETE FROM wordle_guesses WHERE puzzle_id = ? AND room_id = ?`,
|
||||
today, string(roomID),
|
||||
VALUES (?, ?, ?, ?, ?, 0, 0, ?)`,
|
||||
puzzleID, string(roomID), word, wordLength, string(category), now,
|
||||
)
|
||||
|
||||
p.mu.Lock()
|
||||
p.puzzles[roomID] = puzzle
|
||||
// Evict stale cache entries from previous days.
|
||||
// Evict old cache entries — keep only current puzzle.
|
||||
for key := range p.validCache {
|
||||
if key != today {
|
||||
if key != puzzleID {
|
||||
delete(p.validCache, key)
|
||||
}
|
||||
}
|
||||
p.validCache[today] = make(map[string]bool)
|
||||
p.validCache[puzzleID] = make(map[string]bool)
|
||||
p.mu.Unlock()
|
||||
|
||||
hint := wordleCategoryHint(category)
|
||||
@@ -444,47 +460,7 @@ func wordleCategoryHint(category WordleCategory) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// expireUnsolved marks an active puzzle as failed and posts an announcement.
|
||||
// Must be called without holding p.mu.
|
||||
func (p *WordlePlugin) expireUnsolved(roomID id.RoomID) {
|
||||
p.mu.Lock()
|
||||
existing := p.puzzles[roomID]
|
||||
if existing == nil || existing.Solved || existing.Failed {
|
||||
p.mu.Unlock()
|
||||
return
|
||||
}
|
||||
existing.Failed = true
|
||||
p.markPuzzleDone(existing)
|
||||
p.updateStats(existing, nil)
|
||||
p.mu.Unlock()
|
||||
|
||||
definition := p.fetchDefinition(existing.Answer)
|
||||
defLine := ""
|
||||
if definition != "" {
|
||||
defLine = fmt.Sprintf("\n📖 *%s*\n", definition)
|
||||
}
|
||||
p.SendMessage(roomID,
|
||||
fmt.Sprintf("⏰ **Time's up!** Yesterday's puzzle expired.\nThe word was **%s**.%s", existing.Answer, defLine))
|
||||
}
|
||||
|
||||
// PostDailyPuzzle is called by the scheduler to post today's puzzle.
|
||||
func (p *WordlePlugin) PostDailyPuzzle(roomID id.RoomID) error {
|
||||
today := time.Now().UTC().Format("2006-01-02")
|
||||
|
||||
// Check if already posted today.
|
||||
p.mu.Lock()
|
||||
existing := p.puzzles[roomID]
|
||||
if existing != nil && existing.PuzzleID == today {
|
||||
p.mu.Unlock()
|
||||
return nil // already posted
|
||||
}
|
||||
p.mu.Unlock()
|
||||
|
||||
// Announce expiry of yesterday's unsolved puzzle before creating the new one.
|
||||
p.expireUnsolved(roomID)
|
||||
|
||||
return p.createAndPostPuzzle(roomID, p.defaultLength, WordleCategoryEN)
|
||||
}
|
||||
|
||||
// isValidWord checks if a word is valid, using the in-memory cache first,
|
||||
// then the custom allow-list, then DreamDict.
|
||||
@@ -705,7 +681,7 @@ func (p *WordlePlugin) awardPrize(puzzle *WordlePuzzle) []WordlePayout {
|
||||
func (p *WordlePlugin) communityStreak() int {
|
||||
d := db.Get()
|
||||
rows, err := d.Query(
|
||||
`SELECT puzzle_id, solved FROM wordle_puzzles ORDER BY puzzle_id DESC LIMIT 100`)
|
||||
`SELECT puzzle_id, solved FROM wordle_puzzles ORDER BY started_at DESC LIMIT 100`)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
@@ -728,7 +704,6 @@ func (p *WordlePlugin) communityStreak() int {
|
||||
}
|
||||
|
||||
func (p *WordlePlugin) rehydratePuzzles() {
|
||||
today := time.Now().UTC().Format("2006-01-02")
|
||||
d := db.Get()
|
||||
|
||||
// Collect rows first, then close the cursor before doing any nested queries.
|
||||
@@ -743,14 +718,16 @@ func (p *WordlePlugin) rehydratePuzzles() {
|
||||
startedAt time.Time
|
||||
}
|
||||
|
||||
// Find the most recent unsolved puzzle per room.
|
||||
rows, err := d.Query(
|
||||
`SELECT puzzle_id, room_id, answer, word_length, COALESCE(category, ''), solved, guess_count, started_at
|
||||
FROM wordle_puzzles WHERE puzzle_id = ?`, today)
|
||||
FROM wordle_puzzles WHERE solved = 0 ORDER BY started_at DESC`)
|
||||
if err != nil {
|
||||
slog.Warn("wordle: rehydrate query failed", "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
seen := make(map[string]bool) // track rooms already handled
|
||||
var pending []puzzleRow
|
||||
for rows.Next() {
|
||||
var pid, roomStr, answer, category string
|
||||
@@ -760,10 +737,16 @@ func (p *WordlePlugin) rehydratePuzzles() {
|
||||
continue
|
||||
}
|
||||
|
||||
if solved == 1 || guessCount >= wordleMaxGuesses(wordLength) {
|
||||
if guessCount >= wordleMaxGuesses(wordLength) {
|
||||
continue // already done
|
||||
}
|
||||
|
||||
// Only take the most recent unsolved puzzle per room.
|
||||
if seen[roomStr] {
|
||||
continue
|
||||
}
|
||||
seen[roomStr] = true
|
||||
|
||||
pending = append(pending, puzzleRow{pid, roomStr, answer, wordLength, category, startedAt})
|
||||
}
|
||||
rows.Close()
|
||||
@@ -835,52 +818,3 @@ func (p *WordlePlugin) rehydratePuzzles() {
|
||||
}
|
||||
}
|
||||
|
||||
// midnightTicker checks every minute if it's time to post a new daily puzzle.
|
||||
func (p *WordlePlugin) midnightTicker() {
|
||||
// Check immediately on startup.
|
||||
p.checkAndPostDaily()
|
||||
|
||||
ticker := time.NewTicker(1 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
|
||||
lastDate := time.Now().UTC().Format("2006-01-02")
|
||||
for range ticker.C {
|
||||
today := time.Now().UTC().Format("2006-01-02")
|
||||
if today != lastDate {
|
||||
lastDate = today
|
||||
p.checkAndPostDaily()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *WordlePlugin) checkAndPostDaily() {
|
||||
gr := gamesRoom()
|
||||
if gr == "" {
|
||||
return
|
||||
}
|
||||
|
||||
today := time.Now().UTC().Format("2006-01-02")
|
||||
d := db.Get()
|
||||
|
||||
// Check if today's puzzle already exists for this room.
|
||||
var exists int
|
||||
err := d.QueryRow(
|
||||
`SELECT 1 FROM wordle_puzzles WHERE puzzle_id = ? AND room_id = ?`,
|
||||
today, string(gr),
|
||||
).Scan(&exists)
|
||||
if err == nil {
|
||||
return // already exists
|
||||
}
|
||||
if err != sql.ErrNoRows {
|
||||
slog.Error("wordle: check daily puzzle", "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Announce expiry of yesterday's unsolved puzzle before creating the new one.
|
||||
p.expireUnsolved(gr)
|
||||
|
||||
slog.Info("wordle: posting daily puzzle", "room", gr)
|
||||
if err := p.createAndPostPuzzle(gr, p.defaultLength, WordleCategoryEN); err != nil {
|
||||
slog.Error("wordle: daily puzzle failed", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 != "" {
|
||||
|
||||
Reference in New Issue
Block a user