mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 00:32:40 +00:00
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:
@@ -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.
|
||||||
|
|||||||
@@ -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(¤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
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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++
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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{
|
||||||
|
|||||||
@@ -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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 1–30.
|
Tickets: €1 each. Max 100 per week. 5 numbers from 1–30.
|
||||||
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())
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
28
internal/version/version.go
Normal file
28
internal/version/version.go
Normal 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
|
||||||
|
}
|
||||||
7
main.go
7
main.go
@@ -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))
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user