Files
gogobee/internal/plugin/coop_dungeon_scheduler.go
prosolis 9f094549b7 Coop: per-gift voting timers (6h) — gifts resolve throughout the day
Replace the single daily-tick gift resolution with independent per-gift
expiries. Each gift now has its own 6h voting window; once that elapses,
the votes are tallied, the sender gets DM feedback, and the live game-room
post is edited to reveal the type and resolved modifier. The modifier
sits on the run waiting for the next floor resolution to merge it in.

Effect:
- Gifts fire continuously throughout the day rather than all at once at
  08:00 UTC, surfacing sender activity in real time.
- Senders get fast feedback (~6h instead of waiting for the next daily
  tick).
- Floor resolution becomes purely "sum pending modifiers from already-
  resolved gifts" — cleaner separation of concerns.
- Atomic per-gift apply via markCoopGiftApplied (UPDATE...WHERE applied_at
  IS NULL) prevents double-application on retry.

Schema additions:
- coop_dungeon_gifts.expires_at (when voting closes)
- coop_dungeon_gifts.applied_at (when modifier merged into a floor)
- Migration entries provided.

Defensive backstop: floor resolution still force-resolves any gifts whose
expiry was missed (e.g., bot down during the timer window).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 17:55:14 -07:00

559 lines
17 KiB
Go

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)
_ = 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)
_ = 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
}