mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-16 08:52:42 +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 (
|
var (
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
globalDB *sql.DB
|
globalDB *sql.DB
|
||||||
|
dataPath string
|
||||||
)
|
)
|
||||||
|
|
||||||
// Init opens (or creates) the SQLite database and runs migrations.
|
// Init opens (or creates) the SQLite database and runs migrations.
|
||||||
@@ -44,6 +45,7 @@ func Init(dataDir string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
globalDB = d
|
globalDB = d
|
||||||
|
dataPath = dataDir
|
||||||
slog.Info("database initialized", "path", dbPath)
|
slog.Info("database initialized", "path", dbPath)
|
||||||
return nil
|
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 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 misty_roll_target INTEGER NOT NULL DEFAULT 0`,
|
||||||
`ALTER TABLE adventure_characters ADD COLUMN arina_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 {
|
for _, stmt := range columnMigrations {
|
||||||
if _, err := d.Exec(stmt); err != nil {
|
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,
|
// RunMaintenance purges stale data from cache tables, old rate limits,
|
||||||
// expired logs, and runs SQLite optimization. Intended to run daily.
|
// expired logs, and runs SQLite optimization. Intended to run daily.
|
||||||
func RunMaintenance() {
|
func RunMaintenance() {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package plugin
|
package plugin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"math/rand/v2"
|
"math/rand/v2"
|
||||||
@@ -9,6 +11,8 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"gogobee/internal/db"
|
||||||
|
|
||||||
"maunium.net/go/mautrix"
|
"maunium.net/go/mautrix"
|
||||||
"maunium.net/go/mautrix/id"
|
"maunium.net/go/mautrix/id"
|
||||||
)
|
)
|
||||||
@@ -99,6 +103,7 @@ func (p *AdventurePlugin) Commands() []CommandDef {
|
|||||||
return []CommandDef{
|
return []CommandDef{
|
||||||
{Name: "adventure", Description: "Daily adventure game — dungeon, mine, forage, or rest", Usage: "!adventure", Category: "Games"},
|
{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: "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.rivalChallengeTicker()
|
||||||
go p.robbieTicker()
|
go p.robbieTicker()
|
||||||
go p.hospitalNudgeTicker()
|
go p.hospitalNudgeTicker()
|
||||||
|
go p.mortgageTicker()
|
||||||
|
|
||||||
// Auto-cashout any arena runs left in 'awaiting' from a prior restart
|
// Auto-cashout any arena runs left in 'awaiting' from a prior restart
|
||||||
p.arenaCleanupStaleRuns()
|
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") {
|
if !p.IsCommand(ctx.Body, "adventure") && !p.IsCommand(ctx.Body, "adv") {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -243,6 +254,8 @@ func (p *AdventurePlugin) dispatchCommand(ctx MessageContext) error {
|
|||||||
return p.handleRepairAllCmd(ctx)
|
return p.handleRepairAllCmd(ctx)
|
||||||
case strings.HasPrefix(lower, "repair "):
|
case strings.HasPrefix(lower, "repair "):
|
||||||
return p.handleRepairSlotCmd(ctx, strings.TrimSpace(args[7:]))
|
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.")
|
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 all`" + ` — Repair all damaged equipment
|
||||||
` + "`!adventure repair <slot>`" + ` — Repair a specific slot
|
` + "`!adventure repair <slot>`" + ` — Repair a specific slot
|
||||||
` + "`!hospital`" + ` — Visit St. Guildmore's Memorial Hospital (same-day revival when dead)
|
` + "`!hospital`" + ` — Visit St. Guildmore's Memorial Hospital (same-day revival when dead)
|
||||||
|
` + "`!thom`" + ` — Visit Thom Krooke (housing and loans)
|
||||||
` + "`!adventure help`" + ` — This message
|
` + "`!adventure help`" + ` — This message
|
||||||
|
|
||||||
**Arena:**
|
**Arena:**
|
||||||
@@ -511,10 +525,15 @@ func (p *AdventurePlugin) handleDMReply(ctx MessageContext) error {
|
|||||||
|
|
||||||
// Skip if it looks like a command for another plugin
|
// Skip if it looks like a command for another plugin
|
||||||
lower := strings.ToLower(body)
|
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
|
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
|
// Strip !adventure / !adv prefix if present — dispatch directly to avoid recursion
|
||||||
if strings.HasPrefix(lower, "!adventure") || strings.HasPrefix(lower, "!adv") {
|
if strings.HasPrefix(lower, "!adventure") || strings.HasPrefix(lower, "!adv") {
|
||||||
return p.dispatchCommand(ctx)
|
return p.dispatchCommand(ctx)
|
||||||
@@ -568,6 +587,12 @@ func (p *AdventurePlugin) resolvePendingInteraction(ctx MessageContext, interact
|
|||||||
return p.resolveHospitalPay(ctx, interaction)
|
return p.resolveHospitalPay(ctx, interaction)
|
||||||
case "npc_encounter":
|
case "npc_encounter":
|
||||||
return p.resolveNPCEncounter(ctx, interaction)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
@@ -790,6 +815,9 @@ func (p *AdventurePlugin) resolveActivity(ctx MessageContext, char *AdventureCha
|
|||||||
result.XPGained = int(float64(result.XPGained) * (1.0 + bonus))
|
result.XPGained = int(float64(result.XPGained) * (1.0 + bonus))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Double XP/money boost
|
||||||
|
advApplyBoost(result)
|
||||||
|
|
||||||
// Apply XP
|
// Apply XP
|
||||||
switch result.XPSkill {
|
switch result.XPSkill {
|
||||||
case "combat":
|
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.")
|
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
|
// Save equipment changes
|
||||||
for _, slot := range allSlots {
|
for _, slot := range allSlots {
|
||||||
if eq, ok := equip[slot]; ok {
|
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) {
|
func (p *AdventurePlugin) ensureCharacter(userID id.UserID) (*AdventureCharacter, map[EquipmentSlot]*AdvEquipment, error) {
|
||||||
char, err := loadAdvCharacter(userID)
|
char, err := loadAdvCharacter(userID)
|
||||||
if err != nil {
|
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)
|
displayName := p.DisplayName(userID)
|
||||||
if err := createAdvCharacter(userID, displayName); err != nil {
|
if err := createAdvCharacter(userID, displayName); err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
@@ -1229,3 +1272,44 @@ func (p *AdventurePlugin) ensureCharacter(userID id.UserID) (*AdventureCharacter
|
|||||||
return char, equip, nil
|
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.
|
// advCheckPartyBonus checks if other players visited the same location today.
|
||||||
func advCheckPartyBonus(userID id.UserID, location string) bool {
|
func advCheckPartyBonus(userID id.UserID, location string) bool {
|
||||||
logs, err := loadAdvTodayLogs()
|
logs, err := loadAdvLogsForDate(time.Now().UTC().Format("2006-01-02"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -148,21 +148,47 @@ func (p *AdventurePlugin) handleArenaFight(ctx MessageContext) error {
|
|||||||
return p.resolveArenaSurvival(ctx, run, char, tier, monster, combatLog, npcResult)
|
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)
|
deathChance := arenaDeathChance(monster, char, equip)
|
||||||
|
if petResult != nil && petResult.Deflected {
|
||||||
|
deathChance *= 0.5 // deflect halves death chance for this round
|
||||||
|
}
|
||||||
roll := rand.Float64()
|
roll := rand.Float64()
|
||||||
died := roll < deathChance
|
died := roll < deathChance
|
||||||
|
|
||||||
closeness := 1.0 - math.Abs(roll-deathChance)/math.Max(deathChance, 1-deathChance)
|
closeness := 1.0 - math.Abs(roll-deathChance)/math.Max(deathChance, 1-deathChance)
|
||||||
combatLog := generateArenaCombatLog(!died, closeness)
|
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 died {
|
||||||
if npcResult != nil && npcResult.Text != "" {
|
if npcResult != nil && npcResult.Text != "" {
|
||||||
combatLog.NPCText = 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)
|
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)
|
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)
|
return p.resolveArenaSurvival(ctx, run, char, tier, monster, combatLog, npcResult)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
petResult := petRollCombatActions(char, monster.Name)
|
||||||
|
|
||||||
deathChance := arenaDeathChance(monster, char, equip)
|
deathChance := arenaDeathChance(monster, char, equip)
|
||||||
|
if petResult != nil && petResult.Deflected {
|
||||||
|
deathChance *= 0.5
|
||||||
|
}
|
||||||
roll := rand.Float64()
|
roll := rand.Float64()
|
||||||
died := roll < deathChance
|
died := roll < deathChance
|
||||||
|
|
||||||
closeness := 1.0 - math.Abs(roll-deathChance)/math.Max(deathChance, 1-deathChance)
|
closeness := 1.0 - math.Abs(roll-deathChance)/math.Max(deathChance, 1-deathChance)
|
||||||
combatLog := generateArenaCombatLog(!died, closeness)
|
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 died {
|
||||||
if npcResult != nil && npcResult.Text != "" {
|
if npcResult != nil && npcResult.Text != "" {
|
||||||
combatLog.NPCText = 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)
|
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)
|
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)
|
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 ──
|
// ── Actual death — forfeit all session rewards ──
|
||||||
lostEarnings := run.Earnings + run.TierEarnings
|
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
|
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))
|
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
|
result.EquipBroken = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Double XP/money boost
|
||||||
|
advApplyBoost(result)
|
||||||
|
|
||||||
// Apply XP
|
// Apply XP
|
||||||
switch result.XPSkill {
|
switch result.XPSkill {
|
||||||
case "combat":
|
case "combat":
|
||||||
|
|||||||
@@ -71,6 +71,28 @@ type AdventureCharacter struct {
|
|||||||
NPCMsgCountDate string
|
NPCMsgCountDate string
|
||||||
MistyRollTarget int
|
MistyRollTarget int
|
||||||
ArinaRollTarget 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 {
|
type AdvEquipment struct {
|
||||||
@@ -232,6 +254,31 @@ func (c *AdventureCharacter) Kill() {
|
|||||||
c.LastDeathDate = time.Now().UTC().Format("2006-01-02")
|
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 ──────────────────────────────────────────────────────────
|
// ── Action Economy ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const maxCombatActions = 1
|
const maxCombatActions = 1
|
||||||
@@ -368,6 +415,9 @@ func loadAdvCharacter(userID id.UserID) (*AdventureCharacter, error) {
|
|||||||
var deadUntil, reprieveLast, babysitExp, pardonUsed sql.NullTime
|
var deadUntil, reprieveLast, babysitExp, pardonUsed sql.NullTime
|
||||||
var mistyLastSeen, arinaLastSeen, mistyBuffExp, mistyDebuffExp, arinaBuffExp 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(`
|
err := d.QueryRow(`
|
||||||
SELECT user_id, display_name,
|
SELECT user_id, display_name,
|
||||||
combat_level, mining_skill, foraging_skill, fishing_skill,
|
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_last_seen, arina_last_seen,
|
||||||
misty_buff_expires, misty_debuff_expires, arina_buff_expires,
|
misty_buff_expires, misty_debuff_expires, arina_buff_expires,
|
||||||
npc_msg_count, npc_msg_count_date,
|
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(
|
FROM adventure_characters WHERE user_id = ?`, string(userID)).Scan(
|
||||||
&c.UserID, &c.DisplayName,
|
&c.UserID, &c.DisplayName,
|
||||||
&c.CombatLevel, &c.MiningSkill, &c.ForagingSkill, &c.FishingSkill,
|
&c.CombatLevel, &c.MiningSkill, &c.ForagingSkill, &c.FishingSkill,
|
||||||
@@ -404,6 +461,13 @@ func loadAdvCharacter(userID id.UserID) (*AdventureCharacter, error) {
|
|||||||
&mistyBuffExp, &mistyDebuffExp, &arinaBuffExp,
|
&mistyBuffExp, &mistyDebuffExp, &arinaBuffExp,
|
||||||
&c.NPCMsgCount, &c.NPCMsgCountDate,
|
&c.NPCMsgCount, &c.NPCMsgCountDate,
|
||||||
&c.MistyRollTarget, &c.ArinaRollTarget,
|
&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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -413,6 +477,7 @@ func loadAdvCharacter(userID id.UserID) (*AdventureCharacter, error) {
|
|||||||
c.HolidayActionTaken = holidayTaken == 1
|
c.HolidayActionTaken = holidayTaken == 1
|
||||||
c.RivalUnlockedNotified = rivalUnlocked == 1
|
c.RivalUnlockedNotified = rivalUnlocked == 1
|
||||||
c.BabysitActive = babysitAct == 1
|
c.BabysitActive = babysitAct == 1
|
||||||
|
c.PetMorningDefense = petMorningDef == 1
|
||||||
if deadUntil.Valid {
|
if deadUntil.Valid {
|
||||||
c.DeadUntil = &deadUntil.Time
|
c.DeadUntil = &deadUntil.Time
|
||||||
}
|
}
|
||||||
@@ -440,6 +505,13 @@ func loadAdvCharacter(userID id.UserID) (*AdventureCharacter, error) {
|
|||||||
if arinaBuffExp.Valid {
|
if arinaBuffExp.Valid {
|
||||||
c.ArinaBuffExpires = &arinaBuffExp.Time
|
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
|
return c, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -494,6 +566,38 @@ func saveAdvCharacter(char *AdventureCharacter) error {
|
|||||||
if char.BabysitActive {
|
if char.BabysitActive {
|
||||||
babysitAct = 1
|
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(`
|
_, err := d.Exec(`
|
||||||
UPDATE adventure_characters SET
|
UPDATE adventure_characters SET
|
||||||
@@ -512,7 +616,14 @@ func saveAdvCharacter(char *AdventureCharacter) error {
|
|||||||
misty_last_seen = ?, arina_last_seen = ?,
|
misty_last_seen = ?, arina_last_seen = ?,
|
||||||
misty_buff_expires = ?, misty_debuff_expires = ?, arina_buff_expires = ?,
|
misty_buff_expires = ?, misty_debuff_expires = ?, arina_buff_expires = ?,
|
||||||
npc_msg_count = ?, npc_msg_count_date = ?,
|
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 = ?`,
|
WHERE user_id = ?`,
|
||||||
char.DisplayName, char.CombatLevel, char.MiningSkill, char.ForagingSkill, char.FishingSkill,
|
char.DisplayName, char.CombatLevel, char.MiningSkill, char.ForagingSkill, char.FishingSkill,
|
||||||
char.CombatXP, char.MiningXP, char.ForagingXP, char.FishingXP,
|
char.CombatXP, char.MiningXP, char.ForagingXP, char.FishingXP,
|
||||||
@@ -529,6 +640,13 @@ func saveAdvCharacter(char *AdventureCharacter) error {
|
|||||||
char.MistyBuffExpires, char.MistyDebuffExpires, char.ArinaBuffExpires,
|
char.MistyBuffExpires, char.MistyDebuffExpires, char.ArinaBuffExpires,
|
||||||
char.NPCMsgCount, char.NPCMsgCountDate,
|
char.NPCMsgCount, char.NPCMsgCountDate,
|
||||||
char.MistyRollTarget, char.ArinaRollTarget,
|
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),
|
string(char.UserID),
|
||||||
)
|
)
|
||||||
return err
|
return err
|
||||||
@@ -646,9 +764,20 @@ func loadAllAdvCharacters() ([]AdventureCharacter, error) {
|
|||||||
masterwork_drops_received,
|
masterwork_drops_received,
|
||||||
rival_pool, rival_unlocked_notified,
|
rival_pool, rival_unlocked_notified,
|
||||||
babysit_active, babysit_expires_at, babysit_skill_focus,
|
babysit_active, babysit_expires_at, babysit_skill_focus,
|
||||||
|
hospital_visits, robbie_visit_count, last_death_date,
|
||||||
combat_actions_used, harvest_actions_used,
|
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,
|
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`)
|
FROM adventure_characters`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -659,7 +788,10 @@ func loadAllAdvCharacters() ([]AdventureCharacter, error) {
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
c := AdventureCharacter{}
|
c := AdventureCharacter{}
|
||||||
var alive, actionTaken, holidayTaken, rivalUnlocked, babysitAct int
|
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(
|
if err := rows.Scan(
|
||||||
&c.UserID, &c.DisplayName,
|
&c.UserID, &c.DisplayName,
|
||||||
&c.CombatLevel, &c.MiningSkill, &c.ForagingSkill, &c.FishingSkill,
|
&c.CombatLevel, &c.MiningSkill, &c.ForagingSkill, &c.FishingSkill,
|
||||||
@@ -671,9 +803,20 @@ func loadAllAdvCharacters() ([]AdventureCharacter, error) {
|
|||||||
&c.MasterworkDropsReceived,
|
&c.MasterworkDropsReceived,
|
||||||
&c.RivalPool, &rivalUnlocked,
|
&c.RivalPool, &rivalUnlocked,
|
||||||
&babysitAct, &babysitExp, &c.BabysitSkillFocus,
|
&babysitAct, &babysitExp, &c.BabysitSkillFocus,
|
||||||
|
&c.HospitalVisits, &c.RobbieVisitCount, &c.LastDeathDate,
|
||||||
&c.CombatActionsUsed, &c.HarvestActionsUsed,
|
&c.CombatActionsUsed, &c.HarvestActionsUsed,
|
||||||
|
&pardonUsed,
|
||||||
|
&mistyLastSeen, &arinaLastSeen,
|
||||||
|
&mistyBuffExp, &mistyDebuffExp, &arinaBuffExp,
|
||||||
&c.NPCMsgCount, &c.NPCMsgCountDate,
|
&c.NPCMsgCount, &c.NPCMsgCountDate,
|
||||||
&c.MistyRollTarget, &c.ArinaRollTarget,
|
&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 {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -682,6 +825,14 @@ func loadAllAdvCharacters() ([]AdventureCharacter, error) {
|
|||||||
c.HolidayActionTaken = holidayTaken == 1
|
c.HolidayActionTaken = holidayTaken == 1
|
||||||
c.RivalUnlockedNotified = rivalUnlocked == 1
|
c.RivalUnlockedNotified = rivalUnlocked == 1
|
||||||
c.BabysitActive = babysitAct == 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 {
|
if deadUntil.Valid {
|
||||||
c.DeadUntil = &deadUntil.Time
|
c.DeadUntil = &deadUntil.Time
|
||||||
}
|
}
|
||||||
@@ -691,6 +842,24 @@ func loadAllAdvCharacters() ([]AdventureCharacter, error) {
|
|||||||
if babysitExp.Valid {
|
if babysitExp.Valid {
|
||||||
c.BabysitExpiresAt = &babysitExp.Time
|
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)
|
chars = append(chars, c)
|
||||||
}
|
}
|
||||||
return chars, rows.Err()
|
return chars, rows.Err()
|
||||||
@@ -701,7 +870,7 @@ func resetAllAdvDailyActions() error {
|
|||||||
// Only reset actions taken before today — protects against race if a player
|
// Only reset actions taken before today — protects against race if a player
|
||||||
// resolves their action at exactly midnight.
|
// resolves their action at exactly midnight.
|
||||||
today := time.Now().UTC().Format("2006-01-02")
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -762,14 +931,13 @@ type AdvDayLog struct {
|
|||||||
XPGained int
|
XPGained int
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadAdvTodayLogs() ([]AdvDayLog, error) {
|
func loadAdvLogsForDate(date string) ([]AdvDayLog, error) {
|
||||||
d := db.Get()
|
d := db.Get()
|
||||||
today := time.Now().UTC().Format("2006-01-02")
|
|
||||||
rows, err := d.Query(`
|
rows, err := d.Query(`
|
||||||
SELECT user_id, activity_type, COALESCE(location,''), outcome, loot_value, xp_gained
|
SELECT user_id, activity_type, COALESCE(location,''), outcome, loot_value, xp_gained
|
||||||
FROM adventure_activity_log
|
FROM adventure_activity_log
|
||||||
WHERE logged_at >= ? AND logged_at < DATE(?, '+1 day')
|
WHERE logged_at >= ? AND logged_at < DATE(?, '+1 day')
|
||||||
ORDER BY logged_at`, today, today)
|
ORDER BY logged_at`, date, date)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
package plugin
|
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 ───────────────────────────────────────────────────────
|
// ── FISHING FLAVOR TEXT ───────────────────────────────────────────────────────
|
||||||
//
|
//
|
||||||
// Tier 1: Muddy Pond — garbage, sad fish, puns, Stardew energy
|
// 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 {
|
switch npc {
|
||||||
case "misty":
|
case "misty":
|
||||||
char.MistyLastSeen = &now
|
char.MistyLastSeen = &now
|
||||||
|
char.MistyEncounterCount++
|
||||||
opening = mistyOpenings[rand.IntN(len(mistyOpenings))]
|
opening = mistyOpenings[rand.IntN(len(mistyOpenings))]
|
||||||
prompt = fmt.Sprintf("👤 A woman approaches you.\n\n_%s_\n\n"+
|
prompt = fmt.Sprintf("👤 A woman approaches you.\n\n_%s_\n\n"+
|
||||||
"Reply `yes` to give €%d, or `no` to walk away.", opening, mistyCost)
|
"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+"_")
|
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.MistyBuffExpires = expires
|
||||||
|
char.MistyDonatedCount++
|
||||||
|
|
||||||
|
// Pet reactivation: donating to Misty after chasing pet away
|
||||||
|
mistyReactivatePet(char)
|
||||||
|
|
||||||
if err := saveAdvCharacter(char); err != nil {
|
if err := saveAdvCharacter(char); err != nil {
|
||||||
slog.Error("npc: failed to save misty buff", "user", ctx.Sender, "err", err)
|
slog.Error("npc: failed to save misty buff", "user", ctx.Sender, "err", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
reply := mistyAcceptLines[rand.IntN(len(mistyAcceptLines))]
|
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))
|
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("**5️⃣ Shop** — buy/sell gear and loot\n")
|
||||||
sb.WriteString("**6️⃣ Blacksmith** — repair damaged equipment\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("Reply with the number and location, e.g: `1 Soggy Cellar`\n")
|
||||||
sb.WriteString("You have until midnight UTC to choose.")
|
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",
|
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))
|
p.DisplayName, p.CombatLevel, p.MiningSkill, p.ForagingSkill, p.FishingSkill))
|
||||||
|
if p.Location != "" {
|
||||||
sb.WriteString(fmt.Sprintf(" Went to: %s\n", p.Location))
|
sb.WriteString(fmt.Sprintf(" Went to: %s\n", p.Location))
|
||||||
sb.WriteString(fmt.Sprintf(" Outcome: %s\n\n", p.SummaryLine))
|
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 {
|
if bestPlayer == nil || p.LootValue > bestPlayer.LootValue {
|
||||||
bestPlayer = p
|
bestPlayer = p
|
||||||
@@ -723,11 +728,11 @@ func renderAdvLeaderboard(chars []AdventureCharacter) string {
|
|||||||
}
|
}
|
||||||
var entries []entry
|
var entries []entry
|
||||||
for _, c := range chars {
|
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{
|
entries = append(entries, entry{
|
||||||
Name: c.DisplayName,
|
Name: c.DisplayName,
|
||||||
Score: score,
|
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,
|
Streak: c.CurrentStreak,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -459,8 +459,11 @@ func (p *AdventurePlugin) resolveRivalRPSRound(ctx MessageContext, interaction *
|
|||||||
// Outcome line.
|
// Outcome line.
|
||||||
outcomePool := rivalRoundOutcomeWin
|
outcomePool := rivalRoundOutcomeWin
|
||||||
outcome := pickRivalFlavor(outcomePool)
|
outcome := pickRivalFlavor(outcomePool)
|
||||||
if strings.Contains(outcome, "%s") {
|
switch strings.Count(outcome, "%s") {
|
||||||
|
case 2:
|
||||||
outcome = fmt.Sprintf(outcome, rpsNames[rivalThrow], rpsNames[playerThrow])
|
outcome = fmt.Sprintf(outcome, rpsNames[rivalThrow], rpsNames[playerThrow])
|
||||||
|
case 1:
|
||||||
|
outcome = fmt.Sprintf(outcome, rpsNames[rivalThrow])
|
||||||
}
|
}
|
||||||
sb.WriteString(outcome + "\n\n")
|
sb.WriteString(outcome + "\n\n")
|
||||||
sb.WriteString(fmt.Sprintf("Score: You %d -- Rival %d\n\n", challenge.PlayerScore, challenge.RivalScore))
|
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++
|
challenge.RivalScore++
|
||||||
outcomePool := rivalRoundOutcomeLoss
|
outcomePool := rivalRoundOutcomeLoss
|
||||||
outcome := pickRivalFlavor(outcomePool)
|
outcome := pickRivalFlavor(outcomePool)
|
||||||
if strings.Contains(outcome, "%s") {
|
switch strings.Count(outcome, "%s") {
|
||||||
|
case 2:
|
||||||
outcome = fmt.Sprintf(outcome, rpsNames[rivalThrow], rpsNames[playerThrow])
|
outcome = fmt.Sprintf(outcome, rpsNames[rivalThrow], rpsNames[playerThrow])
|
||||||
|
case 1:
|
||||||
|
outcome = fmt.Sprintf(outcome, rpsNames[rivalThrow])
|
||||||
}
|
}
|
||||||
sb.WriteString(outcome + "\n\n")
|
sb.WriteString(outcome + "\n\n")
|
||||||
sb.WriteString(fmt.Sprintf("Score: You %d -- Rival %d\n\n", challenge.PlayerScore, challenge.RivalScore))
|
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
|
continue
|
||||||
}
|
}
|
||||||
takenItems = append(takenItems, item)
|
takenItems = append(takenItems, item)
|
||||||
totalPayout += 50
|
payout := item.Value / 4
|
||||||
|
if payout < 1 {
|
||||||
|
payout = 1
|
||||||
|
}
|
||||||
|
totalPayout += payout
|
||||||
communityTotal += item.Value
|
communityTotal += item.Value
|
||||||
if item.Type == "MasterworkGear" {
|
if item.Type == "MasterworkGear" {
|
||||||
masterworkTaken = true
|
masterworkTaken = true
|
||||||
@@ -246,14 +250,18 @@ func renderRobbieDM(userID id.UserID, items []AdvItem, total int64, mwTaken, gav
|
|||||||
cardShownOnLine := false
|
cardShownOnLine := false
|
||||||
for _, item := range items {
|
for _, item := range items {
|
||||||
emoji := slotEmoji(item.Slot)
|
emoji := slotEmoji(item.Slot)
|
||||||
|
payout := item.Value / 4
|
||||||
|
if payout < 1 {
|
||||||
|
payout = 1
|
||||||
|
}
|
||||||
if item.Type == "MasterworkGear" {
|
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 {
|
if gaveCard && !cardShownOnLine {
|
||||||
sb.WriteString(" + 🃏 Get Out of Medical Debt Free card")
|
sb.WriteString(" + 🃏 Get Out of Medical Debt Free card")
|
||||||
cardShownOnLine = true
|
cardShownOnLine = true
|
||||||
}
|
}
|
||||||
} else {
|
} 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')
|
sb.WriteByte('\n')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"math/rand/v2"
|
"math/rand/v2"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gogobee/internal/db"
|
"gogobee/internal/db"
|
||||||
@@ -102,6 +104,19 @@ func (p *AdventurePlugin) sendMorningDMs() {
|
|||||||
continue
|
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
|
// Send morning DM with choices
|
||||||
equip, err := loadAdvEquipment(char.UserID)
|
equip, err := loadAdvEquipment(char.UserID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -119,6 +134,9 @@ func (p *AdventurePlugin) sendMorningDMs() {
|
|||||||
holidayLabel = holName
|
holidayLabel = holName
|
||||||
}
|
}
|
||||||
text := renderAdvMorningDM(&char, equip, balance, bonuses, holidayLabel)
|
text := renderAdvMorningDM(&char, equip, balance, bonuses, holidayLabel)
|
||||||
|
if petEvent != "" {
|
||||||
|
text = fmt.Sprintf("🐾 *%s*\n\n%s", petEvent, text)
|
||||||
|
}
|
||||||
p.advMarkMenuSent(char.UserID)
|
p.advMarkMenuSent(char.UserID)
|
||||||
if err := p.SendDM(char.UserID, text); err != nil {
|
if err := p.SendDM(char.UserID, text); err != nil {
|
||||||
slog.Error("adventure: failed to send morning DM", "user", char.UserID, "err", err)
|
slog.Error("adventure: failed to send morning DM", "user", char.UserID, "err", err)
|
||||||
@@ -171,7 +189,11 @@ func (p *AdventurePlugin) postDailySummary() {
|
|||||||
return
|
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
|
// Group logs per user — holiday days may produce 2 entries per user
|
||||||
logsPerUser := make(map[id.UserID][]*AdvDayLog)
|
logsPerUser := make(map[id.UserID][]*AdvDayLog)
|
||||||
for i := range todayLogs {
|
for i := range todayLogs {
|
||||||
@@ -394,9 +416,39 @@ func (p *AdventurePlugin) midnightReset() error {
|
|||||||
// Check babysitting service expirations
|
// Check babysitting service expirations
|
||||||
p.checkBabysitExpiry(chars)
|
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
|
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 ───────────────────────────────────────────────────────────────────
|
// ── Helper ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func (p *AdventurePlugin) registerDMRoom(userID id.UserID) {
|
func (p *AdventurePlugin) registerDMRoom(userID id.UserID) {
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ func twinBeeMaxTier() int {
|
|||||||
if !c.Alive {
|
if !c.Alive {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
combined := c.CombatLevel + c.MiningSkill + c.ForagingSkill
|
combined := c.CombatLevel + c.MiningSkill + c.ForagingSkill + c.FishingSkill
|
||||||
if combined > bestLevel {
|
if combined > bestLevel {
|
||||||
bestLevel = combined
|
bestLevel = combined
|
||||||
}
|
}
|
||||||
@@ -264,12 +264,12 @@ func (p *AdventurePlugin) distributeTwinBeeRewards(result *TwinBeeResult) TwinBe
|
|||||||
var players []eligiblePlayer
|
var players []eligiblePlayer
|
||||||
totalWeight := 0
|
totalWeight := 0
|
||||||
for _, uid := range eligible {
|
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 {
|
for _, c := range chars {
|
||||||
if c.UserID == uid {
|
if c.UserID == uid {
|
||||||
weight = c.CombatLevel + c.MiningSkill + c.ForagingSkill
|
weight = c.CombatLevel + c.MiningSkill + c.ForagingSkill + c.FishingSkill
|
||||||
if weight < 3 {
|
if weight < 4 {
|
||||||
weight = 3
|
weight = 4
|
||||||
}
|
}
|
||||||
break
|
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.
|
// 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 {
|
if isDM {
|
||||||
p.mu.Lock()
|
p.mu.Lock()
|
||||||
gameRoom := p.findGameRoom(ctx.Sender)
|
gameRoom := p.findGameRoom(ctx.Sender)
|
||||||
p.mu.Unlock()
|
p.mu.Unlock()
|
||||||
if gameRoom != "" {
|
if gameRoom != "" {
|
||||||
ctx.RoomID = gameRoom
|
ctx.RoomID = gameRoom
|
||||||
|
ctx.EventID = ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1216,6 +1218,14 @@ func (p *HoldemPlugin) updateNPCBalance(delta int64) {
|
|||||||
delta, p.cfg.NPCName)
|
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) {
|
func (p *HoldemPlugin) recordScores(game *HoldemGame, winnings map[id.UserID]int64) {
|
||||||
for _, pl := range game.Players {
|
for _, pl := range game.Players {
|
||||||
if pl.IsNPC {
|
if pl.IsNPC {
|
||||||
|
|||||||
@@ -576,11 +576,13 @@ func (b *Base) SendNotice(roomID id.RoomID, text string) error {
|
|||||||
// SendReply sends a reply to a specific event.
|
// SendReply sends a reply to a specific event.
|
||||||
func (b *Base) SendReply(roomID id.RoomID, eventID id.EventID, text string) error {
|
func (b *Base) SendReply(roomID id.RoomID, eventID id.EventID, text string) error {
|
||||||
content := textContent(text)
|
content := textContent(text)
|
||||||
|
if eventID != "" {
|
||||||
content.RelatesTo = &event.RelatesTo{
|
content.RelatesTo = &event.RelatesTo{
|
||||||
InReplyTo: &event.InReplyTo{
|
InReplyTo: &event.InReplyTo{
|
||||||
EventID: eventID,
|
EventID: eventID,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
}
|
||||||
_, err := b.Client.SendMessageEvent(context.Background(), roomID, event.EventMessage, content)
|
_, err := b.Client.SendMessageEvent(context.Background(), roomID, event.EventMessage, content)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("failed to send reply", "room", roomID, "err", err)
|
slog.Error("failed to send reply", "room", roomID, "err", err)
|
||||||
|
|||||||
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
|
package plugin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
@@ -54,17 +53,13 @@ func (p *WordlePlugin) Name() string { return "wordle" }
|
|||||||
|
|
||||||
func (p *WordlePlugin) Commands() []CommandDef {
|
func (p *WordlePlugin) Commands() []CommandDef {
|
||||||
return []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 {
|
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()
|
p.rehydratePuzzles()
|
||||||
|
|
||||||
// Start the midnight ticker for auto-posting.
|
|
||||||
go p.midnightTicker()
|
|
||||||
|
|
||||||
return nil
|
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 <5-20>` — New puzzle with specific length (admin)\n"+
|
||||||
"`!wordle new pt` — Portuguese puzzle (admin)\n"+
|
"`!wordle new pt` — Portuguese puzzle (admin)\n"+
|
||||||
"`!wordle new fr` — French 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 {
|
func (p *WordlePlugin) handleGuess(ctx MessageContext, guess string) error {
|
||||||
guess = strings.ToUpper(strings.TrimSpace(guess))
|
guess = strings.ToUpper(strings.TrimSpace(guess))
|
||||||
|
|
||||||
p.mu.Lock()
|
p.mu.Lock()
|
||||||
defer p.mu.Unlock()
|
|
||||||
|
|
||||||
puzzle := p.puzzles[ctx.RoomID]
|
puzzle := p.puzzles[ctx.RoomID]
|
||||||
if puzzle == nil {
|
if puzzle == nil || puzzle.Solved || puzzle.Failed {
|
||||||
return p.SendReply(ctx.RoomID, ctx.EventID, "No active puzzle. An admin can start one with `!wordle new`.")
|
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.")
|
||||||
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!")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check length.
|
// Check length.
|
||||||
if len([]rune(guess)) != puzzle.WordLength {
|
if len([]rune(guess)) != puzzle.WordLength {
|
||||||
|
p.mu.Unlock()
|
||||||
return p.SendReply(ctx.RoomID, ctx.EventID,
|
return p.SendReply(ctx.RoomID, ctx.EventID,
|
||||||
fmt.Sprintf("Guesses must be %d letters.", puzzle.WordLength))
|
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.
|
// Check for non-alphabetic characters.
|
||||||
for _, r := range guess {
|
for _, r := range guess {
|
||||||
if r < 'A' || r > 'Z' {
|
if r < 'A' || r > 'Z' {
|
||||||
|
p.mu.Unlock()
|
||||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Guesses must contain only letters.")
|
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.
|
// Check duplicate guess.
|
||||||
for _, g := range puzzle.Guesses {
|
for _, g := range puzzle.Guesses {
|
||||||
if g.Word == guess {
|
if g.Word == guess {
|
||||||
|
p.mu.Unlock()
|
||||||
return p.SendReply(ctx.RoomID, ctx.EventID,
|
return p.SendReply(ctx.RoomID, ctx.EventID,
|
||||||
fmt.Sprintf("**%s** has already been tried.", guess))
|
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).
|
// Validate word via DreamDict (with caching).
|
||||||
valid, apiErr := p.isValidWord(puzzle.PuzzleID, guess, puzzle.Category)
|
valid, apiErr := p.isValidWord(puzzle.PuzzleID, guess, puzzle.Category)
|
||||||
if apiErr {
|
if apiErr {
|
||||||
|
p.mu.Unlock()
|
||||||
return p.SendReply(ctx.RoomID, ctx.EventID,
|
return p.SendReply(ctx.RoomID, ctx.EventID,
|
||||||
"Word validation is temporarily unavailable. Try again in a moment.")
|
"Word validation is temporarily unavailable. Try again in a moment.")
|
||||||
}
|
}
|
||||||
if !valid {
|
if !valid {
|
||||||
|
p.mu.Unlock()
|
||||||
return p.SendReply(ctx.RoomID, ctx.EventID,
|
return p.SendReply(ctx.RoomID, ctx.EventID,
|
||||||
fmt.Sprintf("❌ **%s** is not a valid word.", guess))
|
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)
|
payouts := p.awardPrize(puzzle)
|
||||||
p.updateStats(puzzle, payouts)
|
p.updateStats(puzzle, payouts)
|
||||||
p.markPuzzleDone(puzzle)
|
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).
|
// Check for failure (all guesses used).
|
||||||
@@ -207,11 +214,18 @@ func (p *WordlePlugin) handleGuess(ctx MessageContext, guess string) error {
|
|||||||
definition := p.fetchDefinition(puzzle.Answer)
|
definition := p.fetchDefinition(puzzle.Answer)
|
||||||
p.updateStats(puzzle, nil)
|
p.updateStats(puzzle, nil)
|
||||||
p.markPuzzleDone(puzzle)
|
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.
|
// Post updated grid.
|
||||||
|
p.mu.Unlock()
|
||||||
return p.SendMessage(ctx.RoomID, renderWordleGrid(puzzle))
|
return p.SendMessage(ctx.RoomID, renderWordleGrid(puzzle))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -220,8 +234,12 @@ func (p *WordlePlugin) handleGrid(ctx MessageContext) error {
|
|||||||
puzzle := p.puzzles[ctx.RoomID]
|
puzzle := p.puzzles[ctx.RoomID]
|
||||||
p.mu.Unlock()
|
p.mu.Unlock()
|
||||||
|
|
||||||
if puzzle == nil {
|
if puzzle == nil || puzzle.Solved || puzzle.Failed {
|
||||||
return p.SendReply(ctx.RoomID, ctx.EventID, "No active puzzle.")
|
// 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 {
|
if len(puzzle.Guesses) == 0 {
|
||||||
@@ -290,8 +308,13 @@ func (p *WordlePlugin) handleSkip(ctx MessageContext) error {
|
|||||||
defLine = fmt.Sprintf("\n📖 *%s*\n", definition)
|
defLine = fmt.Sprintf("\n📖 *%s*\n", definition)
|
||||||
}
|
}
|
||||||
|
|
||||||
return p.SendMessage(ctx.RoomID,
|
if err := p.SendMessage(ctx.RoomID,
|
||||||
fmt.Sprintf("⏭️ **Puzzle skipped.**\nThe word was **%s**.%s", puzzle.Answer, defLine))
|
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 {
|
func (p *WordlePlugin) handleStats(ctx MessageContext) error {
|
||||||
@@ -337,11 +360,11 @@ func (p *WordlePlugin) createAndPostPuzzle(roomID id.RoomID, wordLength int, cat
|
|||||||
}
|
}
|
||||||
|
|
||||||
puzzleNumber := p.nextPuzzleNumber()
|
puzzleNumber := p.nextPuzzleNumber()
|
||||||
today := time.Now().UTC().Format("2006-01-02")
|
puzzleID := fmt.Sprintf("%d", puzzleNumber)
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
|
|
||||||
puzzle := &WordlePuzzle{
|
puzzle := &WordlePuzzle{
|
||||||
PuzzleID: today,
|
PuzzleID: puzzleID,
|
||||||
PuzzleNumber: puzzleNumber,
|
PuzzleNumber: puzzleNumber,
|
||||||
RoomID: roomID,
|
RoomID: roomID,
|
||||||
Answer: word,
|
Answer: word,
|
||||||
@@ -355,26 +378,19 @@ func (p *WordlePlugin) createAndPostPuzzle(roomID id.RoomID, wordLength int, cat
|
|||||||
// Persist to DB.
|
// Persist to DB.
|
||||||
db.Exec("wordle: persist puzzle",
|
db.Exec("wordle: persist puzzle",
|
||||||
`INSERT INTO wordle_puzzles (puzzle_id, room_id, answer, word_length, category, solved, guess_count, started_at)
|
`INSERT INTO wordle_puzzles (puzzle_id, room_id, answer, word_length, category, solved, guess_count, started_at)
|
||||||
VALUES (?, ?, ?, ?, ?, 0, 0, ?)
|
VALUES (?, ?, ?, ?, ?, 0, 0, ?)`,
|
||||||
ON CONFLICT(puzzle_id, room_id) DO UPDATE SET answer = ?, word_length = ?, category = ?, solved = 0, guess_count = 0, started_at = ?`,
|
puzzleID, string(roomID), word, wordLength, string(category), now,
|
||||||
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),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
p.mu.Lock()
|
p.mu.Lock()
|
||||||
p.puzzles[roomID] = puzzle
|
p.puzzles[roomID] = puzzle
|
||||||
// Evict stale cache entries from previous days.
|
// Evict old cache entries — keep only current puzzle.
|
||||||
for key := range p.validCache {
|
for key := range p.validCache {
|
||||||
if key != today {
|
if key != puzzleID {
|
||||||
delete(p.validCache, key)
|
delete(p.validCache, key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
p.validCache[today] = make(map[string]bool)
|
p.validCache[puzzleID] = make(map[string]bool)
|
||||||
p.mu.Unlock()
|
p.mu.Unlock()
|
||||||
|
|
||||||
hint := wordleCategoryHint(category)
|
hint := wordleCategoryHint(category)
|
||||||
@@ -444,47 +460,7 @@ func wordleCategoryHint(category WordleCategory) string {
|
|||||||
return ""
|
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,
|
// isValidWord checks if a word is valid, using the in-memory cache first,
|
||||||
// then the custom allow-list, then DreamDict.
|
// then the custom allow-list, then DreamDict.
|
||||||
@@ -705,7 +681,7 @@ func (p *WordlePlugin) awardPrize(puzzle *WordlePuzzle) []WordlePayout {
|
|||||||
func (p *WordlePlugin) communityStreak() int {
|
func (p *WordlePlugin) communityStreak() int {
|
||||||
d := db.Get()
|
d := db.Get()
|
||||||
rows, err := d.Query(
|
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 {
|
if err != nil {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
@@ -728,7 +704,6 @@ func (p *WordlePlugin) communityStreak() int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p *WordlePlugin) rehydratePuzzles() {
|
func (p *WordlePlugin) rehydratePuzzles() {
|
||||||
today := time.Now().UTC().Format("2006-01-02")
|
|
||||||
d := db.Get()
|
d := db.Get()
|
||||||
|
|
||||||
// Collect rows first, then close the cursor before doing any nested queries.
|
// Collect rows first, then close the cursor before doing any nested queries.
|
||||||
@@ -743,14 +718,16 @@ func (p *WordlePlugin) rehydratePuzzles() {
|
|||||||
startedAt time.Time
|
startedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Find the most recent unsolved puzzle per room.
|
||||||
rows, err := d.Query(
|
rows, err := d.Query(
|
||||||
`SELECT puzzle_id, room_id, answer, word_length, COALESCE(category, ''), solved, guess_count, started_at
|
`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 {
|
if err != nil {
|
||||||
slog.Warn("wordle: rehydrate query failed", "err", err)
|
slog.Warn("wordle: rehydrate query failed", "err", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
seen := make(map[string]bool) // track rooms already handled
|
||||||
var pending []puzzleRow
|
var pending []puzzleRow
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var pid, roomStr, answer, category string
|
var pid, roomStr, answer, category string
|
||||||
@@ -760,10 +737,16 @@ func (p *WordlePlugin) rehydratePuzzles() {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if solved == 1 || guessCount >= wordleMaxGuesses(wordLength) {
|
if guessCount >= wordleMaxGuesses(wordLength) {
|
||||||
continue // already done
|
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})
|
pending = append(pending, puzzleRow{pid, roomStr, answer, wordLength, category, startedAt})
|
||||||
}
|
}
|
||||||
rows.Close()
|
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.
|
// renderWordleStartAnnouncement renders the puzzle start message.
|
||||||
func renderWordleStartAnnouncement(puzzleNumber, wordLength int, hint string) string {
|
func renderWordleStartAnnouncement(puzzleNumber, wordLength int, hint string) string {
|
||||||
base := fmt.Sprintf(
|
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),
|
puzzleNumber, wordLength, wordleMaxGuesses(wordLength),
|
||||||
)
|
)
|
||||||
if hint != "" {
|
if hint != "" {
|
||||||
|
|||||||
Reference in New Issue
Block a user