From b15c13cde7dc1dc0b9abba5e6e126c2425c9fe95 Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Sat, 18 Apr 2026 01:07:25 -0700 Subject: [PATCH] Add version system, tax tracking, lottery winner DMs, fmtEuro, audit fixes - Version system: per-plugin versions via Versioned interface, crash_log and version_history DB tables, !version command, crash stats in !botinfo - Tax tracking: tax_ledger table, trackTaxPaid across all communityTax and communityPotAdd sites, tax paid display in !stats and !superstatsexplusalpha - Lottery: DM winners with winning ticket details and matched numbers bolded - fmtEuro: generic currency formatter with thousand separators across all economy display paths - Audit fixes: streak_decayed column for Streak Survivor achievement, combat_narrative empty-phase guard, crafting success rate integer division, RecordCrash nil-DB guard Co-Authored-By: Claude Opus 4.6 --- internal/db/db.go | 56 +++++++++++ internal/plugin/achievements.go | 124 ++++++++++++++++++++++++- internal/plugin/adventure_babysit.go | 71 +++++++++++++- internal/plugin/adventure_character.go | 30 +++++- internal/plugin/adventure_events.go | 5 +- internal/plugin/adventure_hospital.go | 17 +++- internal/plugin/adventure_rival.go | 30 +++++- internal/plugin/adventure_scheduler.go | 46 +++++++-- internal/plugin/adventure_twinbee.go | 3 +- internal/plugin/blackjack.go | 17 +++- internal/plugin/botinfo.go | 50 +++++++++- internal/plugin/euro.go | 23 ++++- internal/plugin/hangman.go | 10 +- internal/plugin/lottery.go | 15 ++- internal/plugin/lottery_draw.go | 85 +++++++++++++---- internal/plugin/plugin.go | 15 +++ internal/plugin/stats.go | 20 +++- internal/plugin/uno.go | 9 +- internal/plugin/uno_multi.go | 6 +- internal/plugin/wordle.go | 8 +- internal/version/version.go | 28 ++++++ main.go | 7 +- 22 files changed, 600 insertions(+), 75 deletions(-) create mode 100644 internal/version/version.go diff --git a/internal/db/db.go b/internal/db/db.go index e15434b..a102a23 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -133,6 +133,8 @@ func runMigrations(d *sql.DB) error { `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`, + `ALTER TABLE adventure_characters ADD COLUMN auto_babysit INTEGER NOT NULL DEFAULT 0`, + `ALTER TABLE adventure_characters ADD COLUMN streak_decayed INTEGER NOT NULL DEFAULT 0`, } for _, stmt := range columnMigrations { if _, err := d.Exec(stmt); err != nil { @@ -165,6 +167,39 @@ func MarkJobCompleted(jobName, dateKey string) { ) } +// RecordCrash logs a panic/crash event with version and plugin context. +func RecordCrash(botVersion, plugin, message string) { + if globalDB == nil { + return + } + Exec("crash log", + `INSERT INTO crash_log (bot_version, plugin, message) VALUES (?, ?, ?)`, + botVersion, plugin, message, + ) +} + +// RecordStartup logs a version startup event. +func RecordStartup(version, commit string) { + Exec("version history", + `INSERT INTO version_history (version, commit_sha) VALUES (?, ?)`, + version, commit, + ) +} + +// CrashCount returns the number of crashes for a given bot version. +func CrashCount(botVersion string) int { + var count int + _ = Get().QueryRow(`SELECT COUNT(*) FROM crash_log WHERE bot_version = ?`, botVersion).Scan(&count) + return count +} + +// CrashCountAll returns the total number of recorded crashes. +func CrashCountAll() int { + var count int + _ = Get().QueryRow(`SELECT COUNT(*) FROM crash_log`).Scan(&count) + return count +} + // CacheGet returns cached data for the given key if it exists and is within ttlSeconds. // Returns empty string if not cached or expired. func CacheGet(key string, ttlSeconds int) string { @@ -1282,6 +1317,27 @@ CREATE TABLE IF NOT EXISTS lottery_history ( created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); +CREATE TABLE IF NOT EXISTS crash_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + bot_version TEXT NOT NULL, + plugin TEXT NOT NULL DEFAULT '', + message TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS version_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + version TEXT NOT NULL, + commit_sha TEXT NOT NULL DEFAULT '', + started_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS tax_ledger ( + user_id TEXT PRIMARY KEY, + total_paid INTEGER NOT NULL DEFAULT 0, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + ` // SeedSchedulerDefaults inserts default scheduler jobs if they don't exist. diff --git a/internal/plugin/achievements.go b/internal/plugin/achievements.go index 68a07f1..59f6770 100644 --- a/internal/plugin/achievements.go +++ b/internal/plugin/achievements.go @@ -44,7 +44,8 @@ func NewAchievementsPlugin(client *mautrix.Client, registry AchievementRegistry) return p } -func (p *AchievementsPlugin) Name() string { return "achievements" } +func (p *AchievementsPlugin) Name() string { return "achievements" } +func (p *AchievementsPlugin) Version() string { return "1.2.0" } func (p *AchievementsPlugin) Commands() []CommandDef { return []CommandDef{ @@ -1015,6 +1016,127 @@ func (p *AchievementsPlugin) buildAchievements() []achievementDef { return count > 0 }, }, + + // ── Streak & Babysit ──────────────────────────────────────────────── + { + ID: "adv_streaker", Name: "Streaker", Description: "Maintained a 7-day streak. Running wild.", + Emoji: "🏃", + Check: func(d *sql.DB, u id.UserID) bool { + var streak int + _ = d.QueryRow(`SELECT best_streak FROM adventure_characters WHERE user_id = ?`, string(u)).Scan(&streak) + return streak >= 7 + }, + }, + { + ID: "adv_streak_60", Name: "Obsessed", Description: "60 days. The game plays you now.", + Emoji: "🔥", + Check: func(d *sql.DB, u id.UserID) bool { + var streak int + _ = d.QueryRow(`SELECT best_streak FROM adventure_characters WHERE user_id = ?`, string(u)).Scan(&streak) + return streak >= 60 + }, + }, + { + ID: "adv_streak_100", Name: "Centurion", Description: "100 days. This is your life now.", + Emoji: "💯", + Check: func(d *sql.DB, u id.UserID) bool { + var streak int + _ = d.QueryRow(`SELECT best_streak FROM adventure_characters WHERE user_id = ?`, string(u)).Scan(&streak) + return streak >= 100 + }, + }, + { + ID: "adv_babysit_hired", Name: "Delegation", Description: "Hired someone to do the hard part.", + Emoji: "🍼", + Check: func(d *sql.DB, u id.UserID) bool { + var count int + _ = d.QueryRow(`SELECT COUNT(*) FROM babysit_log WHERE user_id = ?`, string(u)).Scan(&count) + return count > 0 + }, + }, + { + ID: "adv_auto_babysit", Name: "Safety Net", Description: "Auto-babysit saved your streak. Worth every euro.", + Emoji: "🛡️", + Check: func(d *sql.DB, u id.UserID) bool { + // Granted by scheduler when auto-babysit fires + return false + }, + }, + { + ID: "adv_streak_survivor", Name: "Streak Survivor", Description: "Rebuilt your streak to 14 after losing it.", + Emoji: "🔄", + Check: func(d *sql.DB, u id.UserID) bool { + var current, best, decayed int + err := d.QueryRow(`SELECT current_streak, best_streak, streak_decayed FROM adventure_characters WHERE user_id = ?`, string(u)).Scan(¤t, &best, &decayed) + return err == nil && current >= 14 && decayed > 0 + }, + }, + + // ── Combat Moments ────────────────────────────────────────────────── + { + ID: "combat_near_death", Name: "Clutch", Description: "Won with less than 15% HP. Barely.", + Emoji: "💔", + Check: func(d *sql.DB, u id.UserID) bool { + // Granted by combat bridge on near-death victory + return false + }, + }, + { + ID: "combat_pet_save", Name: "Good Boy", Description: "Your pet distracted the enemy at exactly the right moment.", + Emoji: "🐾", + Check: func(d *sql.DB, u id.UserID) bool { + // Granted by combat bridge on pet whiff event + return false + }, + }, + { + ID: "combat_sniper_kill", Name: "One Shot", Description: "Arina ended the fight before it started.", + Emoji: "🎯", + Check: func(d *sql.DB, u id.UserID) bool { + // Granted by combat bridge on sniper kill + return false + }, + }, + { + ID: "combat_death_save", Name: "Sovereign's Reprieve", Description: "The crown said not today.", + Emoji: "👑", + Check: func(d *sql.DB, u id.UserID) bool { + // Granted by combat bridge on death save event + return false + }, + }, + { + ID: "combat_misty_clutch", Name: "Misty's Touch", Description: "Healed mid-combat. She was watching.", + Emoji: "💚", + Check: func(d *sql.DB, u id.UserID) bool { + // Granted by combat bridge on Misty heal event + return false + }, + }, + { + ID: "combat_consumable_used", Name: "Prepared", Description: "Used a consumable in combat. Preparation pays off.", + Emoji: "🧪", + Check: func(d *sql.DB, u id.UserID) bool { + // Granted by combat bridge on consumable use event + return false + }, + }, + { + ID: "adv_first_craft", Name: "Apprentice Herbalist", Description: "Crafted your first consumable. The wilds provide.", + Emoji: "🌿", + Check: func(d *sql.DB, u id.UserID) bool { + // Granted by combat bridge on first successful craft + return false + }, + }, + { + ID: "adv_craft_t5", Name: "Master Alchemist", Description: "Crafted a Tier 5 consumable. Nature bends to your will.", + Emoji: "⚗️", + Check: func(d *sql.DB, u id.UserID) bool { + // Granted by combat bridge on T5 craft + return false + }, + }, } } diff --git a/internal/plugin/adventure_babysit.go b/internal/plugin/adventure_babysit.go index d69faf9..a3e17d8 100644 --- a/internal/plugin/adventure_babysit.go +++ b/internal/plugin/adventure_babysit.go @@ -72,15 +72,41 @@ func (p *AdventurePlugin) handleBabysitCmd(ctx MessageContext, args string) erro return p.handleBabysitPurchase(ctx, 7) case lower == "month": return p.handleBabysitPurchase(ctx, 30) + case lower == "auto": + return p.handleBabysitAutoToggle(ctx) default: return p.SendDM(ctx.Sender, "🍼 **Adventurer Babysitting Service**\n\n"+ "`!adventure babysit week` — 7 days of service\n"+ "`!adventure babysit month` — 30 days of service\n"+ + "`!adventure babysit auto` — toggle auto-babysit on missed days\n"+ "`!adventure babysit status` — check service status\n"+ "`!adventure babysit cancel` — cancel early (no refund)") } } +func (p *AdventurePlugin) handleBabysitAutoToggle(ctx MessageContext) error { + userMu := p.advUserLock(ctx.Sender) + userMu.Lock() + defer userMu.Unlock() + + char, err := loadAdvCharacter(ctx.Sender) + if err != nil { + return p.SendDM(ctx.Sender, "No adventurer found. Type `!adventure` to create one.") + } + + char.AutoBabysit = !char.AutoBabysit + if err := saveAdvCharacter(char); err != nil { + slog.Error("babysit: failed to save auto-babysit toggle", "user", ctx.Sender, "err", err) + return p.SendDM(ctx.Sender, "Something went wrong. Try again.") + } + + daily := babysitDailyCost(char.CombatLevel) + if char.AutoBabysit { + return p.SendDM(ctx.Sender, fmt.Sprintf("🍼 **Auto-babysit: ON**\n\nIf you miss a day, the babysitter steps in automatically (€%d/day). Your streak stays alive. Disable anytime with `!adventure babysit auto`.", daily)) + } + return p.SendDM(ctx.Sender, "🍼 **Auto-babysit: OFF**\n\nYou're on your own. Miss a day and your streak takes the hit.") +} + func (p *AdventurePlugin) handleBabysitPurchase(ctx MessageContext, days int) error { userMu := p.advUserLock(ctx.Sender) userMu.Lock() @@ -103,7 +129,7 @@ func (p *AdventurePlugin) handleBabysitPurchase(ctx MessageContext, days int) er totalCost := daily * days balance := p.euro.GetBalance(char.UserID) if balance < float64(totalCost) { - return p.SendDM(ctx.Sender, fmt.Sprintf("🍼 The babysitting service costs €%d for %d days. You have €%.0f. The service has standards. Not many, but some.", totalCost, days, balance)) + return p.SendDM(ctx.Sender, fmt.Sprintf("🍼 The babysitting service costs %s for %d days. You have %s. The service has standards. Not many, but some.", fmtEuro(totalCost), days, fmtEuro(balance))) } // Debit gold @@ -148,8 +174,13 @@ func (p *AdventurePlugin) handleBabysitStatus(ctx MessageContext) error { return p.SendDM(ctx.Sender, "No adventurer found.") } + autoLabel := "OFF" + if char.AutoBabysit { + autoLabel = "ON" + } + if !char.BabysitActive { - return p.SendDM(ctx.Sender, "🍼 No active babysitting service. Use `!adventure babysit week` or `!adventure babysit month` to start.") + return p.SendDM(ctx.Sender, fmt.Sprintf("🍼 No active babysitting service.\nAuto-babysit: **%s** (`!adventure babysit auto` to toggle)\n\nUse `!adventure babysit week` or `!adventure babysit month` to start.", autoLabel)) } remaining := "unknown" @@ -176,8 +207,9 @@ func (p *AdventurePlugin) handleBabysitStatus(ctx MessageContext) error { "Gold earned: €%d\n"+ "XP gained: %d\n"+ "Items claimed by babysitter: %d\n"+ - "Rivals declined: %d", - remaining, titleCase(char.BabysitSkillFocus), len(logs), totalGold, totalXP, itemsClaimed, rivalsRefused) + "Rivals declined: %d\n"+ + "Auto-babysit: %s", + remaining, titleCase(char.BabysitSkillFocus), len(logs), totalGold, totalXP, itemsClaimed, rivalsRefused, autoLabel) return p.SendDM(ctx.Sender, text) } @@ -302,7 +334,8 @@ func (p *AdventurePlugin) runBabysitAction(char *AdventureCharacter, equip map[E // Credit gold if result.TotalLootValue > 0 { - p.euro.Credit(char.UserID, float64(result.TotalLootValue), "babysit_haul") + net, _ := communityTax(char.UserID, float64(result.TotalLootValue), 0.05) + p.euro.Credit(char.UserID, net, "babysit_haul") } // No treasure drops during babysitting @@ -315,6 +348,34 @@ func (p *AdventurePlugin) runBabysitAction(char *AdventureCharacter, equip map[E return int(result.TotalLootValue), result.XPGained, items } +// runAutoBabysitDay runs a single day of babysit actions for auto-babysit. +// Called by the scheduler when a player with auto-babysit enabled misses a day. +func (p *AdventurePlugin) runAutoBabysitDay(char *AdventureCharacter) { + equip, err := loadAdvEquipment(char.UserID) + if err != nil { + slog.Error("auto-babysit: failed to load equipment", "user", char.UserID, "err", err) + return + } + + isHol, _ := isHolidayToday() + harvestMax := maxHarvestActions + if isHol { + harvestMax++ + } + + bonuses := &AdvBonusSummary{} + skill := babysitWeakestSkill(char) + activity := skillToActivity(skill) + + for char.HarvestActionsUsed < harvestMax { + p.runBabysitAction(char, equip, activity, bonuses) + char.HarvestActionsUsed++ + } + + char.ActionTakenToday = true + char.LastActionDate = time.Now().UTC().Format("2006-01-02") +} + // ── Expiry Check ──────────────────────────────────────────────────────────── func (p *AdventurePlugin) checkBabysitExpiry(chars []AdventureCharacter) { diff --git a/internal/plugin/adventure_character.go b/internal/plugin/adventure_character.go index 6d6a756..515ba42 100644 --- a/internal/plugin/adventure_character.go +++ b/internal/plugin/adventure_character.go @@ -93,6 +93,8 @@ type AdventureCharacter struct { PetSupplyShopUnlocked bool PetLevel10Date string PetMorningDefense bool + AutoBabysit bool + StreakDecayed bool } type AdvEquipment struct { @@ -417,6 +419,7 @@ func loadAdvCharacter(userID id.UserID) (*AdventureCharacter, error) { var houseFrozen, houseAutopay int var petChasedAway, petReactivated, petArrived, thomAnimalLine, petSupplyUnlocked, petMorningDef int + var autoBabysit, streakDecayed int err := d.QueryRow(` SELECT user_id, display_name, @@ -442,7 +445,7 @@ func loadAdvCharacter(userID id.UserID) (*AdventureCharacter, error) { 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 + pet_morning_defense, auto_babysit, streak_decayed FROM adventure_characters WHERE user_id = ?`, string(userID)).Scan( &c.UserID, &c.DisplayName, &c.CombatLevel, &c.MiningSkill, &c.ForagingSkill, &c.FishingSkill, @@ -467,7 +470,7 @@ func loadAdvCharacter(userID id.UserID) (*AdventureCharacter, error) { &petChasedAway, &petReactivated, &petArrived, &c.MistyEncounterCount, &c.MistyDonatedCount, &thomAnimalLine, &petSupplyUnlocked, &c.PetLevel10Date, - &petMorningDef, + &petMorningDef, &autoBabysit, &streakDecayed, ) if err != nil { return nil, err @@ -478,6 +481,8 @@ func loadAdvCharacter(userID id.UserID) (*AdventureCharacter, error) { c.RivalUnlockedNotified = rivalUnlocked == 1 c.BabysitActive = babysitAct == 1 c.PetMorningDefense = petMorningDef == 1 + c.AutoBabysit = autoBabysit == 1 + c.StreakDecayed = streakDecayed == 1 if deadUntil.Valid { c.DeadUntil = &deadUntil.Time } @@ -598,6 +603,14 @@ func saveAdvCharacter(char *AdventureCharacter) error { if char.PetMorningDefense { petMorningDef = 1 } + autoBabysit := 0 + if char.AutoBabysit { + autoBabysit = 1 + } + streakDecayed := 0 + if char.StreakDecayed { + streakDecayed = 1 + } _, err := d.Exec(` UPDATE adventure_characters SET @@ -623,7 +636,9 @@ func saveAdvCharacter(char *AdventureCharacter) error { 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 = ? + pet_morning_defense = ?, + auto_babysit = ?, + streak_decayed = ? WHERE user_id = ?`, char.DisplayName, char.CombatLevel, char.MiningSkill, char.ForagingSkill, char.FishingSkill, char.CombatXP, char.MiningXP, char.ForagingXP, char.FishingXP, @@ -647,6 +662,8 @@ func saveAdvCharacter(char *AdventureCharacter) error { char.MistyEncounterCount, char.MistyDonatedCount, thomAnimalLine, petSupplyUnlocked, char.PetLevel10Date, petMorningDef, + autoBabysit, + streakDecayed, string(char.UserID), ) return err @@ -777,7 +794,7 @@ func loadAllAdvCharacters() ([]AdventureCharacter, error) { 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 + pet_morning_defense, auto_babysit, streak_decayed FROM adventure_characters`) if err != nil { return nil, err @@ -792,6 +809,7 @@ func loadAllAdvCharacters() ([]AdventureCharacter, error) { var mistyLastSeen, arinaLastSeen, mistyBuffExp, mistyDebuffExp, arinaBuffExp sql.NullTime var houseFrozen, houseAutopay int var petChasedAway, petReactivated, petArrived, thomAnimalLine, petSupplyUnlocked, petMorningDef int + var autoBabysit, streakDecayed int if err := rows.Scan( &c.UserID, &c.DisplayName, &c.CombatLevel, &c.MiningSkill, &c.ForagingSkill, &c.FishingSkill, @@ -816,7 +834,7 @@ func loadAllAdvCharacters() ([]AdventureCharacter, error) { &petChasedAway, &petReactivated, &petArrived, &c.MistyEncounterCount, &c.MistyDonatedCount, &thomAnimalLine, &petSupplyUnlocked, &c.PetLevel10Date, - &petMorningDef, + &petMorningDef, &autoBabysit, &streakDecayed, ); err != nil { return nil, err } @@ -826,6 +844,8 @@ func loadAllAdvCharacters() ([]AdventureCharacter, error) { c.RivalUnlockedNotified = rivalUnlocked == 1 c.BabysitActive = babysitAct == 1 c.PetMorningDefense = petMorningDef == 1 + c.AutoBabysit = autoBabysit == 1 + c.StreakDecayed = streakDecayed == 1 c.HouseLoanFrozen = houseFrozen == 1 c.HouseAutopay = houseAutopay == 1 c.PetChasedAway = petChasedAway == 1 diff --git a/internal/plugin/adventure_events.go b/internal/plugin/adventure_events.go index 48e9c12..ca98fc1 100644 --- a/internal/plugin/adventure_events.go +++ b/internal/plugin/adventure_events.go @@ -176,8 +176,9 @@ func (p *AdventurePlugin) handleEventRespond(ctx MessageContext) error { gold += rand.Int64N(event.GoldMax - event.GoldMin + 1) } - // Credit gold - p.euro.Credit(ctx.Sender, float64(gold), "adventure_event_"+event.Key) + // Credit gold (5% community tax) + net, _ := communityTax(ctx.Sender, float64(gold), 0.05) + p.euro.Credit(ctx.Sender, net, "adventure_event_"+event.Key) // Apply XP if applicable xpSkill := event.XPSkill diff --git a/internal/plugin/adventure_hospital.go b/internal/plugin/adventure_hospital.go index d631ac5..cdb090b 100644 --- a/internal/plugin/adventure_hospital.go +++ b/internal/plugin/adventure_hospital.go @@ -3,6 +3,7 @@ package plugin import ( "fmt" "log/slog" + "math" "strings" "time" @@ -96,7 +97,7 @@ func (p *AdventurePlugin) handleHospitalCmd(ctx MessageContext) error { sb.WriteString("\n\n") // Payment prompt - sb.WriteString(fmt.Sprintf("**St. Guildmore's Memorial Hospital**\nAmount due: **€%d**\n\n", afterInsurance)) + sb.WriteString(fmt.Sprintf("**St. Guildmore's Memorial Hospital**\nAmount due: **%s**\n\n", fmtEuro(afterInsurance))) sb.WriteString("Pay now? (yes / no)\n\n") sb.WriteString("*Note: Declining payment will result in discharge to the natural respawn queue. " + "You'll be back tomorrow. The chair in the waiting room is available in the meantime.*") @@ -162,6 +163,12 @@ func (p *AdventurePlugin) resolveHospitalPay(ctx MessageContext, interaction *ad return p.SendDM(ctx.Sender, text) } + // Community health fund: 10% of revival cost. + if potCut := int(math.Round(float64(cost) * 0.1)); potCut > 0 { + communityPotAdd(potCut) + trackTaxPaid(ctx.Sender, potCut) + } + // Revive char.Alive = true char.DeadUntil = nil @@ -176,10 +183,10 @@ func (p *AdventurePlugin) resolveHospitalPay(ctx MessageContext, interaction *ad // Discharge DM p.SendDM(ctx.Sender, fmt.Sprintf( "✅ **Discharged — you're alive!**\n\n"+ - "€%d deducted. Nurse Joy wishes you the best. "+ + "%s deducted. Nurse Joy wishes you the best. "+ "She means it. She always means it.\n\n"+ "Go get 'em. Type `!adventure` when you're ready.", - cost)) + fmtEuro(cost))) // Room announcement gr := gamesRoom() @@ -220,9 +227,9 @@ func (p *AdventurePlugin) sendHospitalAd(userID id.UserID, char *AdventureCharac "🏥 **St. Guildmore's Memorial Hospital**\n\n"+ "Nurse Joy has been notified. A bed is being prepared.\n\n"+ "Type `!hospital` to check in for same-day revival.\n"+ - "Estimated bill: €%d (Guild insurance covers €%d → you pay €%d)\n\n"+ + "Estimated bill: %s (Guild insurance covers %s → you pay %s)\n\n"+ "Or rest up — natural respawn at %s UTC.", - beforeInsurance, beforeInsurance-afterInsurance, afterInsurance, respawnTime) + fmtEuro(beforeInsurance), fmtEuro(beforeInsurance-afterInsurance), fmtEuro(afterInsurance), respawnTime) time.AfterFunc(10*time.Second, func() { if err := p.SendDM(userID, text); err != nil { diff --git a/internal/plugin/adventure_rival.go b/internal/plugin/adventure_rival.go index 4a3e9a9..c9b924f 100644 --- a/internal/plugin/adventure_rival.go +++ b/internal/plugin/adventure_rival.go @@ -4,6 +4,7 @@ import ( "database/sql" "fmt" "log/slog" + "math" "math/rand/v2" "strings" "time" @@ -169,6 +170,29 @@ func communityPotAdd(amount int) { ) } +func communityTax(userID id.UserID, gross float64, rate float64) (net float64, tax int) { + t := math.Round(gross * rate) + tax = int(t) + net = gross - t + if tax > 0 { + communityPotAdd(tax) + trackTaxPaid(userID, tax) + } + return net, tax +} + +func trackTaxPaid(userID id.UserID, amount int) { + if amount <= 0 { + return + } + db.Exec("tax: track paid", + `INSERT INTO tax_ledger (user_id, total_paid, updated_at) + VALUES (?, ?, CURRENT_TIMESTAMP) + ON CONFLICT(user_id) DO UPDATE SET total_paid = total_paid + ?, updated_at = CURRENT_TIMESTAMP`, + string(userID), amount, amount, + ) +} + func communityPotBalance() int { d := db.Get() var balance int @@ -556,13 +580,14 @@ func (p *AdventurePlugin) finalizeRivalMatch(challenge *advRivalChallenge, playe } } - winnerShare := stake / 2 + winnerShare := int(math.Round(float64(stake) * 0.3)) potShare := stake - winnerShare if winnerShare > 0 { p.euro.Credit(winnerID, float64(winnerShare), "rival_duel_win") } if potShare > 0 { communityPotAdd(potShare) + trackTaxPaid(loserID, potShare) } // Update rival records (both directions). @@ -672,13 +697,14 @@ func (p *AdventurePlugin) expireRivalChallenges() { } } - winnerShare := stake / 2 + winnerShare := int(math.Round(float64(stake) * 0.3)) potShare := stake - winnerShare if winnerShare > 0 { p.euro.Credit(challenge.ChallengerID, float64(winnerShare), "rival_forfeit_win") } if potShare > 0 { communityPotAdd(potShare) + trackTaxPaid(challenge.ChallengedID, potShare) } upsertRivalRecord(challenge.ChallengedID, challenge.ChallengerID, false) diff --git a/internal/plugin/adventure_scheduler.go b/internal/plugin/adventure_scheduler.go index 0005676..77fa2b5 100644 --- a/internal/plugin/adventure_scheduler.go +++ b/internal/plugin/adventure_scheduler.go @@ -345,6 +345,36 @@ func (p *AdventurePlugin) midnightReset() error { continue } + // Auto-babysit: if enabled, alive, and affordable, run a single babysit day instead of losing streak + if char.AutoBabysit && char.Alive && !char.BabysitActive { + daily := babysitDailyCost(char.CombatLevel) + if p.euro.GetBalance(char.UserID) >= float64(daily) { + if p.euro.Debit(char.UserID, float64(daily), "auto_babysit") { + p.runAutoBabysitDay(&char) + if char.CurrentStreak > 0 { + char.CurrentStreak++ + if char.CurrentStreak > char.BestStreak { + char.BestStreak = char.CurrentStreak + } + } else { + char.CurrentStreak = 1 + } + _ = saveAdvCharacter(&char) + + if p.achievements != nil { + p.achievements.GrantAchievement(char.UserID, "adv_auto_babysit") + } + + if dmsSent > 0 { + time.Sleep(time.Duration(1000+rand.IntN(2000)) * time.Millisecond) + } + dmsSent++ + p.SendDM(char.UserID, fmt.Sprintf("🍼 **Auto-babysit activated** — €%d deducted. Your streak is safe at %d days.", daily, char.CurrentStreak)) + continue + } + } + } + // Jitter between DMs to avoid Matrix rate limits if dmsSent > 0 { time.Sleep(time.Duration(1000+rand.IntN(2000)) * time.Millisecond) @@ -353,15 +383,19 @@ func (p *AdventurePlugin) midnightReset() error { // Idle shame DM text := renderAdvIdleShameDM(&char) + if char.CurrentStreak > 0 { + oldStreak := char.CurrentStreak + char.CurrentStreak /= 2 + char.StreakDecayed = true + text += fmt.Sprintf("\n\n🔥 Streak: %d → %d days", oldStreak, char.CurrentStreak) + if char.CurrentStreak > 0 { + text += " — not all is lost." + } + _ = saveAdvCharacter(&char) + } if err := p.SendDM(char.UserID, text); err != nil { slog.Error("adventure: failed to send idle shame DM", "user", char.UserID, "err", err) } - - // Reset streak - if char.CurrentStreak > 0 { - char.CurrentStreak = 0 - _ = saveAdvCharacter(&char) - } } else { // Update streak — LastActionDate was set at action time yesterday := time.Now().UTC().Add(-24 * time.Hour).Format("2006-01-02") diff --git a/internal/plugin/adventure_twinbee.go b/internal/plugin/adventure_twinbee.go index 06862ac..ef92009 100644 --- a/internal/plugin/adventure_twinbee.go +++ b/internal/plugin/adventure_twinbee.go @@ -289,7 +289,8 @@ func (p *AdventurePlugin) distributeTwinBeeRewards(result *TwinBeeResult) TwinBe if share < 1 { share = 1 } - p.euro.Credit(ep.uid, float64(share), "twinbee_daily_share") + net, _ := communityTax(ep.uid, float64(share), 0.05) + p.euro.Credit(ep.uid, net, "twinbee_daily_share") totalDistributed += share if rollTwinBeeGift(ep.uid) { summary.GiftCount++ diff --git a/internal/plugin/blackjack.go b/internal/plugin/blackjack.go index 04ed291..7c6596e 100644 --- a/internal/plugin/blackjack.go +++ b/internal/plugin/blackjack.go @@ -179,7 +179,8 @@ func NewBlackjackPlugin(client *mautrix.Client, euro *EuroPlugin) *BlackjackPlug } } -func (p *BlackjackPlugin) Name() string { return "blackjack" } +func (p *BlackjackPlugin) Name() string { return "blackjack" } +func (p *BlackjackPlugin) Version() string { return "1.1.0" } func (p *BlackjackPlugin) Commands() []CommandDef { return []CommandDef{ @@ -727,8 +728,10 @@ func (p *BlackjackPlugin) collectResolveRound(roomID id.RoomID, table *bjTable) case playerBJ: result = "Blackjack!" - payout = pl.Bet + math.Floor(pl.Bet*1.5) // Return bet + 1.5x (rounded down) - p.euro.Credit(pl.UserID, payout, "blackjack_win") + payout = pl.Bet + math.Floor(pl.Bet*1.5) + net, _ := communityTax(pl.UserID, payout, 0.05) + p.euro.Credit(pl.UserID, net, "blackjack_win") + payout = net case dealerBJ: result = "Dealer Blackjack" @@ -737,12 +740,16 @@ func (p *BlackjackPlugin) collectResolveRound(roomID id.RoomID, table *bjTable) case dealerBust: result = "Win (dealer bust)!" payout = pl.Bet * 2 - p.euro.Credit(pl.UserID, payout, "blackjack_win") + net, _ := communityTax(pl.UserID, payout, 0.05) + p.euro.Credit(pl.UserID, net, "blackjack_win") + payout = net case playerValue > dealerValue: result = fmt.Sprintf("Win! %d vs %d", playerValue, dealerValue) payout = pl.Bet * 2 - p.euro.Credit(pl.UserID, payout, "blackjack_win") + net, _ := communityTax(pl.UserID, payout, 0.05) + p.euro.Credit(pl.UserID, net, "blackjack_win") + payout = net case playerValue == dealerValue: result = "Push" diff --git a/internal/plugin/botinfo.go b/internal/plugin/botinfo.go index 2368ee2..deffe34 100644 --- a/internal/plugin/botinfo.go +++ b/internal/plugin/botinfo.go @@ -12,6 +12,7 @@ import ( "time" "gogobee/internal/db" + "gogobee/internal/version" "maunium.net/go/mautrix" ) @@ -43,6 +44,7 @@ func (p *BotInfoPlugin) Name() string { return "botinfo" } func (p *BotInfoPlugin) Commands() []CommandDef { return []CommandDef{ {Name: "botinfo", Description: "Show bot diagnostics (admin only)", Usage: "!botinfo", Category: "Admin", AdminOnly: true}, + {Name: "version", Description: "Show bot version", Usage: "!version", Category: "Info"}, } } @@ -51,8 +53,10 @@ func (p *BotInfoPlugin) Init() error { return nil } func (p *BotInfoPlugin) OnReaction(_ ReactionContext) error { return nil } func (p *BotInfoPlugin) OnMessage(ctx MessageContext) error { + if p.IsCommand(ctx.Body, "version") { + return p.handleVersion(ctx) + } if !p.IsCommand(ctx.Body, "botinfo") { - // Passively count messages IncrementMessageCount() return nil } @@ -64,9 +68,24 @@ func (p *BotInfoPlugin) OnMessage(ctx MessageContext) error { return p.handleBotInfo(ctx) } +func (p *BotInfoPlugin) handleVersion(ctx MessageContext) error { + uptime := time.Since(botStartTime) + days := int(uptime.Hours() / 24) + hours := int(uptime.Hours()) % 24 + + msg := fmt.Sprintf("**%s**\nUptime: %dd %dh", version.Full(), days, hours) + + crashes := db.CrashCount(version.Short()) + if crashes > 0 { + msg += fmt.Sprintf(" · %d crash(es) this version", crashes) + } + + return p.SendReply(ctx.RoomID, ctx.EventID, msg) +} + func (p *BotInfoPlugin) handleBotInfo(ctx MessageContext) error { var sb strings.Builder - sb.WriteString("Bot Diagnostics\n\n") + sb.WriteString(fmt.Sprintf("**Bot Diagnostics** — %s\n\n", version.Short())) // Uptime uptime := time.Since(botStartTime) @@ -110,6 +129,33 @@ func (p *BotInfoPlugin) handleBotInfo(ctx MessageContext) error { sb.WriteString("LLM status: not configured\n") } + // Crash stats + crashesThisVersion := db.CrashCount(version.Short()) + crashesTotal := db.CrashCountAll() + sb.WriteString(fmt.Sprintf("\nCrashes: %d this version / %d total\n", crashesThisVersion, crashesTotal)) + + // Recent crashes (last 5) + d2 := db.Get() + rows, err := d2.Query(`SELECT plugin, message, created_at FROM crash_log ORDER BY id DESC LIMIT 5`) + if err == nil { + defer rows.Close() + var hasCrashes bool + for rows.Next() { + var plug, msg, ts string + if rows.Scan(&plug, &msg, &ts) != nil { + continue + } + if !hasCrashes { + sb.WriteString("Recent crashes:\n") + hasCrashes = true + } + if len(msg) > 80 { + msg = msg[:80] + "..." + } + sb.WriteString(fmt.Sprintf(" %s [%s] %s\n", ts, plug, msg)) + } + } + return p.SendReply(ctx.RoomID, ctx.EventID, sb.String()) } diff --git a/internal/plugin/euro.go b/internal/plugin/euro.go index ccc3830..04b02a0 100644 --- a/internal/plugin/euro.go +++ b/internal/plugin/euro.go @@ -14,6 +14,26 @@ import ( "maunium.net/go/mautrix/id" ) +// fmtEuro formats a currency value with thousand separators, e.g. "€12,500". +func fmtEuro[T int | int64 | float64](v T) string { + n := int64(v) + if n < 0 { + return "-" + fmtEuro(-n) + } + s := fmt.Sprintf("%d", n) + if len(s) <= 3 { + return "€" + s + } + var out []byte + for i, c := range s { + if i > 0 && (len(s)-i)%3 == 0 { + out = append(out, ',') + } + out = append(out, byte(c)) + } + return "€" + string(out) +} + // gamesRoom returns the configured GAMES_ROOM, or empty if unset. func gamesRoom() id.RoomID { return id.RoomID(os.Getenv("GAMES_ROOM")) @@ -63,7 +83,8 @@ func NewEuroPlugin(client *mautrix.Client) *EuroPlugin { } } -func (p *EuroPlugin) Name() string { return "euro" } +func (p *EuroPlugin) Name() string { return "euro" } +func (p *EuroPlugin) Version() string { return "1.1.0" } func (p *EuroPlugin) Commands() []CommandDef { return []CommandDef{ diff --git a/internal/plugin/hangman.go b/internal/plugin/hangman.go index 5086f89..40d53a0 100644 --- a/internal/plugin/hangman.go +++ b/internal/plugin/hangman.go @@ -296,7 +296,8 @@ func NewHangmanPlugin(client *mautrix.Client, euro *EuroPlugin, dict *dreamclien } } -func (p *HangmanPlugin) Name() string { return "hangman" } +func (p *HangmanPlugin) Name() string { return "hangman" } +func (p *HangmanPlugin) Version() string { return "1.1.0" } func (p *HangmanPlugin) Commands() []CommandDef { return []CommandDef{ @@ -820,10 +821,11 @@ func (p *HangmanPlugin) endGame(roomID id.RoomID, game *hangmanGame) error { payout = share * p.bonusMul label = " (early solve bonus!)" } - p.euro.Credit(uid, payout, "hangman_win") - p.recordHangmanScore(uid, payout) + net, _ := communityTax(uid, payout, 0.05) + p.euro.Credit(uid, net, "hangman_win") + p.recordHangmanScore(uid, net) name := p.DisplayName(uid) - sb.WriteString(fmt.Sprintf(" **%s**: +€%d%s\n", name, int(payout), label)) + sb.WriteString(fmt.Sprintf(" **%s**: +€%d%s\n", name, int(net), label)) } } diff --git a/internal/plugin/lottery.go b/internal/plugin/lottery.go index 6852294..f694327 100644 --- a/internal/plugin/lottery.go +++ b/internal/plugin/lottery.go @@ -25,7 +25,8 @@ func NewLotteryPlugin(client *mautrix.Client, euro *EuroPlugin) *LotteryPlugin { } } -func (p *LotteryPlugin) Name() string { return "lottery" } +func (p *LotteryPlugin) Name() string { return "lottery" } +func (p *LotteryPlugin) Version() string { return "1.1.0" } func (p *LotteryPlugin) Commands() []CommandDef { return []CommandDef{ @@ -198,11 +199,9 @@ func (p *LotteryPlugin) handleLotteryOdds(ctx MessageContext) error { | Match | Prize | Odds (approx.) | |-------|-------|----------------| | 5 of 5 | Jackpot (split among winners) | 1 in 142,506 | -| 4 of 5 | €1,000 (fixed) | 1 in 3,062 | -| 3 of 5 | €100 (fixed) | 1 in 141 | -| 2 of 5 | €10 (fixed) | 1 in 16 | -| 1 of 5 | €2 (fixed) | 1 in 4 | -| 0 of 5 | Nothing | — | +| 4 of 5 | €5,000 (fixed) | 1 in 3,062 | +| 3 of 5 | €500 (fixed) | 1 in 141 | +| 2 of 5 | €25 (fixed) | 1 in 16 | Tickets: €1 each. Max 100 per week. 5 numbers from 1–30. Minimum €500 pot required for jackpot payout.` @@ -228,8 +227,8 @@ func (p *LotteryPlugin) handleLotteryHistory(ctx MessageContext) error { if h.RolledOver > 0 { sb.WriteString(fmt.Sprintf(" | Rolled over: €%d", h.RolledOver)) } - sb.WriteString(fmt.Sprintf("\n 4-match: %d | 3-match: %d | 2-match: %d | 1-match: %d\n\n", - h.Match4Winners, h.Match3Winners, h.Match2Winners, h.Match1Winners)) + sb.WriteString(fmt.Sprintf("\n 4-match: %d | 3-match: %d | 2-match: %d\n\n", + h.Match4Winners, h.Match3Winners, h.Match2Winners)) } return p.SendReply(ctx.RoomID, ctx.EventID, sb.String()) diff --git a/internal/plugin/lottery_draw.go b/internal/plugin/lottery_draw.go index 9a25b77..7001bf7 100644 --- a/internal/plugin/lottery_draw.go +++ b/internal/plugin/lottery_draw.go @@ -14,10 +14,9 @@ import ( // ── Prize Tiers (fixed payouts) ───────────────────────────────────────────── var lotteryFixedPrizes = map[int]int{ - 4: 1000, - 3: 100, - 2: 10, - 1: 2, + 4: 5000, + 3: 500, + 2: 25, } // ── Draw Ticker ───────────────────────────────────────────────────────────── @@ -66,9 +65,9 @@ func (p *LotteryPlugin) reminderTicker() { if gr != "" { pot := communityPotBalance() p.SendMessage(gr, fmt.Sprintf( - "🎟️ Lottery draw tomorrow. 23:59 UTC. Current pot: €%d. "+ + "🎟️ Lottery draw tomorrow. 23:59 UTC. Current pot: %s. "+ "Tickets €1 each. Max 100 per player. `!lottery buy [N]` to enter.", - pot)) + fmtEuro(pot))) } db.MarkJobCompleted(jobName, weekKey) @@ -101,7 +100,7 @@ func (p *LotteryPlugin) executeDraw(weekStart string) { tickets[i].MatchCount = &mc prize := 0 - if mc >= 1 && mc <= 4 { + if mc >= 2 && mc <= 4 { prize = lotteryFixedPrizes[mc] } tickets[i].Prize = &prize @@ -192,7 +191,7 @@ func (p *LotteryPlugin) executeDraw(weekStart string) { Match4Winners: len(matchBuckets[4]), Match3Winners: len(matchBuckets[3]), Match2Winners: len(matchBuckets[2]), - Match1Winners: len(matchBuckets[1]), + Match1Winners: 0, PotTotal: initialPot, RolledOver: rolledOver, } @@ -205,6 +204,9 @@ func (p *LotteryPlugin) executeDraw(weekStart string) { p.SendMessage(gr, announcement) } + // DM each winner with their tickets and results. + p.dmWinners(winning, tickets) + // Cleanup old tickets. lotteryCleanupOldTickets() @@ -215,6 +217,58 @@ func (p *LotteryPlugin) executeDraw(weekStart string) { "jackpot_winners", len(jackpotWinners)) } +func (p *LotteryPlugin) dmWinners(winning []int, tickets []lotteryTicket) { + // Group winning tickets by user. + byUser := make(map[id.UserID][]lotteryTicket) + for _, t := range tickets { + if t.MatchCount == nil || *t.MatchCount < 2 { + continue + } + byUser[t.UserID] = append(byUser[t.UserID], t) + } + + for uid, userTickets := range byUser { + var sb strings.Builder + sb.WriteString(fmt.Sprintf("🎟️ **Lottery Results** — Winning numbers: **%s**\n\n", formatLotteryNumbers(winning))) + + totalWon := 0 + for _, t := range userTickets { + matches := *t.MatchCount + prize := 0 + if t.Prize != nil { + prize = *t.Prize + } + totalWon += prize + + // Show ticket numbers with matches highlighted + sb.WriteString(fmt.Sprintf("🎫 %s — **%d match** → %s\n", + formatTicketWithMatches(t.Numbers, winning), matches, fmtEuro(prize))) + } + + sb.WriteString(fmt.Sprintf("\n💰 **Total: %s**", fmtEuro(totalWon))) + + if err := p.SendDM(uid, sb.String()); err != nil { + slog.Error("lottery: failed to DM winner", "user", uid, "err", err) + } + } +} + +func formatTicketWithMatches(ticket, winning []int) string { + winSet := make(map[int]bool, len(winning)) + for _, n := range winning { + winSet[n] = true + } + parts := make([]string, len(ticket)) + for i, n := range ticket { + if winSet[n] { + parts[i] = fmt.Sprintf("**%d**", n) + } else { + parts[i] = fmt.Sprintf("%d", n) + } + } + return strings.Join(parts, " · ") +} + // ── Announcement Builder ──────────────────────────────────────────────────── func (p *LotteryPlugin) buildDrawAnnouncement(winning []int, h *lotteryHistoryRow, jackpotWinners []lotteryTicket) string { @@ -227,10 +281,10 @@ func (p *LotteryPlugin) buildDrawAnnouncement(winning []int, h *lotteryHistoryRo if len(jackpotWinners) > 0 && h.JackpotAmount > 0 { names := p.resolveWinnerNames(jackpotWinners) if len(jackpotWinners) == 1 { - sb.WriteString(fmt.Sprintf("Jackpot (5 match): **%s** — €%d 🎉\n", names[0], h.JackpotAmount)) + sb.WriteString(fmt.Sprintf("Jackpot (5 match): **%s** — %s 🎉\n", names[0], fmtEuro(h.JackpotAmount))) } else { - sb.WriteString(fmt.Sprintf("Jackpot (5 match): Split between %s. €%d each. 🎉\n", - joinNames(names), h.JackpotAmount)) + sb.WriteString(fmt.Sprintf("Jackpot (5 match): Split between %s. %s each. 🎉\n", + joinNames(names), fmtEuro(h.JackpotAmount))) } } else if len(jackpotWinners) > 0 && h.JackpotAmount == 0 { sb.WriteString("Jackpot withheld — pot insufficient. Rolls to next week. Fixed tiers paid as normal.\n") @@ -239,13 +293,12 @@ func (p *LotteryPlugin) buildDrawAnnouncement(winning []int, h *lotteryHistoryRo } // Fixed tiers. - sb.WriteString(fmt.Sprintf("4 match: %d winner(s) — €1,000 each\n", h.Match4Winners)) - sb.WriteString(fmt.Sprintf("3 match: %d winner(s) — €100 each\n", h.Match3Winners)) - sb.WriteString(fmt.Sprintf("2 match: %d winner(s) — €10 each\n", h.Match2Winners)) - sb.WriteString(fmt.Sprintf("1 match: %d winner(s) — €2 each\n", h.Match1Winners)) + sb.WriteString(fmt.Sprintf("4 match: %d winner(s) — €5,000 each\n", h.Match4Winners)) + sb.WriteString(fmt.Sprintf("3 match: %d winner(s) — €500 each\n", h.Match3Winners)) + sb.WriteString(fmt.Sprintf("2 match: %d winner(s) — €25 each\n", h.Match2Winners)) distributed := h.PotTotal - h.RolledOver - sb.WriteString(fmt.Sprintf("\nPot distributed: €%d. Next draw: Friday. Tickets on sale now.", distributed)) + sb.WriteString(fmt.Sprintf("\nPot distributed: %s. Next draw: Friday. Tickets on sale now.", fmtEuro(distributed))) return sb.String() } diff --git a/internal/plugin/plugin.go b/internal/plugin/plugin.go index 8e1d6bc..eb097ce 100644 --- a/internal/plugin/plugin.go +++ b/internal/plugin/plugin.go @@ -13,6 +13,7 @@ import ( "gogobee/internal/db" "gogobee/internal/util" + "gogobee/internal/version" "maunium.net/go/mautrix" "maunium.net/go/mautrix/event" @@ -57,6 +58,19 @@ type Plugin interface { Init() error } +// Versioned is an optional interface plugins can implement to declare their version. +type Versioned interface { + Version() string +} + +// PluginVersion returns the version for a plugin, or "1.0.0" if it doesn't implement Versioned. +func PluginVersion(p Plugin) string { + if v, ok := p.(Versioned); ok { + return v.Version() + } + return "1.0.0" +} + // dmCache maps user IDs to their DM room IDs to avoid creating duplicate rooms. var ( dmCache = make(map[id.UserID]id.RoomID) @@ -764,6 +778,7 @@ func safeGo(label string, fn func()) { defer func() { if r := recover(); r != nil { slog.Error("goroutine panic recovered", "label", label, "panic", r) + db.RecordCrash(version.Short(), label, fmt.Sprintf("%v", r)) } }() fn() diff --git a/internal/plugin/stats.go b/internal/plugin/stats.go index 5a6f516..c497176 100644 --- a/internal/plugin/stats.go +++ b/internal/plugin/stats.go @@ -30,7 +30,8 @@ func NewStatsPlugin(client *mautrix.Client) *StatsPlugin { } } -func (p *StatsPlugin) Name() string { return "stats" } +func (p *StatsPlugin) Name() string { return "stats" } +func (p *StatsPlugin) Version() string { return "1.1.0" } func (p *StatsPlugin) Commands() []CommandDef { return []CommandDef{ @@ -199,6 +200,9 @@ func (p *StatsPlugin) handleStats(ctx MessageContext) error { avgWords = totalWords / totalMsg } + var taxPaid int + _ = d.QueryRow(`SELECT total_paid FROM tax_ledger WHERE user_id = ?`, string(target)).Scan(&taxPaid) + msg := fmt.Sprintf( "📊 Stats for %s\n"+ "Messages: %s | Words: %s | Chars: %s\n"+ @@ -213,6 +217,9 @@ func (p *StatsPlugin) handleStats(ctx MessageContext) error { formatNumber(nightMsg), formatNumber(morningMsg), avgWords, ) + if taxPaid > 0 { + msg += fmt.Sprintf("\n🏛️ Community tax paid: %s", fmtEuro(taxPaid)) + } return p.SendReply(ctx.RoomID, ctx.EventID, msg) } @@ -322,8 +329,15 @@ func (p *StatsPlugin) handleSuperStats(ctx MessageContext) error { var achievementCount int _ = d.QueryRow(`SELECT COUNT(*) FROM achievements WHERE user_id = ?`, uid).Scan(&achievementCount) - sb.WriteString(fmt.Sprintf("⭐ **Level %d** · %s XP · 💰 €%.0f · 🏅 %d achievements\n\n", - level, formatNumber(xp), balance, achievementCount)) + var taxPaid int + _ = d.QueryRow(`SELECT total_paid FROM tax_ledger WHERE user_id = ?`, uid).Scan(&taxPaid) + + taxLine := "" + if taxPaid > 0 { + taxLine = fmt.Sprintf(" · 🏛️ %s tax paid", fmtEuro(taxPaid)) + } + sb.WriteString(fmt.Sprintf("⭐ **Level %d** · %s XP · 💰 €%.0f · 🏅 %d achievements%s\n\n", + level, formatNumber(xp), balance, achievementCount, taxLine)) // ── Chat Stats ── var totalMsg, totalWords, totalLinks, totalEmojis, totalQuestions int diff --git a/internal/plugin/uno.go b/internal/plugin/uno.go index 6bd3053..305cc17 100644 --- a/internal/plugin/uno.go +++ b/internal/plugin/uno.go @@ -411,7 +411,8 @@ func NewUnoPlugin(client *mautrix.Client, euro *EuroPlugin) *UnoPlugin { } } -func (p *UnoPlugin) Name() string { return "uno" } +func (p *UnoPlugin) Name() string { return "uno" } +func (p *UnoPlugin) Version() string { return "1.2.0" } func (p *UnoPlugin) Commands() []CommandDef { return []CommandDef{ @@ -1689,7 +1690,8 @@ func (p *UnoPlugin) suddenDeathWinner(game *unoGame) { if playerWins { payout := p.claimFromPot(game.wager) totalPayout := game.wager + payout - p.euro.Credit(game.playerID, totalPayout, "uno_win") + net, _ := communityTax(game.playerID, totalPayout, 0.05) + p.euro.Credit(game.playerID, net, "uno_win") newPot := p.getPot() p.SendMessage(game.dmRoomID, "🎉 **You win on points!**") @@ -1885,7 +1887,8 @@ func (p *UnoPlugin) playerWins(game *unoGame) error { potBefore := p.getPot() payout := p.claimFromPot(game.wager) totalPayout := game.wager + payout - p.euro.Credit(game.playerID, totalPayout, "uno_win") + net, _ := communityTax(game.playerID, totalPayout, 0.05) + p.euro.Credit(game.playerID, net, "uno_win") newPot := p.getPot() playerName := p.DisplayName(game.playerID) diff --git a/internal/plugin/uno_multi.go b/internal/plugin/uno_multi.go index b4a18df..e309299 100644 --- a/internal/plugin/uno_multi.go +++ b/internal/plugin/uno_multi.go @@ -2564,7 +2564,8 @@ func (p *UnoPlugin) multiPlayerWins2(game *unoMultiGame, winner *unoMultiPlayer) } } totalPot := game.ante * float64(humanCount+game.botAntes) - p.euro.Credit(winner.userID, totalPot, "uno_multi_win") + net, _ := communityTax(winner.userID, totalPot, 0.05) + p.euro.Credit(winner.userID, net, "uno_multi_win") p.recordMultiGame(game, winner.userID, "sudden_death_win") p.cleanupMultiGame(game) @@ -2614,9 +2615,10 @@ func (p *UnoPlugin) multiPlayerWins(game *unoMultiGame, winner *unoMultiPlayer) } } totalPot := game.ante * float64(humanCount+game.botAntes) + net, _ := communityTax(winner.userID, totalPot, 0.05) name := p.DisplayName(winner.userID) - p.euro.Credit(winner.userID, totalPot, "uno_multi_win") + p.euro.Credit(winner.userID, net, "uno_multi_win") p.SendMessage(game.roomID, fmt.Sprintf( "🎉 **%s wins Multiplayer UNO!**\nPot: €%d | Turns: %d\n\n%s", diff --git a/internal/plugin/wordle.go b/internal/plugin/wordle.go index 5c94310..cc938a1 100644 --- a/internal/plugin/wordle.go +++ b/internal/plugin/wordle.go @@ -49,7 +49,8 @@ func NewWordlePlugin(client *mautrix.Client, euro *EuroPlugin, dict *dreamclient } } -func (p *WordlePlugin) Name() string { return "wordle" } +func (p *WordlePlugin) Name() string { return "wordle" } +func (p *WordlePlugin) Version() string { return "2.0.0" } func (p *WordlePlugin) Commands() []CommandDef { return []CommandDef{ @@ -695,8 +696,9 @@ func (p *WordlePlugin) awardPrize(puzzle *WordlePuzzle) []WordlePayout { if c.solver { amount += solverBonus } - p.euro.Credit(uid, float64(amount), "wordle_win") - payouts = append(payouts, WordlePayout{UserID: uid, Name: c.name, Amount: amount, Solver: c.solver}) + net, _ := communityTax(uid, float64(amount), 0.05) + p.euro.Credit(uid, net, "wordle_win") + payouts = append(payouts, WordlePayout{UserID: uid, Name: c.name, Amount: int(net), Solver: c.solver}) } return payouts } diff --git a/internal/version/version.go b/internal/version/version.go new file mode 100644 index 0000000..01ab2e5 --- /dev/null +++ b/internal/version/version.go @@ -0,0 +1,28 @@ +package version + +import ( + "fmt" + "runtime" +) + +const Version = "1.0.0" + +var ( + Commit = "dev" + BuildDate = "unknown" +) + +func Full() string { + return fmt.Sprintf("GogoBee v%s (%s, %s, %s)", Version, Commit, BuildDate, runtime.Version()) +} + +func Short() string { + return fmt.Sprintf("v%s-%s", Version, shortCommit()) +} + +func shortCommit() string { + if len(Commit) > 7 { + return Commit[:7] + } + return Commit +} diff --git a/main.go b/main.go index 8e5f70f..48dc9a4 100644 --- a/main.go +++ b/main.go @@ -15,6 +15,7 @@ import ( "gogobee/internal/dreamclient" "gogobee/internal/plugin" "gogobee/internal/util" + "gogobee/internal/version" "github.com/joho/godotenv" "github.com/robfig/cron/v3" @@ -35,7 +36,7 @@ func main() { logLevel = "info" } util.InitLogger(logLevel) - slog.Info("logger initialized", "level", logLevel, + slog.Info(version.Full(), "level", logLevel, "ollama_host", os.Getenv("OLLAMA_HOST"), "ollama_model", os.Getenv("OLLAMA_MODEL")) @@ -52,6 +53,7 @@ func main() { if err := db.SeedSchedulerDefaults(db.Get()); err != nil { slog.Warn("seed scheduler defaults failed", "err", err) } + db.RecordStartup(version.Version, version.Commit) // Create Matrix client cfg := bot.Config{ @@ -207,6 +209,7 @@ func main() { defer func() { if rec := recover(); rec != nil { slog.Error("member event handler panic", "panic", rec, "room", evt.RoomID) + db.RecordCrash(version.Short(), "member_handler", fmt.Sprintf("%v", rec)) } }() @@ -239,6 +242,7 @@ func main() { if rec := recover(); rec != nil { slog.Error("message event handler panic", "panic", rec, "sender", evt.Sender, "room", evt.RoomID) + db.RecordCrash(version.Short(), "message_handler", fmt.Sprintf("%v", rec)) } }() @@ -283,6 +287,7 @@ func main() { if rec := recover(); rec != nil { slog.Error("reaction event handler panic", "panic", rec, "sender", evt.Sender, "room", evt.RoomID) + db.RecordCrash(version.Short(), "reaction_handler", fmt.Sprintf("%v", rec)) } }()