Files
gogobee/internal/plugin/coop_dungeon_scheduler.go
prosolis 8ad31a0009 Add co-op dungeon system (party runs, voting, betting, gifts, items)
Multi-day party runs with funding decisions, TwinBee-narrated floor events,
spectator parimutuel betting, basket/mimic gift system, and weighted-roll
item distribution including masterwork drops at T4-T5.

Schema (5 tables, 2 fewer than spec by computing helpfulness on-the-fly and
skipping the loot-pending state):
- coop_dungeon_runs / _members (daily funding as JSON column)
- coop_dungeon_events (votes as JSON, used to derive TwinBee helpfulness)
- coop_dungeon_bets / _gifts

Mechanics:
- 2-4 player parties, 24h invite window, locks consume one combat action
- Per-floor success roll: base + sum(funding) + level/pet bonuses + event
  vote modifier + active gift modifiers, clamped to 5..95
- Funding tiers (none/min/std/agg/all_in) with liability cap at +8% for
  under-leveled players
- TwinBee narrates events from existing flavor pool; 20 authored events with
  per-option modifiers and embedded recommendation
- Parimutuel betting with 10% rake; odds line shown on lock/daily/status posts
- Gift modifiers symmetric at 50/50 sender mix so no dominant strategy
- Item drops on success via weight-by-contribution roll; T4 25% / T5 100%
  masterwork chance (random pick from existing T4/T5 defs)

Balance via Monte Carlo (coop_dungeon_balance_test.go):
- All tiers exceed 1.5x solo daily income at average party profile
- Optimal funding strategy walks up tiers correctly (Min/Std/Std/Mixed/Agg)
- All-In stays -EV at every tier (boost lever, not optimal play)

Tests: parsing, liability cap, JSON roundtrips, vote tally with leader
tiebreak, event meta consistency, parimutuel payouts, gift EV symmetry,
weighted-roll distribution (Monte Carlo), masterwork tier gates.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 07:58:13 -07:00

484 lines
14 KiB
Go

package plugin
import (
"fmt"
"log/slog"
"math/rand/v2"
"strings"
"time"
"gogobee/internal/db"
"maunium.net/go/mautrix/id"
)
// coopTicker fires once per UTC day to:
// 1. Lock open invites whose 24h window has elapsed.
// 2. For each active run, resolve the day's floor and advance/complete.
//
// Reuses the daily_prefetch job-completion guard so a bot restart on the same
// UTC day does not double-process.
func (p *AdventurePlugin) coopTicker() {
ticker := time.NewTicker(1 * time.Minute)
defer ticker.Stop()
for range ticker.C {
now := time.Now().UTC()
// Run at the same minute as the morning DM (08:00 UTC) — same cadence as
// the rest of the adventure system.
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")
p.coopProcessLocks()
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 != "" {
_ = 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. Combat action refunded.", 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 != "" {
_ = 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
}
today := time.Now().UTC().Format("2006-01-02")
for _, run := range runs {
// Skip runs locked on this same tick — they need a full day for
// funding decisions and voting before the first floor resolves.
if run.LockedAt != nil && run.LockedAt.UTC().Format("2006-01-02") == today {
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
members, err := loadCoopMembers(run.ID)
if err != nil {
return err
}
// Auto-play any member who didn't fund.
autoPlayed := []*CoopMember{}
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)
}
}
// Compute success modifier: base = (100 - baseDifficulty), adjusted by
// funding, party levels, pets, and the floor event vote.
totalMod := 0
choices := make(map[string]CoopFundingTier, len(members))
tierDef := coopTierTable[run.Tier]
for _, m := range members {
tier, mod := coopMemberModifier(&m, day)
choices[string(m.UserID)] = tier
totalMod += mod
// Level + pet bonuses pulled fresh per-floor; they reflect current
// state (a player may have leveled up mid-run).
char, err := loadAdvCharacter(m.UserID)
if err != nil {
continue
}
totalMod += coopLevelBonus(char.CombatLevel, tierDef.minLevel)
totalMod += coopPetBonus(char)
}
// Tally vote on today's floor event. Ties go to the leader's vote (or
// option A if leader didn't vote either).
event, _ := loadCoopEvent(run.ID, day)
winning := ""
eventMod := 0
if event != nil {
winning = coopTallyVote(event, run.LeaderID)
eventMod = coopEventOptionModifier(event.Category, event.EventIndex, winning)
totalMod += eventMod
}
// Resolve gifts received on this day.
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 the event resolution before posting (so a crash mid-post still
// records what happened for TwinBee helpfulness tracking).
if event != nil {
outcomeStr := "failure"
if floorWon {
outcomeStr = "success"
}
_ = resolveCoopEvent(run.ID, day, winning, outcomeStr, eventMod)
}
gr := gamesRoom()
dailyPost := renderCoopDailyResult(p, run, members, day, choices, successPct, floorWon, autoPlayed, event, winning, eventMod)
if giftSummary != "" {
dailyPost += "\n\n" + giftSummary
}
if gr != "" {
_ = p.SendMessage(gr, dailyPost)
}
if !floorWon {
// Wipe. Combat actions are only consumed at lock (day 1), so only a
// day-1 wipe has anything to refund.
if day == 1 {
now := time.Now().UTC().Format("2006-01-02")
for _, m := range members {
char, err := loadAdvCharacter(m.UserID)
if err != nil {
continue
}
if char.LastActionDate == now && char.CombatActionsUsed > 0 {
char.CombatActionsUsed--
_ = saveAdvCharacter(char)
}
}
}
_ = completeCoopRun(run.ID, "wiped", 0)
// Resolve betting pool — failure side wins on a wipe.
freshRun, _ := loadCoopRun(run.ID)
if freshRun != nil {
summary := p.coopResolveBets(freshRun, false)
giftLog := renderCoopGiftLog(p, run.ID)
parts := []string{}
if summary != "" {
parts = append(parts, summary)
}
if giftLog != "" {
parts = append(parts, giftLog)
}
if len(parts) > 0 {
if gr := gamesRoom(); gr != "" {
_ = p.SendMessage(gr, strings.Join(parts, "\n\n"))
}
}
}
return nil
}
// Floor cleared — advance or complete.
if day >= run.TotalDays {
// Run complete — split reward.
def := coopTierTable[run.Tier]
reward := def.rewardBase
_ = completeCoopRun(run.ID, "complete", reward)
freshRun, _ := loadCoopRun(run.ID)
if freshRun != nil {
p.coopDistributeReward(freshRun, reward, members)
}
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))
}
// Generate next day's floor event.
freshRun, _ := loadCoopRun(run.ID)
if freshRun != nil {
p.coopGenerateAndPostEvent(freshRun, members, day+1)
}
return nil
}
// coopTallyVote counts votes; majority wins. Ties resolve to the leader's
// vote if any, otherwise option A.
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]
}
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 {
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
}