diff --git a/internal/plugin/euro.go b/internal/plugin/euro.go index 04b02a0..5aad6d9 100644 --- a/internal/plugin/euro.go +++ b/internal/plugin/euro.go @@ -4,6 +4,7 @@ import ( "fmt" "log/slog" "os" + "regexp" "strings" "sync" "time" @@ -11,6 +12,7 @@ import ( "gogobee/internal/db" "maunium.net/go/mautrix" + "maunium.net/go/mautrix/event" "maunium.net/go/mautrix/id" ) @@ -64,6 +66,34 @@ func loadEuroConfig() euroConfig { } } +// --------------------------------------------------------------------------- +// Combo system — activity streaks that multiply passive euro earning. +// +// Three independent combo categories: messages, links, images. +// Each combo increments with qualifying activity and resets after 10 min idle. +// Multiplier: 1 + (combo_step * 2.0) — so step 1 = 3x, step 5 = 11x, etc. +// Daily cap per category prevents unbounded farming. +// --------------------------------------------------------------------------- + +const ( + comboTimeout = 10 * time.Minute + comboMsgCap = 50 + comboLinkCap = 10 + comboImageCap = 15 + comboMultPerStep = 2.0 // +200% per step + comboLinkBase = 5.0 + comboImageBase = 3.0 +) + +type comboState struct { + Step int + LastAt time.Time + Today string + DayHits int +} + +var urlPattern = regexp.MustCompile(`https?://\S+`) + // --------------------------------------------------------------------------- // Euro Plugin // --------------------------------------------------------------------------- @@ -72,6 +102,7 @@ type EuroPlugin struct { Base cfg euroConfig cooldowns map[id.UserID]time.Time + combos map[id.UserID]map[string]*comboState // category → state mu sync.Mutex } @@ -80,6 +111,7 @@ func NewEuroPlugin(client *mautrix.Client) *EuroPlugin { Base: NewBase(client), cfg: loadEuroConfig(), cooldowns: make(map[id.UserID]time.Time), + combos: make(map[id.UserID]map[string]*comboState), } } @@ -91,6 +123,7 @@ func (p *EuroPlugin) Commands() []CommandDef { {Name: "balance", Description: "Check your euro balance", Usage: "!balance", Category: "Economy"}, {Name: "baltop", Description: "Euro leaderboard", Usage: "!baltop", Category: "Economy"}, {Name: "baltransfer", Description: "Send euros to another player", Usage: "!baltransfer @user €amount", Category: "Economy"}, + {Name: "combo", Description: "View your current activity combo streaks", Usage: "!combo", Category: "Economy"}, } } @@ -111,6 +144,8 @@ func (p *EuroPlugin) OnMessage(ctx MessageContext) error { return p.handleBaltop(ctx) case p.IsCommand(ctx.Body, "baltransfer"): return p.handleTransfer(ctx) + case p.IsCommand(ctx.Body, "combo"): + return p.handleCombo(ctx) } return nil } @@ -128,7 +163,6 @@ func (p *EuroPlugin) awardPassiveEuros(ctx MessageContext) { return } p.cooldowns[ctx.Sender] = now - // Periodic cleanup if len(p.cooldowns) > 1000 { for uid, t := range p.cooldowns { if now.Sub(t) > time.Duration(p.cfg.CooldownSeconds)*time.Second { @@ -138,23 +172,138 @@ func (p *EuroPlugin) awardPassiveEuros(ctx MessageContext) { } p.mu.Unlock() + p.ensureBalance(ctx.Sender) + + // Message combo — base amount from word count, multiplied by combo. words := len(strings.Fields(ctx.Body)) - var amount float64 + var baseMsg float64 switch { case words >= 51: - amount = 20.00 + baseMsg = 20.00 case words >= 26: - amount = 10.00 + baseMsg = 10.00 case words >= 11: - amount = 5.00 + baseMsg = 5.00 case words >= 4: - amount = 2.50 + baseMsg = 2.50 default: - amount = 1.00 + baseMsg = 1.00 + } + msgMult := p.advanceCombo(ctx.Sender, "message", comboMsgCap) + p.credit(ctx.Sender, baseMsg*msgMult, "message") + + // Link combo — bonus per URL in the message. + if urls := urlPattern.FindAllString(ctx.Body, -1); len(urls) > 0 { + linkMult := p.advanceCombo(ctx.Sender, "link", comboLinkCap) + p.credit(ctx.Sender, comboLinkBase*linkMult*float64(len(urls)), "link_combo") } - p.ensureBalance(ctx.Sender) - p.credit(ctx.Sender, amount, "message") + // Image combo — bonus for image/video/file attachments. + if mc := ctx.Event.Content.AsMessage(); mc != nil { + if mc.MsgType == event.MsgImage || mc.MsgType == event.MsgVideo || mc.MsgType == event.MsgFile { + imgMult := p.advanceCombo(ctx.Sender, "image", comboImageCap) + p.credit(ctx.Sender, comboImageBase*imgMult, "image_combo") + } + } +} + +// advanceCombo increments a user's combo for a category, resets if timed out +// or daily cap reached, and returns the current multiplier. +func (p *EuroPlugin) advanceCombo(userID id.UserID, category string, dailyCap int) float64 { + p.mu.Lock() + defer p.mu.Unlock() + + now := time.Now() + today := now.UTC().Format("2006-01-02") + + userCombos, ok := p.combos[userID] + if !ok { + userCombos = make(map[string]*comboState) + p.combos[userID] = userCombos + } + + cs, ok := userCombos[category] + if !ok { + cs = &comboState{} + userCombos[category] = cs + } + + // Reset on new day. + if cs.Today != today { + cs.Step = 0 + cs.DayHits = 0 + cs.Today = today + } + + // Reset if combo timed out. + if !cs.LastAt.IsZero() && now.Sub(cs.LastAt) > comboTimeout { + cs.Step = 0 + } + + // At daily cap, return base multiplier (no combo bonus). + if cs.DayHits >= dailyCap { + return 1.0 + } + + cs.DayHits++ + cs.Step++ + cs.LastAt = now + + return 1.0 + float64(cs.Step)*comboMultPerStep +} + +// --------------------------------------------------------------------------- +// !combo command +// --------------------------------------------------------------------------- + +func (p *EuroPlugin) handleCombo(ctx MessageContext) error { + p.mu.Lock() + userCombos := p.combos[ctx.Sender] + now := time.Now() + today := now.UTC().Format("2006-01-02") + + type info struct { + name string + step int + dayHits int + cap int + active bool + } + cats := []info{ + {"Messages", 0, 0, comboMsgCap, false}, + {"Links", 0, 0, comboLinkCap, false}, + {"Images", 0, 0, comboImageCap, false}, + } + keys := []string{"message", "link", "image"} + + if userCombos != nil { + for i, k := range keys { + if cs, ok := userCombos[k]; ok && cs.Today == today { + cats[i].step = cs.Step + cats[i].dayHits = cs.DayHits + cats[i].active = !cs.LastAt.IsZero() && now.Sub(cs.LastAt) < comboTimeout + } + } + } + p.mu.Unlock() + + var sb strings.Builder + sb.WriteString("🔥 **Activity Combo**\n\n") + + for _, c := range cats { + mult := 1.0 + float64(c.step)*comboMultPerStep + status := "💤 inactive" + if c.active { + status = fmt.Sprintf("⚡ **%.0fx** multiplier", mult) + } else if c.step > 0 { + status = "⏳ expired (resets on next activity)" + } + sb.WriteString(fmt.Sprintf("**%s**: step %d/%d — %s\n", c.name, c.dayHits, c.cap, status)) + } + + sb.WriteString(fmt.Sprintf("\nCombo grows +%.0f%% per step. Resets after %d min idle.", comboMultPerStep*100, int(comboTimeout.Minutes()))) + + return p.SendReply(ctx.RoomID, ctx.EventID, sb.String()) } // ---------------------------------------------------------------------------