package plugin import ( "fmt" "log/slog" "math/rand/v2" "sort" "strings" "sync" "time" "gogobee/internal/db" "maunium.net/go/mautrix/id" ) // coopTicker has two cadences: // // 1. Lock checks fire every minute. They're cheap (one timestamp comparison // per open run) and a 24h invite window is poorly served by a 24h tick — // a run started after 08:00 UTC would otherwise wait 30-48h to lock. // 2. Floor resolution fires once per UTC day at morningHour, gated by the // daily_prefetch JobCompleted guard. Resolution is the heavy step that // advances days, distributes rewards, and posts to the room. func (p *AdventurePlugin) coopTicker() { ticker := time.NewTicker(1 * time.Minute) defer ticker.Stop() for range ticker.C { // Locks: every minute. p.coopProcessLocks() // Gift expiries: every minute. Resolves any gift whose individual // 6h voting window has elapsed. Modifier sits on the run until the // next floor resolution merges it. p.coopProcessGiftExpiries() // Resolutions: once per day at morningHour:00 UTC. now := time.Now().UTC() if now.Hour() != p.morningHour || now.Minute() != 0 { continue } dateKey := now.Format("2006-01-02") jobName := "coop_dungeon_daily" if db.JobCompleted(jobName, dateKey) { continue } slog.Info("coop: daily tick — resolving active runs") p.coopProcessActiveRuns() db.MarkJobCompleted(jobName, dateKey) } } // ── Lock Phase ────────────────────────────────────────────────────────────── // coopProcessLocks closes invites whose 24h window has elapsed: locks parties // of 2+, cancels solo runs. func (p *AdventurePlugin) coopProcessLocks() { runs, err := loadOpenCoopRuns() if err != nil { slog.Error("coop: load open runs", "err", err) return } now := time.Now().UTC() for _, run := range runs { if now.Before(run.CreatedAt.Add(coopInviteWindow)) { continue } members, err := loadCoopMembers(run.ID) if err != nil { slog.Error("coop: load members for lock", "run", run.ID, "err", err) continue } if len(members) < coopMinPartySize { // Cancel solo runs and refund nothing (no funding has been spent yet). _ = setCoopRunStatus(run.ID, "cancelled") gr := gamesRoom() if gr != "" { if run.InvitePostID != "" { _ = p.UnpinEvent(gr, run.InvitePostID) } _ = p.SendMessage(gr, fmt.Sprintf("⚔️ Tier %d Co-op #%d expired with no party. Cancelled.", run.Tier, run.ID)) } _ = p.SendDM(run.LeaderID, fmt.Sprintf("Co-op #%d expired before anyone joined. No party, no run.", run.ID)) continue } // Lock: deduct combat action from each member, mark active, post lock notice. if err := lockCoopRun(run.ID); err != nil { slog.Error("coop: lock run", "run", run.ID, "err", err) continue } for _, m := range members { char, err := loadAdvCharacter(m.UserID) if err != nil { continue } if char.CanDoCombat(false) { char.CombatActionsUsed++ char.ActionTakenToday = true char.LastActionDate = now.Format("2006-01-02") _ = saveAdvCharacter(char) } } fresh, _ := loadCoopRun(run.ID) gr := gamesRoom() if gr != "" { if run.InvitePostID != "" { _ = p.UnpinEvent(gr, run.InvitePostID) } _ = p.SendMessage(gr, renderCoopLock(fresh, members, p)) } // Per-player DM with funding instructions. for _, m := range members { _ = p.SendDM(m.UserID, renderCoopFundingPrompt(fresh, 1)) } // Generate and post day-1 floor event. p.coopGenerateAndPostEvent(fresh, members, 1) } } // coopGenerateAndPostEvent picks a category-weighted event for the given day, // stores it, and posts the vote prompt to the game room with TwinBee narration. func (p *AdventurePlugin) coopGenerateAndPostEvent(run *CoopRun, members []CoopMember, day int) { cat := pickCoopEventCategory(run.Tier) idx := pickCoopEvent(cat) rec := coopEventRecommended(cat, idx) if err := createCoopEvent(run.ID, day, cat, idx, rec); err != nil { slog.Error("coop: create event", "run", run.ID, "day", day, "err", err) return } gr := gamesRoom() if gr == "" { return } event, _ := loadCoopEvent(run.ID, day) if event == nil { return } postID, err := p.SendMessageID(gr, renderCoopEventPost(run, members, event)) if err != nil { slog.Error("coop: post event", "err", err) return } _ = saveCoopEventPostID(run.ID, day, postID) } // ── Daily Resolution ──────────────────────────────────────────────────────── func (p *AdventurePlugin) coopProcessActiveRuns() { runs, err := loadActiveCoopRuns() if err != nil { slog.Error("coop: load active runs", "err", err) return } now := time.Now().UTC() for _, run := range runs { // Need at least a 12h funding window between lock and the first // floor resolution. Without this, a run locked at 07:00 UTC would // resolve an hour later at 08:00 UTC. With per-minute lock checks // (locks now fire any time), the previous "same UTC date" guard // was too conservative — it pushed first-day windows out to 24-32h // even when 13-23h would have been plenty. if run.LockedAt != nil && now.Sub(run.LockedAt.UTC()) < 12*time.Hour { continue } if err := p.coopResolveFloor(run); err != nil { slog.Error("coop: resolve floor", "run", run.ID, "err", err) } } } // coopResolveFloor processes the current day's floor: auto-plays missing // funders, rolls outcome, advances or completes the run. func (p *AdventurePlugin) coopResolveFloor(run *CoopRun) error { day := run.CurrentDay // Skip only if this run is fully terminal. A "resolved" day with the run // still in 'active' status means a prior tick crashed mid-distribution; // we must re-enter to finish via idempotent operations (per-row claims). if run.LastResolvedDay >= day && run.Status != "active" { return nil } members, err := loadCoopMembers(run.ID) if err != nil { return err } // Lock all party members in stable UserID order so concurrent !coop fund / // vote / giftvote commands don't race the resolver. Stable order rules // out lock-order deadlock with other goroutines that lock multiple users. sort.Slice(members, func(i, j int) bool { return members[i].UserID < members[j].UserID }) mutexes := make([]*sync.Mutex, 0, len(members)) for _, m := range members { mu := p.advUserLock(m.UserID) mu.Lock() mutexes = append(mutexes, mu) } defer func() { for _, mu := range mutexes { mu.Unlock() } }() // Reload the run after acquiring locks. A concurrent admin action or // another scheduler tick may have moved the state. freshRun, err := loadCoopRun(run.ID) if err != nil || freshRun == nil { return err } run = freshRun if run.Status != "active" { return nil } // Re-check skip after lock acquisition (another tick could have already // fully finalized — though our per-tick guard makes that unlikely). if run.LastResolvedDay >= day && run.Status != "active" { return nil } // Reload members under lock for fresh funding state. members, err = loadCoopMembers(run.ID) if err != nil { return err } sort.Slice(members, func(i, j int) bool { return members[i].UserID < members[j].UserID }) // Resume vs. fresh detection: if the day's event already has an outcome, // a prior tick rolled this floor before crashing. Reuse that outcome — // re-rolling would be the worst sin (different result on retry). event, _ := loadCoopEvent(run.ID, day) resuming := event != nil && event.Outcome != "" autoPlayed := []*CoopMember{} choices := make(map[string]CoopFundingTier, len(members)) var floorWon bool var winning string var eventMod, successPct int var giftSummary string if resuming { floorWon = event.Outcome == "success" winning = event.WinningVote eventMod = event.ModifierApplied // Reconstruct choices for display purposes. for _, m := range members { tier, _ := coopMemberModifier(&m, day) choices[string(m.UserID)] = tier } successPct = 0 // unknown on resume; render hides it } else { // Auto-play any member who didn't fund. for i := range members { m := &members[i] if _, ok := m.DailyFunding[day]; !ok { m.DailyFunding[day] = CoopFundNone if err := saveCoopMemberFunding(m); err != nil { slog.Error("coop: auto-play save", "run", run.ID, "user", m.UserID, "err", err) } autoPlayed = append(autoPlayed, m) } } totalMod := 0 tierDef := coopTierTable[run.Tier] for _, m := range members { tier, mod := coopMemberModifier(&m, day) choices[string(m.UserID)] = tier totalMod += mod char, err := loadAdvCharacter(m.UserID) if err != nil { continue } totalMod += coopLevelBonus(char.CombatLevel, tierDef.minLevel) totalMod += coopPetBonus(char) } // Lazy-create the day's event if missing — covers the crash window // between lock and the original event creation. if event == nil { cat := pickCoopEventCategory(run.Tier) idx := pickCoopEvent(cat) _ = createCoopEvent(run.ID, day, cat, idx, coopEventRecommended(cat, idx)) event, _ = loadCoopEvent(run.ID, day) } if event != nil { winning = coopTallyVote(event, run.LeaderID) eventMod = coopEventOptionModifier(event.Category, event.EventIndex, winning) totalMod += eventMod } var giftMod int giftMod, giftSummary = p.coopResolveGifts(run, run.LeaderID, day) totalMod += giftMod successPct = 100 - run.BaseDifficulty + totalMod if successPct < 5 { successPct = 5 } if successPct > 95 { successPct = 95 } roll := rand.IntN(100) floorWon = roll < successPct // Persist event resolution — turns this into a "resume" path on retry. if event != nil { outcomeStr := "failure" if floorWon { outcomeStr = "success" } _ = resolveCoopEvent(run.ID, day, winning, outcomeStr, eventMod) } dailyPost := renderCoopDailyResult(p, run, members, day, choices, successPct, floorWon, autoPlayed, event, winning, eventMod) if giftSummary != "" { dailyPost += "\n\n" + giftSummary } if gr := gamesRoom(); gr != "" { _ = p.SendMessage(gr, dailyPost) } } gr := gamesRoom() if !floorWon { // Resolve bets first (idempotent via claimCoopBetPayout), then mark // the run wiped. Order matters: if status is set first and the bot // crashes, the next tick won't pick this run up. summary := p.coopResolveBets(run, false) giftLog := renderCoopGiftLog(p, run.ID) parts := []string{} if summary != "" { parts = append(parts, summary) } if giftLog != "" { parts = append(parts, giftLog) } if len(parts) > 0 && gr != "" { _ = p.SendMessage(gr, strings.Join(parts, "\n\n")) } _ = completeCoopRun(run.ID, "wiped", 0) _ = unlockCoopCombatActionsForRun(run.ID) _ = setCoopRunLastResolvedDay(run.ID, day) return nil } if day >= run.TotalDays { def := coopTierTable[run.Tier] reward := def.rewardBase p.coopDistributeReward(run, reward, members) _ = completeCoopRun(run.ID, "complete", reward) _ = unlockCoopCombatActionsForRun(run.ID) _ = setCoopRunLastResolvedDay(run.ID, day) return nil } if err := advanceCoopDay(run.ID, day+1); err != nil { return err } for _, m := range members { _ = p.SendDM(m.UserID, renderCoopFundingPrompt(run, day+1)) } freshRun, _ = loadCoopRun(run.ID) if freshRun != nil { p.coopGenerateAndPostEvent(freshRun, members, day+1) } _ = setCoopRunLastResolvedDay(run.ID, day) return nil } // coopTallyVote counts votes; majority wins. Ties resolve to the leader's // vote if it's among the tied options; otherwise the lexicographically first // tied label (deterministic, since Go map iteration is randomized). func coopTallyVote(event *CoopEvent, leader id.UserID) string { tally := map[string]int{} for _, v := range event.Votes { tally[v]++ } bestCount := -1 var winners []string for label, n := range tally { if n > bestCount { bestCount = n winners = []string{label} } else if n == bestCount { winners = append(winners, label) } } if len(winners) == 1 { return winners[0] } sort.Strings(winners) if leaderVote, ok := event.Votes[leader]; ok { for _, w := range winners { if w == leaderVote { return w } } } if len(winners) > 0 { return winners[0] } return "A" } func (p *AdventurePlugin) coopDistributeReward(run *CoopRun, totalReward int, members []CoopMember) { if len(members) == 0 || totalReward <= 0 { return } share := totalReward / len(members) gr := gamesRoom() var lines []string for _, m := range members { // Atomic claim: only credit if this is the first time we're paying // this member for this run. Skip silently on retry. claimed, err := claimCoopMemberPayout(run.ID, m.UserID, share) if err != nil || !claimed { continue } net, _ := communityTax(m.UserID, float64(share), coopAdventureRake) p.euro.Credit(m.UserID, net, "coop_dungeon_reward") lines = append(lines, fmt.Sprintf(" %s: €%.0f (after tax)", coopDisplayName(p, m.UserID), net)) _ = p.SendDM(m.UserID, fmt.Sprintf("⚔️ Co-op #%d cleared. Your share: €%.0f.", run.ID, net)) } // Generate item drops and distribute via weighted roll. itemSummary := p.coopDistributeItems(run, members) bettingSummary := p.coopResolveBets(run, true) giftLog := renderCoopGiftLog(p, run.ID) if gr != "" { msg := fmt.Sprintf("⚔️ Co-op #%d cleared. Reward pool: €%d.\n\n%s", run.ID, totalReward, strings.Join(lines, "\n")) if itemSummary != "" { msg += "\n\n" + itemSummary } if bettingSummary != "" { msg += "\n\n" + bettingSummary } if giftLog != "" { msg += "\n\n" + giftLog } _ = p.SendMessage(gr, msg) } } // coopDistributeItems generates item drops for a successful run and assigns // each via weighted roll across party members (weight = total_contributed). // Items are added to the winner's inventory; returns a summary line. func (p *AdventurePlugin) coopDistributeItems(run *CoopRun, members []CoopMember) string { loc := findAdvLocationByTier(AdvActivityDungeon, run.Tier) if loc == nil { return "" } // Item count scales with tier: T1 = 2, T5 = 6. count := 2 + (run.Tier - 1) var allItems []AdvItem for i := 0; i < count; i++ { allItems = append(allItems, generateAdvLoot(loc, false, 0)...) } if len(allItems) == 0 { return "" } totalWeight := 0 for _, m := range members { totalWeight += m.TotalContributed } var lines []string for _, item := range allItems { winner := coopWeightedItemWinner(members, totalWeight) _ = addAdvInventoryItem(winner, item) lines = append(lines, fmt.Sprintf(" %s (€%d) → %s", item.Name, item.Value, coopDisplayName(p, winner))) } if mwLine := p.coopMaybeMasterwork(run, members, totalWeight); mwLine != "" { lines = append(lines, mwLine) } return "Item drops:\n" + strings.Join(lines, "\n") } // coopMaybeMasterwork grants a tier-appropriate masterwork drop on T4 (25%) // or T5 (guaranteed). Picks a random masterwork def at the run's tier from // the existing pool (mining sword / fishing armor / foraging boots), awards // it via the same weighted roll, and announces in the game room. // // Returns a one-line summary to splice into the completion post, or "". func (p *AdventurePlugin) coopMaybeMasterwork(run *CoopRun, members []CoopMember, totalWeight int) string { if run.Tier < 4 { return "" } if run.Tier == 4 && rand.Float64() >= 0.25 { return "" } // Collect all masterwork defs at the run's tier. candidates := []MasterworkDef{} for _, def := range masterworkDefs { if def.Tier == run.Tier { candidates = append(candidates, def) } } if len(candidates) == 0 { return "" } def := candidates[rand.IntN(len(candidates))] winner := coopWeightedItemWinner(members, totalWeight) if winner == "" { return "" } // Add to inventory as MasterworkGear (don't auto-equip — let the winner // choose via !adventure equip; their existing gear may be better). item := AdvItem{ Name: def.Name, Type: "MasterworkGear", Tier: def.Tier, Value: 0, Slot: def.Slot, SkillSource: def.SkillSource, } if err := addAdvInventoryItem(winner, item); err != nil { slog.Error("coop: masterwork inventory add", "winner", winner, "err", err) return "" } char, _ := loadAdvCharacter(winner) if char != nil { char.MasterworkDropsReceived++ _ = saveAdvCharacter(char) } dmText := fmt.Sprintf("⭐ **Co-op Masterwork Drop: %s** (T%d %s)\n\n_%s_\n\nMasterwork %s — 1.25x effectiveness, +5%% %s success.\n\nAdded to inventory. `!adventure equip` to use it.", def.Name, def.Tier, slotTitle(def.Slot), def.Description, slotTitle(def.Slot), def.SkillSource) _ = p.SendDM(winner, dmText) return fmt.Sprintf("⭐ Masterwork: %s (T%d %s) → %s", def.Name, def.Tier, slotTitle(def.Slot), coopDisplayName(p, winner)) } // coopWeightedItemWinner picks a member with probability proportional to their // total funding contribution. If no one has contributed (e.g., all None on // every day), falls back to uniform random. func coopWeightedItemWinner(members []CoopMember, totalWeight int) id.UserID { if len(members) == 0 { return "" } if totalWeight <= 0 { return members[rand.IntN(len(members))].UserID } r := rand.IntN(totalWeight) cum := 0 for _, m := range members { cum += m.TotalContributed if r < cum { return m.UserID } } return members[len(members)-1].UserID }