package plugin import ( "fmt" "log/slog" "strconv" "strings" "time" "maunium.net/go/mautrix/id" ) // ── Balance Constants ─────────────────────────────────────────────────────── // // Phase 1 values. Adjust during balance pass. Co-op should feel meaningfully // harder than solo and reward groups for showing up. type CoopFundingTier string const ( CoopFundNone CoopFundingTier = "none" CoopFundMinimal CoopFundingTier = "minimal" CoopFundStandard CoopFundingTier = "standard" CoopFundAggressive CoopFundingTier = "aggressive" CoopFundAllIn CoopFundingTier = "all_in" ) type coopFundingDef struct { cost int modifier int // % added to per-floor success roll label string } var coopFundingTable = map[CoopFundingTier]coopFundingDef{ CoopFundNone: {0, -10, "None"}, CoopFundMinimal: {500, 0, "Minimal"}, CoopFundStandard: {1500, 8, "Standard"}, CoopFundAggressive: {4000, 18, "Aggressive"}, CoopFundAllIn: {10000, 30, "All In"}, } // fundingTierOrder for menu display. var coopFundingOrder = []CoopFundingTier{ CoopFundMinimal, CoopFundStandard, CoopFundAggressive, CoopFundAllIn, } type coopTierDef struct { totalDays int baseFailurePct int // base % failure per floor at zero modifier rewardBase int // base reward (gold) before split difficulty string // label minLevel int // newbie liability threshold lootMult float64 // for display only } // Reward bases tuned via Monte Carlo (coop_dungeon_balance_test.go) so the // optimal funding strategy walks up tiers: T1-T2 Minimal/Standard, T3 Standard, // T4 Standard or Aggressive, T5 Aggressive only. All-In stays -EV at every // tier — it's the boost lever for liability parties, not the optimal play. var coopTierTable = map[int]coopTierDef{ 1: {2, 25, 22500, "Moderate", 5, 2.0}, 2: {3, 32, 40000, "Hard", 12, 3.5}, 3: {4, 40, 72500, "Very Hard", 20, 5.5}, 4: {5, 48, 120000, "Brutal", 30, 8.0}, 5: {7, 58, 600000, "Murderous", 40, 12.0}, } const ( coopMinPartySize = 2 coopMaxPartySize = 4 coopInviteWindow = 24 * time.Hour coopLiabilityCap = 8 // liability players' funding modifier capped at +8% regardless of tier coopAdventureRake = 0.05 ) // ── Command Dispatch ──────────────────────────────────────────────────────── func (p *AdventurePlugin) handleCoopCmd(ctx MessageContext) error { args := strings.TrimSpace(p.GetArgs(ctx.Body, "coop")) lower := strings.ToLower(args) switch { case args == "" || lower == "help": return p.SendDM(ctx.Sender, coopHelpText) case lower == "list": return p.handleCoopList(ctx) case lower == "status": return p.handleCoopStatus(ctx) case lower == "stats": return p.SendDM(ctx.Sender, renderCoopStats()) case strings.HasPrefix(lower, "start "): return p.handleCoopStart(ctx, strings.TrimSpace(args[6:])) case strings.HasPrefix(lower, "join"): return p.handleCoopJoin(ctx, strings.TrimSpace(strings.TrimPrefix(args, "join"))) case strings.HasPrefix(lower, "fund "): return p.handleCoopFund(ctx, strings.TrimSpace(args[5:])) case lower == "cancel": return p.handleCoopCancel(ctx) case strings.HasPrefix(lower, "vote "): return p.handleCoopVote(ctx, strings.TrimSpace(args[5:])) case strings.HasPrefix(lower, "bet "): return p.handleCoopBet(ctx, strings.TrimSpace(args[4:])) case strings.HasPrefix(lower, "gift "): return p.handleCoopGift(ctx, strings.TrimSpace(args[5:])) case strings.HasPrefix(lower, "giftvote "): return p.handleCoopGiftVote(ctx, strings.TrimSpace(args[9:])) case strings.HasPrefix(lower, "admgift "): return p.handleCoopAdmGift(ctx, strings.TrimSpace(args[8:])) } return p.SendDM(ctx.Sender, "Unknown coop command. Try `!coop help`.") } const coopHelpText = `**Co-op Dungeon Commands** ` + "`!coop list`" + ` — Show open invites ` + "`!coop start `" + ` — Open an invite for a co-op dungeon (tier 1-5) ` + "`!coop join []`" + ` — Join an open invite (defaults to most recent) ` + "`!coop fund `" + ` — Set today's funding (none/minimal/standard/aggressive/all_in) ` + "`!coop vote `" + ` — Vote on the day's floor event ` + "`!coop bet `" + ` — Spectator bet (parimutuel, 10% rake) ` + "`!coop gift `" + ` — Send a gift (1 harvest action) ` + "`!coop giftvote `" + ` — Party votes whether to open a gift ` + "`!coop status`" + ` — Show your current run ` + "`!coop stats`" + ` — Aggregate stats: outcomes, gold flow, betting, gifts, TwinBee helpfulness ` + "`!coop cancel`" + ` — Cancel an open invite (leader only, before lock) **How it runs:** - 24h invite window. Run locks automatically when window closes. - 2-4 players. 2 minimum or invite cancels. - Locking the party consumes the day's combat action for every member. - Each subsequent day, every player picks a funding tier. Funding modifies the party's per-floor success chance. - Inactive players (no funding decision) auto-play at None (-10%). - Wipes refund the day's combat action only. Funding is gone. - On success, the reward is split evenly. Tax applies.` // ── Handlers ──────────────────────────────────────────────────────────────── func (p *AdventurePlugin) handleCoopStart(ctx MessageContext, tierArg string) error { tier, err := strconv.Atoi(tierArg) if err != nil || tier < 1 || tier > 5 { return p.SendDM(ctx.Sender, "Tier must be 1-5. Example: `!coop start 3`.") } userMu := p.advUserLock(ctx.Sender) userMu.Lock() defer userMu.Unlock() char, _, err := p.ensureCharacter(ctx.Sender) if err != nil { return p.SendDM(ctx.Sender, "Failed to load your character.") } if !char.Alive { return p.SendDM(ctx.Sender, "You're dead. Co-op dungeons require a living adventurer.") } if !char.CanDoCombat(false) { return p.SendDM(ctx.Sender, "You've already used your combat action today. Try tomorrow.") } // One active run per player. if existing, _ := loadCoopRunForUser(ctx.Sender); existing != nil { return p.SendDM(ctx.Sender, fmt.Sprintf("You're already in a co-op run (#%d, %s). Use `!coop status`.", existing.ID, existing.Status)) } def := coopTierTable[tier] run, err := createCoopRun(tier, ctx.Sender, def.totalDays, def.baseFailurePct) if err != nil { slog.Error("coop: create run", "err", err) return p.SendDM(ctx.Sender, "Couldn't open an invite. Try again.") } if err := addCoopMember(run.ID, ctx.Sender, 0, char.CombatLevel < def.minLevel); err != nil { slog.Error("coop: add leader", "err", err) return p.SendDM(ctx.Sender, "Couldn't add you to the run.") } gr := gamesRoom() if gr != "" { postID, err := p.SendMessageID(gr, renderCoopInvite(run, []CoopMember{{UserID: ctx.Sender, TurnOrder: 0}}, char.DisplayName)) if err == nil { _ = setCoopRunInvitePostID(run.ID, postID) _ = p.PinEvent(gr, postID) } } return p.SendDM(ctx.Sender, fmt.Sprintf("⚔️ Tier %d co-op invite opened (#%d). Locks in 24h. Recruit your party.", tier, run.ID)) } func (p *AdventurePlugin) handleCoopJoin(ctx MessageContext, runIDArg string) error { userMu := p.advUserLock(ctx.Sender) userMu.Lock() defer userMu.Unlock() char, _, err := p.ensureCharacter(ctx.Sender) if err != nil { return p.SendDM(ctx.Sender, "Failed to load your character.") } if !char.Alive { return p.SendDM(ctx.Sender, "You're dead. The dungeon does not accept corpses.") } if !char.CanDoCombat(false) { return p.SendDM(ctx.Sender, "You've already used your combat action today. Try tomorrow.") } if existing, _ := loadCoopRunForUser(ctx.Sender); existing != nil { return p.SendDM(ctx.Sender, fmt.Sprintf("You're already in run #%d.", existing.ID)) } var run *CoopRun if runIDArg == "" { run, err = loadMostRecentOpenCoopRun() } else { id, perr := strconv.Atoi(runIDArg) if perr != nil { return p.SendDM(ctx.Sender, "Run ID must be a number. Try `!coop join 7` or `!coop join`.") } run, err = loadCoopRun(id) } if err != nil || run == nil { return p.SendDM(ctx.Sender, "No open invite found. Use `!coop list` to see open runs.") } if run.Status != "open" { return p.SendDM(ctx.Sender, fmt.Sprintf("Run #%d is %s. Can't join.", run.ID, run.Status)) } members, err := loadCoopMembers(run.ID) if err != nil { return p.SendDM(ctx.Sender, "Couldn't load the party.") } if len(members) >= coopMaxPartySize { return p.SendDM(ctx.Sender, fmt.Sprintf("Run #%d is full (%d/%d).", run.ID, len(members), coopMaxPartySize)) } def := coopTierTable[run.Tier] liability := char.CombatLevel < def.minLevel if err := addCoopMember(run.ID, ctx.Sender, len(members), liability); err != nil { slog.Error("coop: join", "err", err) return p.SendDM(ctx.Sender, "Couldn't join.") } gr := gamesRoom() if gr != "" { warn := "" if liability { warn = " ⚠️ liability" } _ = p.SendMessage(gr, fmt.Sprintf("⚔️ %s joined Tier %d Co-op #%d (%d/%d)%s.", char.DisplayName, run.Tier, run.ID, len(members)+1, coopMaxPartySize, warn)) } return p.SendDM(ctx.Sender, fmt.Sprintf("Joined run #%d. Wait for the lock; the run starts then.", run.ID)) } func (p *AdventurePlugin) handleCoopList(ctx MessageContext) error { runs, err := loadOpenCoopRuns() if err != nil { return p.SendDM(ctx.Sender, "Couldn't load the list.") } if len(runs) == 0 { return p.SendDM(ctx.Sender, "No open invites right now. Start one with `!coop start `.") } var sb strings.Builder sb.WriteString("**Open Co-op Invites**\n\n") for _, r := range runs { members, _ := loadCoopMembers(r.ID) closesIn := time.Until(r.CreatedAt.Add(coopInviteWindow)).Truncate(time.Minute) sb.WriteString(fmt.Sprintf(" #%d T%d (%s) %d/%d closes in %s\n", r.ID, r.Tier, coopTierTable[r.Tier].difficulty, len(members), coopMaxPartySize, closesIn)) } sb.WriteString("\nJoin with `!coop join ` or just `!coop join` for the most recent.") return p.SendDM(ctx.Sender, sb.String()) } func (p *AdventurePlugin) handleCoopStatus(ctx MessageContext) error { run, err := loadCoopRunForUser(ctx.Sender) if err != nil || run == nil { return p.SendDM(ctx.Sender, "You're not in a co-op run. Use `!coop list` or `!coop start `.") } members, _ := loadCoopMembers(run.ID) return p.SendDM(ctx.Sender, renderCoopStatus(p, run, members)) } func (p *AdventurePlugin) handleCoopFund(ctx MessageContext, tierArg string) error { tier := parseCoopFundingTier(tierArg) if tier == "" { return p.SendDM(ctx.Sender, "Funding must be one of: none, minimal, standard, aggressive, all_in.") } userMu := p.advUserLock(ctx.Sender) userMu.Lock() defer userMu.Unlock() run, err := loadCoopRunForUser(ctx.Sender) if err != nil || run == nil { return p.SendDM(ctx.Sender, "You're not in an active co-op run.") } if run.Status != "active" { return p.SendDM(ctx.Sender, fmt.Sprintf("Run #%d is %s — funding decisions don't apply.", run.ID, run.Status)) } day := run.CurrentDay if day < 1 { return p.SendDM(ctx.Sender, "The run hasn't started yet. Wait for the lock tick.") } member, err := loadCoopMember(run.ID, ctx.Sender) if err != nil || member == nil { return p.SendDM(ctx.Sender, "You're not on the party roster for this run.") } if _, alreadySet := member.DailyFunding[day]; alreadySet { return p.SendDM(ctx.Sender, fmt.Sprintf("You've already locked in funding for day %d. Decisions are final until the daily tick.", day)) } cost := coopFundingTable[tier].cost if cost > 0 { balance := p.euro.GetBalance(ctx.Sender) if balance < float64(cost) { return p.SendDM(ctx.Sender, fmt.Sprintf("That tier costs €%d. You have €%.0f.", cost, balance)) } if !p.euro.Debit(ctx.Sender, float64(cost), "coop_funding") { return p.SendDM(ctx.Sender, "Payment failed.") } } member.DailyFunding[day] = tier member.TotalContributed += cost if err := saveCoopMemberFunding(member); err != nil { // Refund and surface if cost > 0 { p.euro.Credit(ctx.Sender, float64(cost), "coop_funding_refund") } return p.SendDM(ctx.Sender, "Couldn't save your decision. Gold refunded if any was charged.") } if cost > 0 { if err := addCoopRunPool(run.ID, cost); err != nil { slog.Error("coop: pool update", "err", err) } } mod := coopFundingTable[tier].modifier return p.SendDM(ctx.Sender, fmt.Sprintf("Funding for day %d locked: %s (€%d, %+d%%). Tomorrow's tick resolves the floor.", day, coopFundingTable[tier].label, cost, mod)) } func (p *AdventurePlugin) handleCoopCancel(ctx MessageContext) error { userMu := p.advUserLock(ctx.Sender) userMu.Lock() defer userMu.Unlock() run, err := loadCoopRunForUser(ctx.Sender) if err != nil || run == nil { return p.SendDM(ctx.Sender, "No co-op run to cancel.") } if run.LeaderID != ctx.Sender { return p.SendDM(ctx.Sender, "Only the party leader can cancel.") } if run.Status != "open" { return p.SendDM(ctx.Sender, fmt.Sprintf("Run #%d already %s — too late.", run.ID, run.Status)) } if err := setCoopRunStatus(run.ID, "cancelled"); err != nil { return p.SendDM(ctx.Sender, "Couldn't cancel.") } gr := gamesRoom() if gr != "" { if run.InvitePostID != "" { _ = p.UnpinEvent(gr, run.InvitePostID) } _ = p.SendMessage(gr, fmt.Sprintf("⚔️ Tier %d Co-op #%d cancelled by the leader.", run.Tier, run.ID)) } return p.SendDM(ctx.Sender, fmt.Sprintf("Run #%d cancelled.", run.ID)) } // handleCoopAdmGift is an admin-only debug path that creates a gift on // behalf of the caller (or a specified sender) without the harvest-action // or party-membership checks. Useful for testing the gift mechanic without // recruiting a non-party spectator. // // Usage: !coop admgift [sender_user_id] func (p *AdventurePlugin) handleCoopAdmGift(ctx MessageContext, args string) error { if !p.IsAdmin(ctx.Sender) { return nil } parts := strings.Fields(args) if len(parts) < 2 { return p.SendDM(ctx.Sender, "Usage: `!coop admgift [sender_user_id]`. Admin-only — bypasses party-member and harvest checks.") } runID, err := strconv.Atoi(parts[0]) if err != nil { return p.SendDM(ctx.Sender, "Run ID must be a number.") } giftType := strings.ToLower(parts[1]) if giftType != coopGiftBasket && giftType != coopGiftMimic { return p.SendDM(ctx.Sender, "Gift type must be `basket` or `mimic`.") } sender := ctx.Sender if len(parts) >= 3 { sender = id.UserID(parts[2]) } run, err := loadCoopRun(runID) if err != nil || run == nil { return p.SendDM(ctx.Sender, "Run not found.") } if run.Status != "active" { return p.SendDM(ctx.Sender, fmt.Sprintf("Run #%d is %s — gifts only land during active runs.", runID, run.Status)) } giftID, leadID, isNewLead, err := createCoopGift(runID, sender, run.CurrentDay, giftType) if err != nil { return p.SendDM(ctx.Sender, "Couldn't create gift.") } gr := gamesRoom() if gr != "" { members, _ := loadCoopMembers(runID) if isNewLead { gift, _ := loadCoopGift(giftID) postID, perr := p.SendMessageID(gr, renderCoopGiftPost(p, run, gift, members)) if perr == nil { _ = saveCoopGiftPostID(giftID, postID) } } else { lead, _ := loadCoopGift(leadID) if lead != nil && lead.PostEventID != "" { _ = p.EditMessage(gr, lead.PostEventID, renderCoopGiftPost(p, run, lead, members)) } } } return p.SendDM(ctx.Sender, fmt.Sprintf("📦 Admin gift dispatched: %s from %s to run #%d (day %d). Gift id #%d (stack lead %d). No harvest action consumed.", giftType, sender, runID, run.CurrentDay, giftID, leadID)) } func (p *AdventurePlugin) handleCoopVote(ctx MessageContext, voteArg string) error { userMu := p.advUserLock(ctx.Sender) userMu.Lock() defer userMu.Unlock() run, err := loadCoopRunForUser(ctx.Sender) if err != nil || run == nil { return p.SendDM(ctx.Sender, "You're not in an active co-op run.") } if run.Status != "active" { return p.SendDM(ctx.Sender, "No active floor event to vote on.") } event, err := loadCoopEvent(run.ID, run.CurrentDay) if err != nil || event == nil { return p.SendDM(ctx.Sender, "No floor event found for the current day.") } if event.Outcome != "" { return p.SendDM(ctx.Sender, "Voting closed for this floor.") } label, ok := validateCoopVote(event.Category, event.EventIndex, voteArg) if !ok { return p.SendDM(ctx.Sender, fmt.Sprintf("Vote one of: %s.", strings.Join(coopEventOptionLabels(event.Category, event.EventIndex), ", "))) } if event.Votes == nil { event.Votes = map[id.UserID]string{} } event.Votes[ctx.Sender] = label if err := saveCoopEventVotes(event); err != nil { return p.SendDM(ctx.Sender, "Couldn't save your vote.") } // Update the game-room post in place to show the running tally. gr := gamesRoom() if gr != "" && event.PostEventID != "" { members, _ := loadCoopMembers(run.ID) _ = p.EditMessage(gr, event.PostEventID, renderCoopEventPost(run, members, event)) } return p.SendDM(ctx.Sender, fmt.Sprintf("Vote recorded: %s.", label)) } // ── Helpers ───────────────────────────────────────────────────────────────── func parseCoopFundingTier(s string) CoopFundingTier { s = strings.ToLower(strings.TrimSpace(s)) s = strings.ReplaceAll(s, "-", "_") s = strings.ReplaceAll(s, " ", "_") switch s { case "none", "skip", "pass": return CoopFundNone case "minimal", "min": return CoopFundMinimal case "standard", "std": return CoopFundStandard case "aggressive", "agg": return CoopFundAggressive case "all_in", "allin", "all": return CoopFundAllIn } return "" } // effectiveModifier returns a member's per-day funding modifier, respecting the // liability cap. func coopMemberModifier(m *CoopMember, day int) (CoopFundingTier, int) { tier, ok := m.DailyFunding[day] if !ok { tier = CoopFundNone } mod := coopFundingTable[tier].modifier if m.IsLiability && mod > coopLiabilityCap { mod = coopLiabilityCap } return tier, mod } // coopLevelBonus returns the per-floor success bonus from a player's combat // level relative to the tier's minimum. Levels above the floor count up // (capped); levels below count down. Encourages tier-appropriate parties. func coopLevelBonus(combatLevel, tierMinLevel int) int { delta := combatLevel - tierMinLevel bonus := delta * 4 / 10 // 0.4× scale, integer math if bonus > 8 { bonus = 8 } if bonus < -8 { bonus = -8 } return bonus } // coopPetBonus returns the per-floor success bonus from a player's pet. Worth // roughly a quarter of an additional baseline party member at pet level 10. // Returns 0 if the player has no pet. func coopPetBonus(char *AdventureCharacter) int { if !char.HasPet() || char.PetLevel <= 0 { return 0 } bonus := char.PetLevel * 25 / 100 // 0.25× scale if bonus > 2 { bonus = 2 } return bonus }