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 <noreply@anthropic.com>
This commit is contained in:
prosolis
2026-04-18 01:07:25 -07:00
parent 42e6e23900
commit b15c13cde7
22 changed files with 600 additions and 75 deletions

View File

@@ -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_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_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 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 { for _, stmt := range columnMigrations {
if _, err := d.Exec(stmt); err != nil { 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. // CacheGet returns cached data for the given key if it exists and is within ttlSeconds.
// Returns empty string if not cached or expired. // Returns empty string if not cached or expired.
func CacheGet(key string, ttlSeconds int) string { func CacheGet(key string, ttlSeconds int) string {
@@ -1282,6 +1317,27 @@ CREATE TABLE IF NOT EXISTS lottery_history (
created_at DATETIME DEFAULT CURRENT_TIMESTAMP 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. // SeedSchedulerDefaults inserts default scheduler jobs if they don't exist.

View File

@@ -44,7 +44,8 @@ func NewAchievementsPlugin(client *mautrix.Client, registry AchievementRegistry)
return p 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 { func (p *AchievementsPlugin) Commands() []CommandDef {
return []CommandDef{ return []CommandDef{
@@ -1015,6 +1016,127 @@ func (p *AchievementsPlugin) buildAchievements() []achievementDef {
return count > 0 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(&current, &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
},
},
} }
} }

View File

@@ -72,15 +72,41 @@ func (p *AdventurePlugin) handleBabysitCmd(ctx MessageContext, args string) erro
return p.handleBabysitPurchase(ctx, 7) return p.handleBabysitPurchase(ctx, 7)
case lower == "month": case lower == "month":
return p.handleBabysitPurchase(ctx, 30) return p.handleBabysitPurchase(ctx, 30)
case lower == "auto":
return p.handleBabysitAutoToggle(ctx)
default: default:
return p.SendDM(ctx.Sender, "🍼 **Adventurer Babysitting Service**\n\n"+ return p.SendDM(ctx.Sender, "🍼 **Adventurer Babysitting Service**\n\n"+
"`!adventure babysit week` — 7 days of service\n"+ "`!adventure babysit week` — 7 days of service\n"+
"`!adventure babysit month` — 30 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 status` — check service status\n"+
"`!adventure babysit cancel` — cancel early (no refund)") "`!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 { func (p *AdventurePlugin) handleBabysitPurchase(ctx MessageContext, days int) error {
userMu := p.advUserLock(ctx.Sender) userMu := p.advUserLock(ctx.Sender)
userMu.Lock() userMu.Lock()
@@ -103,7 +129,7 @@ func (p *AdventurePlugin) handleBabysitPurchase(ctx MessageContext, days int) er
totalCost := daily * days totalCost := daily * days
balance := p.euro.GetBalance(char.UserID) balance := p.euro.GetBalance(char.UserID)
if balance < float64(totalCost) { 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 // Debit gold
@@ -148,8 +174,13 @@ func (p *AdventurePlugin) handleBabysitStatus(ctx MessageContext) error {
return p.SendDM(ctx.Sender, "No adventurer found.") return p.SendDM(ctx.Sender, "No adventurer found.")
} }
autoLabel := "OFF"
if char.AutoBabysit {
autoLabel = "ON"
}
if !char.BabysitActive { 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" remaining := "unknown"
@@ -176,8 +207,9 @@ func (p *AdventurePlugin) handleBabysitStatus(ctx MessageContext) error {
"Gold earned: €%d\n"+ "Gold earned: €%d\n"+
"XP gained: %d\n"+ "XP gained: %d\n"+
"Items claimed by babysitter: %d\n"+ "Items claimed by babysitter: %d\n"+
"Rivals declined: %d", "Rivals declined: %d\n"+
remaining, titleCase(char.BabysitSkillFocus), len(logs), totalGold, totalXP, itemsClaimed, rivalsRefused) "Auto-babysit: %s",
remaining, titleCase(char.BabysitSkillFocus), len(logs), totalGold, totalXP, itemsClaimed, rivalsRefused, autoLabel)
return p.SendDM(ctx.Sender, text) return p.SendDM(ctx.Sender, text)
} }
@@ -302,7 +334,8 @@ func (p *AdventurePlugin) runBabysitAction(char *AdventureCharacter, equip map[E
// Credit gold // Credit gold
if result.TotalLootValue > 0 { 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 // 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 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 ──────────────────────────────────────────────────────────── // ── Expiry Check ────────────────────────────────────────────────────────────
func (p *AdventurePlugin) checkBabysitExpiry(chars []AdventureCharacter) { func (p *AdventurePlugin) checkBabysitExpiry(chars []AdventureCharacter) {

View File

@@ -93,6 +93,8 @@ type AdventureCharacter struct {
PetSupplyShopUnlocked bool PetSupplyShopUnlocked bool
PetLevel10Date string PetLevel10Date string
PetMorningDefense bool PetMorningDefense bool
AutoBabysit bool
StreakDecayed bool
} }
type AdvEquipment struct { type AdvEquipment struct {
@@ -417,6 +419,7 @@ func loadAdvCharacter(userID id.UserID) (*AdventureCharacter, error) {
var houseFrozen, houseAutopay int var houseFrozen, houseAutopay int
var petChasedAway, petReactivated, petArrived, thomAnimalLine, petSupplyUnlocked, petMorningDef int var petChasedAway, petReactivated, petArrived, thomAnimalLine, petSupplyUnlocked, petMorningDef int
var autoBabysit, streakDecayed int
err := d.QueryRow(` err := d.QueryRow(`
SELECT user_id, display_name, SELECT user_id, display_name,
@@ -442,7 +445,7 @@ func loadAdvCharacter(userID id.UserID) (*AdventureCharacter, error) {
pet_chased_away, pet_reactivated, pet_arrived, pet_chased_away, pet_reactivated, pet_arrived,
misty_encounter_count, misty_donated_count, misty_encounter_count, misty_donated_count,
thom_animal_line_fired, pet_supply_shop_unlocked, pet_level10_date, 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( 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,
@@ -467,7 +470,7 @@ func loadAdvCharacter(userID id.UserID) (*AdventureCharacter, error) {
&petChasedAway, &petReactivated, &petArrived, &petChasedAway, &petReactivated, &petArrived,
&c.MistyEncounterCount, &c.MistyDonatedCount, &c.MistyEncounterCount, &c.MistyDonatedCount,
&thomAnimalLine, &petSupplyUnlocked, &c.PetLevel10Date, &thomAnimalLine, &petSupplyUnlocked, &c.PetLevel10Date,
&petMorningDef, &petMorningDef, &autoBabysit, &streakDecayed,
) )
if err != nil { if err != nil {
return nil, err return nil, err
@@ -478,6 +481,8 @@ func loadAdvCharacter(userID id.UserID) (*AdventureCharacter, error) {
c.RivalUnlockedNotified = rivalUnlocked == 1 c.RivalUnlockedNotified = rivalUnlocked == 1
c.BabysitActive = babysitAct == 1 c.BabysitActive = babysitAct == 1
c.PetMorningDefense = petMorningDef == 1 c.PetMorningDefense = petMorningDef == 1
c.AutoBabysit = autoBabysit == 1
c.StreakDecayed = streakDecayed == 1
if deadUntil.Valid { if deadUntil.Valid {
c.DeadUntil = &deadUntil.Time c.DeadUntil = &deadUntil.Time
} }
@@ -598,6 +603,14 @@ func saveAdvCharacter(char *AdventureCharacter) error {
if char.PetMorningDefense { if char.PetMorningDefense {
petMorningDef = 1 petMorningDef = 1
} }
autoBabysit := 0
if char.AutoBabysit {
autoBabysit = 1
}
streakDecayed := 0
if char.StreakDecayed {
streakDecayed = 1
}
_, err := d.Exec(` _, err := d.Exec(`
UPDATE adventure_characters SET UPDATE adventure_characters SET
@@ -623,7 +636,9 @@ func saveAdvCharacter(char *AdventureCharacter) error {
pet_chased_away = ?, pet_reactivated = ?, pet_arrived = ?, pet_chased_away = ?, pet_reactivated = ?, pet_arrived = ?,
misty_encounter_count = ?, misty_donated_count = ?, misty_encounter_count = ?, misty_donated_count = ?,
thom_animal_line_fired = ?, pet_supply_shop_unlocked = ?, pet_level10_date = ?, thom_animal_line_fired = ?, pet_supply_shop_unlocked = ?, pet_level10_date = ?,
pet_morning_defense = ? pet_morning_defense = ?,
auto_babysit = ?,
streak_decayed = ?
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,
@@ -647,6 +662,8 @@ func saveAdvCharacter(char *AdventureCharacter) error {
char.MistyEncounterCount, char.MistyDonatedCount, char.MistyEncounterCount, char.MistyDonatedCount,
thomAnimalLine, petSupplyUnlocked, char.PetLevel10Date, thomAnimalLine, petSupplyUnlocked, char.PetLevel10Date,
petMorningDef, petMorningDef,
autoBabysit,
streakDecayed,
string(char.UserID), string(char.UserID),
) )
return err return err
@@ -777,7 +794,7 @@ func loadAllAdvCharacters() ([]AdventureCharacter, error) {
pet_chased_away, pet_reactivated, pet_arrived, pet_chased_away, pet_reactivated, pet_arrived,
misty_encounter_count, misty_donated_count, misty_encounter_count, misty_donated_count,
thom_animal_line_fired, pet_supply_shop_unlocked, pet_level10_date, thom_animal_line_fired, pet_supply_shop_unlocked, pet_level10_date,
pet_morning_defense pet_morning_defense, auto_babysit, streak_decayed
FROM adventure_characters`) FROM adventure_characters`)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -792,6 +809,7 @@ func loadAllAdvCharacters() ([]AdventureCharacter, error) {
var mistyLastSeen, arinaLastSeen, mistyBuffExp, mistyDebuffExp, arinaBuffExp sql.NullTime var mistyLastSeen, arinaLastSeen, mistyBuffExp, mistyDebuffExp, arinaBuffExp sql.NullTime
var houseFrozen, houseAutopay int var houseFrozen, houseAutopay int
var petChasedAway, petReactivated, petArrived, thomAnimalLine, petSupplyUnlocked, petMorningDef int var petChasedAway, petReactivated, petArrived, thomAnimalLine, petSupplyUnlocked, petMorningDef int
var autoBabysit, streakDecayed 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,
@@ -816,7 +834,7 @@ func loadAllAdvCharacters() ([]AdventureCharacter, error) {
&petChasedAway, &petReactivated, &petArrived, &petChasedAway, &petReactivated, &petArrived,
&c.MistyEncounterCount, &c.MistyDonatedCount, &c.MistyEncounterCount, &c.MistyDonatedCount,
&thomAnimalLine, &petSupplyUnlocked, &c.PetLevel10Date, &thomAnimalLine, &petSupplyUnlocked, &c.PetLevel10Date,
&petMorningDef, &petMorningDef, &autoBabysit, &streakDecayed,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@@ -826,6 +844,8 @@ func loadAllAdvCharacters() ([]AdventureCharacter, error) {
c.RivalUnlockedNotified = rivalUnlocked == 1 c.RivalUnlockedNotified = rivalUnlocked == 1
c.BabysitActive = babysitAct == 1 c.BabysitActive = babysitAct == 1
c.PetMorningDefense = petMorningDef == 1 c.PetMorningDefense = petMorningDef == 1
c.AutoBabysit = autoBabysit == 1
c.StreakDecayed = streakDecayed == 1
c.HouseLoanFrozen = houseFrozen == 1 c.HouseLoanFrozen = houseFrozen == 1
c.HouseAutopay = houseAutopay == 1 c.HouseAutopay = houseAutopay == 1
c.PetChasedAway = petChasedAway == 1 c.PetChasedAway = petChasedAway == 1

View File

@@ -176,8 +176,9 @@ func (p *AdventurePlugin) handleEventRespond(ctx MessageContext) error {
gold += rand.Int64N(event.GoldMax - event.GoldMin + 1) gold += rand.Int64N(event.GoldMax - event.GoldMin + 1)
} }
// Credit gold // Credit gold (5% community tax)
p.euro.Credit(ctx.Sender, float64(gold), "adventure_event_"+event.Key) net, _ := communityTax(ctx.Sender, float64(gold), 0.05)
p.euro.Credit(ctx.Sender, net, "adventure_event_"+event.Key)
// Apply XP if applicable // Apply XP if applicable
xpSkill := event.XPSkill xpSkill := event.XPSkill

View File

@@ -3,6 +3,7 @@ package plugin
import ( import (
"fmt" "fmt"
"log/slog" "log/slog"
"math"
"strings" "strings"
"time" "time"
@@ -96,7 +97,7 @@ func (p *AdventurePlugin) handleHospitalCmd(ctx MessageContext) error {
sb.WriteString("\n\n") sb.WriteString("\n\n")
// Payment prompt // 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("Pay now? (yes / no)\n\n")
sb.WriteString("*Note: Declining payment will result in discharge to the natural respawn queue. " + 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.*") "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) 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 // Revive
char.Alive = true char.Alive = true
char.DeadUntil = nil char.DeadUntil = nil
@@ -176,10 +183,10 @@ func (p *AdventurePlugin) resolveHospitalPay(ctx MessageContext, interaction *ad
// Discharge DM // Discharge DM
p.SendDM(ctx.Sender, fmt.Sprintf( p.SendDM(ctx.Sender, fmt.Sprintf(
"✅ **Discharged — you're alive!**\n\n"+ "✅ **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"+ "She means it. She always means it.\n\n"+
"Go get 'em. Type `!adventure` when you're ready.", "Go get 'em. Type `!adventure` when you're ready.",
cost)) fmtEuro(cost)))
// Room announcement // Room announcement
gr := gamesRoom() gr := gamesRoom()
@@ -220,9 +227,9 @@ func (p *AdventurePlugin) sendHospitalAd(userID id.UserID, char *AdventureCharac
"🏥 **St. Guildmore's Memorial Hospital**\n\n"+ "🏥 **St. Guildmore's Memorial Hospital**\n\n"+
"Nurse Joy has been notified. A bed is being prepared.\n\n"+ "Nurse Joy has been notified. A bed is being prepared.\n\n"+
"Type `!hospital` to check in for same-day revival.\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.", "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() { time.AfterFunc(10*time.Second, func() {
if err := p.SendDM(userID, text); err != nil { if err := p.SendDM(userID, text); err != nil {

View File

@@ -4,6 +4,7 @@ import (
"database/sql" "database/sql"
"fmt" "fmt"
"log/slog" "log/slog"
"math"
"math/rand/v2" "math/rand/v2"
"strings" "strings"
"time" "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 { func communityPotBalance() int {
d := db.Get() d := db.Get()
var balance int 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 potShare := stake - winnerShare
if winnerShare > 0 { if winnerShare > 0 {
p.euro.Credit(winnerID, float64(winnerShare), "rival_duel_win") p.euro.Credit(winnerID, float64(winnerShare), "rival_duel_win")
} }
if potShare > 0 { if potShare > 0 {
communityPotAdd(potShare) communityPotAdd(potShare)
trackTaxPaid(loserID, potShare)
} }
// Update rival records (both directions). // 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 potShare := stake - winnerShare
if winnerShare > 0 { if winnerShare > 0 {
p.euro.Credit(challenge.ChallengerID, float64(winnerShare), "rival_forfeit_win") p.euro.Credit(challenge.ChallengerID, float64(winnerShare), "rival_forfeit_win")
} }
if potShare > 0 { if potShare > 0 {
communityPotAdd(potShare) communityPotAdd(potShare)
trackTaxPaid(challenge.ChallengedID, potShare)
} }
upsertRivalRecord(challenge.ChallengedID, challenge.ChallengerID, false) upsertRivalRecord(challenge.ChallengedID, challenge.ChallengerID, false)

View File

@@ -345,6 +345,36 @@ func (p *AdventurePlugin) midnightReset() error {
continue 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 // Jitter between DMs to avoid Matrix rate limits
if dmsSent > 0 { if dmsSent > 0 {
time.Sleep(time.Duration(1000+rand.IntN(2000)) * time.Millisecond) time.Sleep(time.Duration(1000+rand.IntN(2000)) * time.Millisecond)
@@ -353,15 +383,19 @@ func (p *AdventurePlugin) midnightReset() error {
// Idle shame DM // Idle shame DM
text := renderAdvIdleShameDM(&char) 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 { if err := p.SendDM(char.UserID, text); err != nil {
slog.Error("adventure: failed to send idle shame DM", "user", char.UserID, "err", err) 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 { } else {
// Update streak — LastActionDate was set at action time // Update streak — LastActionDate was set at action time
yesterday := time.Now().UTC().Add(-24 * time.Hour).Format("2006-01-02") yesterday := time.Now().UTC().Add(-24 * time.Hour).Format("2006-01-02")

View File

@@ -289,7 +289,8 @@ func (p *AdventurePlugin) distributeTwinBeeRewards(result *TwinBeeResult) TwinBe
if share < 1 { if share < 1 {
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 totalDistributed += share
if rollTwinBeeGift(ep.uid) { if rollTwinBeeGift(ep.uid) {
summary.GiftCount++ summary.GiftCount++

View File

@@ -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 { func (p *BlackjackPlugin) Commands() []CommandDef {
return []CommandDef{ return []CommandDef{
@@ -727,8 +728,10 @@ func (p *BlackjackPlugin) collectResolveRound(roomID id.RoomID, table *bjTable)
case playerBJ: case playerBJ:
result = "Blackjack!" result = "Blackjack!"
payout = pl.Bet + math.Floor(pl.Bet*1.5) // Return bet + 1.5x (rounded down) payout = pl.Bet + math.Floor(pl.Bet*1.5)
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 dealerBJ: case dealerBJ:
result = "Dealer Blackjack" result = "Dealer Blackjack"
@@ -737,12 +740,16 @@ func (p *BlackjackPlugin) collectResolveRound(roomID id.RoomID, table *bjTable)
case dealerBust: case dealerBust:
result = "Win (dealer bust)!" result = "Win (dealer bust)!"
payout = pl.Bet * 2 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: case playerValue > dealerValue:
result = fmt.Sprintf("Win! %d vs %d", playerValue, dealerValue) result = fmt.Sprintf("Win! %d vs %d", playerValue, dealerValue)
payout = pl.Bet * 2 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: case playerValue == dealerValue:
result = "Push" result = "Push"

View File

@@ -12,6 +12,7 @@ import (
"time" "time"
"gogobee/internal/db" "gogobee/internal/db"
"gogobee/internal/version"
"maunium.net/go/mautrix" "maunium.net/go/mautrix"
) )
@@ -43,6 +44,7 @@ func (p *BotInfoPlugin) Name() string { return "botinfo" }
func (p *BotInfoPlugin) Commands() []CommandDef { func (p *BotInfoPlugin) Commands() []CommandDef {
return []CommandDef{ return []CommandDef{
{Name: "botinfo", Description: "Show bot diagnostics (admin only)", Usage: "!botinfo", Category: "Admin", AdminOnly: true}, {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) OnReaction(_ ReactionContext) error { return nil }
func (p *BotInfoPlugin) OnMessage(ctx MessageContext) error { func (p *BotInfoPlugin) OnMessage(ctx MessageContext) error {
if p.IsCommand(ctx.Body, "version") {
return p.handleVersion(ctx)
}
if !p.IsCommand(ctx.Body, "botinfo") { if !p.IsCommand(ctx.Body, "botinfo") {
// Passively count messages
IncrementMessageCount() IncrementMessageCount()
return nil return nil
} }
@@ -64,9 +68,24 @@ func (p *BotInfoPlugin) OnMessage(ctx MessageContext) error {
return p.handleBotInfo(ctx) 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 { func (p *BotInfoPlugin) handleBotInfo(ctx MessageContext) error {
var sb strings.Builder var sb strings.Builder
sb.WriteString("Bot Diagnostics\n\n") sb.WriteString(fmt.Sprintf("**Bot Diagnostics** — %s\n\n", version.Short()))
// Uptime // Uptime
uptime := time.Since(botStartTime) uptime := time.Since(botStartTime)
@@ -110,6 +129,33 @@ func (p *BotInfoPlugin) handleBotInfo(ctx MessageContext) error {
sb.WriteString("LLM status: not configured\n") 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()) return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
} }

View File

@@ -14,6 +14,26 @@ import (
"maunium.net/go/mautrix/id" "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. // gamesRoom returns the configured GAMES_ROOM, or empty if unset.
func gamesRoom() id.RoomID { func gamesRoom() id.RoomID {
return id.RoomID(os.Getenv("GAMES_ROOM")) 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 { func (p *EuroPlugin) Commands() []CommandDef {
return []CommandDef{ return []CommandDef{

View File

@@ -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 { func (p *HangmanPlugin) Commands() []CommandDef {
return []CommandDef{ return []CommandDef{
@@ -820,10 +821,11 @@ func (p *HangmanPlugin) endGame(roomID id.RoomID, game *hangmanGame) error {
payout = share * p.bonusMul payout = share * p.bonusMul
label = " (early solve bonus!)" label = " (early solve bonus!)"
} }
p.euro.Credit(uid, payout, "hangman_win") net, _ := communityTax(uid, payout, 0.05)
p.recordHangmanScore(uid, payout) p.euro.Credit(uid, net, "hangman_win")
p.recordHangmanScore(uid, net)
name := p.DisplayName(uid) 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))
} }
} }

View File

@@ -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 { func (p *LotteryPlugin) Commands() []CommandDef {
return []CommandDef{ return []CommandDef{
@@ -198,11 +199,9 @@ func (p *LotteryPlugin) handleLotteryOdds(ctx MessageContext) error {
| Match | Prize | Odds (approx.) | | Match | Prize | Odds (approx.) |
|-------|-------|----------------| |-------|-------|----------------|
| 5 of 5 | Jackpot (split among winners) | 1 in 142,506 | | 5 of 5 | Jackpot (split among winners) | 1 in 142,506 |
| 4 of 5 | €1,000 (fixed) | 1 in 3,062 | | 4 of 5 | €5,000 (fixed) | 1 in 3,062 |
| 3 of 5 | €100 (fixed) | 1 in 141 | | 3 of 5 | €500 (fixed) | 1 in 141 |
| 2 of 5 | €10 (fixed) | 1 in 16 | | 2 of 5 | €25 (fixed) | 1 in 16 |
| 1 of 5 | €2 (fixed) | 1 in 4 |
| 0 of 5 | Nothing | — |
Tickets: €1 each. Max 100 per week. 5 numbers from 130. Tickets: €1 each. Max 100 per week. 5 numbers from 130.
Minimum €500 pot required for jackpot payout.` Minimum €500 pot required for jackpot payout.`
@@ -228,8 +227,8 @@ func (p *LotteryPlugin) handleLotteryHistory(ctx MessageContext) error {
if h.RolledOver > 0 { if h.RolledOver > 0 {
sb.WriteString(fmt.Sprintf(" | Rolled over: €%d", h.RolledOver)) 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", sb.WriteString(fmt.Sprintf("\n 4-match: %d | 3-match: %d | 2-match: %d\n\n",
h.Match4Winners, h.Match3Winners, h.Match2Winners, h.Match1Winners)) h.Match4Winners, h.Match3Winners, h.Match2Winners))
} }
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String()) return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())

View File

@@ -14,10 +14,9 @@ import (
// ── Prize Tiers (fixed payouts) ───────────────────────────────────────────── // ── Prize Tiers (fixed payouts) ─────────────────────────────────────────────
var lotteryFixedPrizes = map[int]int{ var lotteryFixedPrizes = map[int]int{
4: 1000, 4: 5000,
3: 100, 3: 500,
2: 10, 2: 25,
1: 2,
} }
// ── Draw Ticker ───────────────────────────────────────────────────────────── // ── Draw Ticker ─────────────────────────────────────────────────────────────
@@ -66,9 +65,9 @@ func (p *LotteryPlugin) reminderTicker() {
if gr != "" { if gr != "" {
pot := communityPotBalance() pot := communityPotBalance()
p.SendMessage(gr, fmt.Sprintf( 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.", "Tickets €1 each. Max 100 per player. `!lottery buy [N]` to enter.",
pot)) fmtEuro(pot)))
} }
db.MarkJobCompleted(jobName, weekKey) db.MarkJobCompleted(jobName, weekKey)
@@ -101,7 +100,7 @@ func (p *LotteryPlugin) executeDraw(weekStart string) {
tickets[i].MatchCount = &mc tickets[i].MatchCount = &mc
prize := 0 prize := 0
if mc >= 1 && mc <= 4 { if mc >= 2 && mc <= 4 {
prize = lotteryFixedPrizes[mc] prize = lotteryFixedPrizes[mc]
} }
tickets[i].Prize = &prize tickets[i].Prize = &prize
@@ -192,7 +191,7 @@ func (p *LotteryPlugin) executeDraw(weekStart string) {
Match4Winners: len(matchBuckets[4]), Match4Winners: len(matchBuckets[4]),
Match3Winners: len(matchBuckets[3]), Match3Winners: len(matchBuckets[3]),
Match2Winners: len(matchBuckets[2]), Match2Winners: len(matchBuckets[2]),
Match1Winners: len(matchBuckets[1]), Match1Winners: 0,
PotTotal: initialPot, PotTotal: initialPot,
RolledOver: rolledOver, RolledOver: rolledOver,
} }
@@ -205,6 +204,9 @@ func (p *LotteryPlugin) executeDraw(weekStart string) {
p.SendMessage(gr, announcement) p.SendMessage(gr, announcement)
} }
// DM each winner with their tickets and results.
p.dmWinners(winning, tickets)
// Cleanup old tickets. // Cleanup old tickets.
lotteryCleanupOldTickets() lotteryCleanupOldTickets()
@@ -215,6 +217,58 @@ func (p *LotteryPlugin) executeDraw(weekStart string) {
"jackpot_winners", len(jackpotWinners)) "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 ──────────────────────────────────────────────────── // ── Announcement Builder ────────────────────────────────────────────────────
func (p *LotteryPlugin) buildDrawAnnouncement(winning []int, h *lotteryHistoryRow, jackpotWinners []lotteryTicket) string { 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 { if len(jackpotWinners) > 0 && h.JackpotAmount > 0 {
names := p.resolveWinnerNames(jackpotWinners) names := p.resolveWinnerNames(jackpotWinners)
if len(jackpotWinners) == 1 { 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 { } else {
sb.WriteString(fmt.Sprintf("Jackpot (5 match): Split between %s. €%d each. 🎉\n", sb.WriteString(fmt.Sprintf("Jackpot (5 match): Split between %s. %s each. 🎉\n",
joinNames(names), h.JackpotAmount)) joinNames(names), fmtEuro(h.JackpotAmount)))
} }
} else if len(jackpotWinners) > 0 && h.JackpotAmount == 0 { } else if len(jackpotWinners) > 0 && h.JackpotAmount == 0 {
sb.WriteString("Jackpot withheld — pot insufficient. Rolls to next week. Fixed tiers paid as normal.\n") 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. // Fixed tiers.
sb.WriteString(fmt.Sprintf("4 match: %d winner(s) — €1,000 each\n", h.Match4Winners)) sb.WriteString(fmt.Sprintf("4 match: %d winner(s) — €5,000 each\n", h.Match4Winners))
sb.WriteString(fmt.Sprintf("3 match: %d winner(s) — €100 each\n", h.Match3Winners)) sb.WriteString(fmt.Sprintf("3 match: %d winner(s) — €500 each\n", h.Match3Winners))
sb.WriteString(fmt.Sprintf("2 match: %d winner(s) — €10 each\n", h.Match2Winners)) sb.WriteString(fmt.Sprintf("2 match: %d winner(s) — €25 each\n", h.Match2Winners))
sb.WriteString(fmt.Sprintf("1 match: %d winner(s) — €2 each\n", h.Match1Winners))
distributed := h.PotTotal - h.RolledOver 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() return sb.String()
} }

View File

@@ -13,6 +13,7 @@ import (
"gogobee/internal/db" "gogobee/internal/db"
"gogobee/internal/util" "gogobee/internal/util"
"gogobee/internal/version"
"maunium.net/go/mautrix" "maunium.net/go/mautrix"
"maunium.net/go/mautrix/event" "maunium.net/go/mautrix/event"
@@ -57,6 +58,19 @@ type Plugin interface {
Init() error 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. // dmCache maps user IDs to their DM room IDs to avoid creating duplicate rooms.
var ( var (
dmCache = make(map[id.UserID]id.RoomID) dmCache = make(map[id.UserID]id.RoomID)
@@ -764,6 +778,7 @@ func safeGo(label string, fn func()) {
defer func() { defer func() {
if r := recover(); r != nil { if r := recover(); r != nil {
slog.Error("goroutine panic recovered", "label", label, "panic", r) slog.Error("goroutine panic recovered", "label", label, "panic", r)
db.RecordCrash(version.Short(), label, fmt.Sprintf("%v", r))
} }
}() }()
fn() fn()

View File

@@ -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 { func (p *StatsPlugin) Commands() []CommandDef {
return []CommandDef{ return []CommandDef{
@@ -199,6 +200,9 @@ func (p *StatsPlugin) handleStats(ctx MessageContext) error {
avgWords = totalWords / totalMsg avgWords = totalWords / totalMsg
} }
var taxPaid int
_ = d.QueryRow(`SELECT total_paid FROM tax_ledger WHERE user_id = ?`, string(target)).Scan(&taxPaid)
msg := fmt.Sprintf( msg := fmt.Sprintf(
"📊 Stats for %s\n"+ "📊 Stats for %s\n"+
"Messages: %s | Words: %s | Chars: %s\n"+ "Messages: %s | Words: %s | Chars: %s\n"+
@@ -213,6 +217,9 @@ func (p *StatsPlugin) handleStats(ctx MessageContext) error {
formatNumber(nightMsg), formatNumber(morningMsg), formatNumber(nightMsg), formatNumber(morningMsg),
avgWords, avgWords,
) )
if taxPaid > 0 {
msg += fmt.Sprintf("\n🏛 Community tax paid: %s", fmtEuro(taxPaid))
}
return p.SendReply(ctx.RoomID, ctx.EventID, msg) return p.SendReply(ctx.RoomID, ctx.EventID, msg)
} }
@@ -322,8 +329,15 @@ func (p *StatsPlugin) handleSuperStats(ctx MessageContext) error {
var achievementCount int var achievementCount int
_ = d.QueryRow(`SELECT COUNT(*) FROM achievements WHERE user_id = ?`, uid).Scan(&achievementCount) _ = 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", var taxPaid int
level, formatNumber(xp), balance, achievementCount)) _ = 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 ── // ── Chat Stats ──
var totalMsg, totalWords, totalLinks, totalEmojis, totalQuestions int var totalMsg, totalWords, totalLinks, totalEmojis, totalQuestions int

View File

@@ -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 { func (p *UnoPlugin) Commands() []CommandDef {
return []CommandDef{ return []CommandDef{
@@ -1689,7 +1690,8 @@ func (p *UnoPlugin) suddenDeathWinner(game *unoGame) {
if playerWins { if playerWins {
payout := p.claimFromPot(game.wager) payout := p.claimFromPot(game.wager)
totalPayout := game.wager + payout 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() newPot := p.getPot()
p.SendMessage(game.dmRoomID, "🎉 **You win on points!**") p.SendMessage(game.dmRoomID, "🎉 **You win on points!**")
@@ -1885,7 +1887,8 @@ func (p *UnoPlugin) playerWins(game *unoGame) error {
potBefore := p.getPot() potBefore := p.getPot()
payout := p.claimFromPot(game.wager) payout := p.claimFromPot(game.wager)
totalPayout := game.wager + payout 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() newPot := p.getPot()
playerName := p.DisplayName(game.playerID) playerName := p.DisplayName(game.playerID)

View File

@@ -2564,7 +2564,8 @@ func (p *UnoPlugin) multiPlayerWins2(game *unoMultiGame, winner *unoMultiPlayer)
} }
} }
totalPot := game.ante * float64(humanCount+game.botAntes) 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.recordMultiGame(game, winner.userID, "sudden_death_win")
p.cleanupMultiGame(game) p.cleanupMultiGame(game)
@@ -2614,9 +2615,10 @@ func (p *UnoPlugin) multiPlayerWins(game *unoMultiGame, winner *unoMultiPlayer)
} }
} }
totalPot := game.ante * float64(humanCount+game.botAntes) totalPot := game.ante * float64(humanCount+game.botAntes)
net, _ := communityTax(winner.userID, totalPot, 0.05)
name := p.DisplayName(winner.userID) 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( p.SendMessage(game.roomID, fmt.Sprintf(
"🎉 **%s wins Multiplayer UNO!**\nPot: €%d | Turns: %d\n\n%s", "🎉 **%s wins Multiplayer UNO!**\nPot: €%d | Turns: %d\n\n%s",

View File

@@ -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 { func (p *WordlePlugin) Commands() []CommandDef {
return []CommandDef{ return []CommandDef{
@@ -695,8 +696,9 @@ func (p *WordlePlugin) awardPrize(puzzle *WordlePuzzle) []WordlePayout {
if c.solver { if c.solver {
amount += solverBonus amount += solverBonus
} }
p.euro.Credit(uid, float64(amount), "wordle_win") net, _ := communityTax(uid, float64(amount), 0.05)
payouts = append(payouts, WordlePayout{UserID: uid, Name: c.name, Amount: amount, Solver: c.solver}) p.euro.Credit(uid, net, "wordle_win")
payouts = append(payouts, WordlePayout{UserID: uid, Name: c.name, Amount: int(net), Solver: c.solver})
} }
return payouts return payouts
} }

View File

@@ -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
}

View File

@@ -15,6 +15,7 @@ import (
"gogobee/internal/dreamclient" "gogobee/internal/dreamclient"
"gogobee/internal/plugin" "gogobee/internal/plugin"
"gogobee/internal/util" "gogobee/internal/util"
"gogobee/internal/version"
"github.com/joho/godotenv" "github.com/joho/godotenv"
"github.com/robfig/cron/v3" "github.com/robfig/cron/v3"
@@ -35,7 +36,7 @@ func main() {
logLevel = "info" logLevel = "info"
} }
util.InitLogger(logLevel) util.InitLogger(logLevel)
slog.Info("logger initialized", "level", logLevel, slog.Info(version.Full(), "level", logLevel,
"ollama_host", os.Getenv("OLLAMA_HOST"), "ollama_host", os.Getenv("OLLAMA_HOST"),
"ollama_model", os.Getenv("OLLAMA_MODEL")) "ollama_model", os.Getenv("OLLAMA_MODEL"))
@@ -52,6 +53,7 @@ func main() {
if err := db.SeedSchedulerDefaults(db.Get()); err != nil { if err := db.SeedSchedulerDefaults(db.Get()); err != nil {
slog.Warn("seed scheduler defaults failed", "err", err) slog.Warn("seed scheduler defaults failed", "err", err)
} }
db.RecordStartup(version.Version, version.Commit)
// Create Matrix client // Create Matrix client
cfg := bot.Config{ cfg := bot.Config{
@@ -207,6 +209,7 @@ func main() {
defer func() { defer func() {
if rec := recover(); rec != nil { if rec := recover(); rec != nil {
slog.Error("member event handler panic", "panic", rec, "room", evt.RoomID) 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 { if rec := recover(); rec != nil {
slog.Error("message event handler panic", "panic", rec, slog.Error("message event handler panic", "panic", rec,
"sender", evt.Sender, "room", evt.RoomID) "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 { if rec := recover(); rec != nil {
slog.Error("reaction event handler panic", "panic", rec, slog.Error("reaction event handler panic", "panic", rec,
"sender", evt.Sender, "room", evt.RoomID) "sender", evt.Sender, "room", evt.RoomID)
db.RecordCrash(version.Short(), "reaction_handler", fmt.Sprintf("%v", rec))
} }
}() }()