From f61bf89c718185c55721e4a8e1c3351b323d29ca Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Tue, 31 Mar 2026 19:20:26 -0700 Subject: [PATCH] Add overlevel penalty and per-location cooldown to prevent low-tier farming - Overlevel penalty reduces loot and XP when effective level exceeds location minimum (gap >3: -15%/level, floor 5%) - 3-hour per-location cooldown after successful runs, persisted via activity log - Applies to all activity types (dungeons, mining, foraging, fishing) Co-Authored-By: Claude Opus 4.6 --- internal/plugin/adventure.go | 13 ++++++ internal/plugin/adventure_activities.go | 60 +++++++++++++++++++++++++ 2 files changed, 73 insertions(+) diff --git a/internal/plugin/adventure.go b/internal/plugin/adventure.go index 9b1dd79..fa4ca86 100644 --- a/internal/plugin/adventure.go +++ b/internal/plugin/adventure.go @@ -632,6 +632,19 @@ func (p *AdventurePlugin) resolveActivity(ctx MessageContext, char *AdventureCha return p.SendDM(ctx.Sender, fmt.Sprintf("You are not eligible for %s. Your level or equipment tier is too low.", loc.Name)) } + // Per-location cooldown: 3 hours after a successful run + if remaining := advLocationCooldown(char.UserID, loc.Name); remaining > 0 { + mins := int(remaining.Minutes()) + if mins < 1 { + return p.SendDM(ctx.Sender, fmt.Sprintf("🕐 %s is still being cleared out. Try again in less than a minute, or pick a different location.", loc.Name)) + } + h, m := mins/60, mins%60 + if h > 0 { + return p.SendDM(ctx.Sender, fmt.Sprintf("🕐 %s is still being cleared out. Try again in %dh %dm, or pick a different location.", loc.Name, h, m)) + } + return p.SendDM(ctx.Sender, fmt.Sprintf("🕐 %s is still being cleared out. Try again in %d minutes, or pick a different location.", loc.Name, m)) + } + // Resolve the action result := resolveAdvAction(char, equip, loc, bonuses, inPenaltyZone) diff --git a/internal/plugin/adventure_activities.go b/internal/plugin/adventure_activities.go index fe66e1d..4b9a8ea 100644 --- a/internal/plugin/adventure_activities.go +++ b/internal/plugin/adventure_activities.go @@ -3,10 +3,15 @@ package plugin import ( "math" "math/rand/v2" + "time" + + "gogobee/internal/db" "maunium.net/go/mautrix/id" ) +const advLocationCooldownDuration = 3 * time.Hour + // ── Activity & Outcome Types ───────────────────────────────────────────────── type AdvActivityType string @@ -339,6 +344,33 @@ func computeAdvBonuses(treasures []AdvTreasureBonus, buffs []AdvBuff, streak int return b } +// ── Location Cooldown ─────────────────────────────────────────────────────── + +// advLocationCooldown returns how long until a player can run the same location +// again. Returns 0 if no cooldown is active. Only successful runs trigger cooldown. +func advLocationCooldown(userID id.UserID, location string) time.Duration { + d := db.Get() + var loggedAt string + err := d.QueryRow( + `SELECT logged_at FROM adventure_activity_log + WHERE user_id = ? AND location = ? AND outcome IN ('success', 'exceptional') + ORDER BY logged_at DESC LIMIT 1`, + string(userID), location, + ).Scan(&loggedAt) + if err != nil { + return 0 + } + t, err := time.Parse("2006-01-02 15:04:05", loggedAt) + if err != nil { + return 0 + } + remaining := advLocationCooldownDuration - time.Since(t) + if remaining <= 0 { + return 0 + } + return remaining +} + // ── Eligibility ────────────────────────────────────────────────────────────── // advIsEligible checks if a character can enter a location. @@ -578,6 +610,20 @@ func advCheckBrokenEquipment(equip map[EquipmentSlot]*AdvEquipment) []EquipmentS return broken } +// ── Overlevel Penalty ─────────────────────────────────────────────────────── + +// advOverlevelMultiplier returns a multiplier (0.05–1.0) that reduces XP and +// loot when a character's effective level far exceeds the location's minimum. +// Gap 0-3: no penalty. Gap 4+: −15% per level over 3, floor 5%. +func advOverlevelMultiplier(effectiveLevel, minLevel int) float64 { + gap := effectiveLevel - minLevel + if gap <= 3 { + return 1.0 + } + mult := 1.0 - 0.15*float64(gap-3) + return math.Max(0.05, mult) +} + // ── Outcome Resolution ─────────────────────────────────────────────────────── type AdvActionResult struct { @@ -606,6 +652,10 @@ func resolveAdvAction(char *AdventureCharacter, equip map[EquipmentSlot]*AdvEqui probs := calculateAdvProbabilities(char, equip, loc, bonuses, inPenaltyZone) + // Overlevel penalty — reduces loot and XP for farming low-tier content + skillLevel := advEffectiveSkill(char, loc.Activity, bonuses) + overlevelMult := advOverlevelMultiplier(skillLevel, loc.MinLevel) + // Roll outcome roll := rand.Float64() * 100 @@ -629,6 +679,12 @@ func resolveAdvAction(char *AdventureCharacter, equip map[EquipmentSlot]*AdvEqui // Generate loot for success/exceptional if result.Outcome == AdvOutcomeSuccess || result.Outcome == AdvOutcomeExceptional { result.LootItems = generateAdvLoot(loc, result.Outcome == AdvOutcomeExceptional, bonuses.LootQuality) + // Apply overlevel penalty to loot values + if overlevelMult < 1.0 { + for i := range result.LootItems { + result.LootItems[i].Value = max(1, int64(float64(result.LootItems[i].Value)*overlevelMult)) + } + } for _, item := range result.LootItems { result.TotalLootValue += item.Value } @@ -656,6 +712,10 @@ func resolveAdvAction(char *AdventureCharacter, equip map[EquipmentSlot]*AdvEqui if advEquippedArenaSets(equip)["ironclad"] { xp = int(float64(xp) * 1.05) } + // Apply overlevel penalty to XP + if overlevelMult < 1.0 { + xp = max(1, int(float64(xp)*overlevelMult)) + } result.XPGained = xp // Equipment degradation on bad outcomes