Files
gogobee/internal/plugin/coop_dungeon_render.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

221 lines
7.2 KiB
Go

package plugin
import (
"fmt"
"math/rand/v2"
"strings"
"time"
"maunium.net/go/mautrix/id"
)
func coopDisplayName(p *AdventurePlugin, userID id.UserID) string {
char, err := loadAdvCharacter(userID)
if err == nil && char.DisplayName != "" {
return char.DisplayName
}
if p != nil {
return p.DisplayName(userID)
}
return string(userID)
}
func renderCoopInvite(run *CoopRun, members []CoopMember, leaderName string) string {
def := coopTierTable[run.Tier]
closes := run.CreatedAt.Add(coopInviteWindow).Format("Mon 15:04 UTC")
return fmt.Sprintf(
"⚔️ %s is opening a Co-op Dungeon — **Tier %d (%s)**, run #%d.\n"+
"Days: %d. Reward pool base: €%d (split among the party).\n"+
"Party: %d/%d. Type `!coop join %d` to enter.\n"+
"Locks at %s. Newbies are a liability — you knew that going in.",
leaderName, run.Tier, def.difficulty, run.ID,
def.totalDays, def.rewardBase,
len(members), coopMaxPartySize, run.ID, closes)
}
func renderCoopLock(run *CoopRun, members []CoopMember, p *AdventurePlugin) string {
var sb strings.Builder
def := coopTierTable[run.Tier]
sb.WriteString(fmt.Sprintf("⚔️ Tier %d Co-op #%d is locked.\n", run.Tier, run.ID))
sb.WriteString(fmt.Sprintf("Difficulty: %s. Days: %d. Reward pool: €%d.\n\nParty (turn order):\n",
def.difficulty, run.TotalDays, def.rewardBase))
for _, m := range members {
warn := ""
if m.IsLiability {
warn = " ⚠️ liability"
}
sb.WriteString(fmt.Sprintf(" %d. %s%s\n", m.TurnOrder+1, coopDisplayName(p, m.UserID), warn))
}
sb.WriteString("\nDay 1 begins now. Each member has until the next daily tick to fund. Inactive = None (-10%).")
bets, _ := loadCoopBets(run.ID)
sb.WriteString("\n\n")
sb.WriteString(renderCoopOddsLine(run, members, bets))
return sb.String()
}
func renderCoopFundingPrompt(run *CoopRun, day int) string {
var sb strings.Builder
sb.WriteString(fmt.Sprintf("⚔️ Co-op #%d — Day %d/%d. Pick your funding tier with `!coop fund <tier>`.\n\n",
run.ID, day, run.TotalDays))
for _, t := range coopFundingOrder {
def := coopFundingTable[t]
sb.WriteString(fmt.Sprintf(" • `%s` — €%d, %+d%%\n", t, def.cost, def.modifier))
}
sb.WriteString("\nNo decision before the next daily tick = auto-played as None (-10%). Funding is non-refundable.")
return sb.String()
}
func renderCoopDailyResult(p *AdventurePlugin, run *CoopRun, members []CoopMember, day int,
choices map[string]CoopFundingTier, successPct int, won bool, autoPlayed []*CoopMember,
event *CoopEvent, winning string, eventMod int) string {
var sb strings.Builder
def := coopTierTable[run.Tier]
header := "✅ Floor cleared"
if !won {
header = "💥 Wipe"
}
if won && day >= run.TotalDays {
header = "🏆 Final floor cleared"
}
sb.WriteString(fmt.Sprintf("⚔️ Co-op #%d — Tier %d (%s), Day %d/%d\n",
run.ID, run.Tier, def.difficulty, day, run.TotalDays))
sb.WriteString(fmt.Sprintf("Success chance: %d%%. %s.\n", successPct, header))
if event != nil {
sb.WriteString(fmt.Sprintf("Floor event: %s. Party voted **%s** (%+d%%).\n",
titleCaseWord(string(event.Category)), winning, eventMod))
}
sb.WriteString("\nFunding:\n")
for _, m := range members {
t := choices[string(m.UserID)]
fdef := coopFundingTable[t]
auto := ""
for _, ap := range autoPlayed {
if ap.UserID == m.UserID {
auto = " ← auto-played"
break
}
}
sb.WriteString(fmt.Sprintf(" %s: %s (%+d%%)%s\n",
coopDisplayName(p, m.UserID), fdef.label, fdef.modifier, auto))
}
if event != nil {
sb.WriteString("\n_TwinBee:_ ")
sb.WriteString(coopTwinBeeReaction(event, winning, won))
}
if !won {
sb.WriteString("\n\nThe dungeon does not editorialize. Combat actions for today are refunded if any. Funding is gone.")
} else if day < run.TotalDays {
sb.WriteString(fmt.Sprintf("\n\nNext floor unlocks at the next daily tick. %d day(s) remain.", run.TotalDays-day))
bets, _ := loadCoopBets(run.ID)
sb.WriteString("\n")
sb.WriteString(renderCoopOddsLine(run, members, bets))
}
return sb.String()
}
// coopTwinBeeReaction picks an outcome line based on whether his recommendation
// matched the winning vote and whether the floor succeeded.
func coopTwinBeeReaction(event *CoopEvent, winning string, won bool) string {
pickFrom := func(pool []string) string {
if len(pool) == 0 {
return ""
}
return pool[rand.IntN(len(pool))]
}
switch {
case event.Recommended == winning && won:
return pickFrom(TwinBeeOutcomeCorrect)
case event.Recommended == winning && !won:
return pickFrom(TwinBeeOutcomeWrong)
case event.Recommended != winning && won:
return pickFrom(TwinBeeOutcomeNotRecommendedGood)
default:
return pickFrom(TwinBeeOutcomeNotRecommendedBad)
}
}
// renderCoopEventPost is the game-room post for a floor event vote prompt.
// Edited in place as votes come in.
func renderCoopEventPost(run *CoopRun, members []CoopMember, event *CoopEvent) string {
var sb strings.Builder
flavorPool := coopEventCategoryFlavor(event.Category)
flavor := ""
if event.EventIndex < len(flavorPool) {
flavor = flavorPool[event.EventIndex]
}
sb.WriteString(fmt.Sprintf("🗺️ **Co-op #%d — Day %d/%d, %s event**\n\n",
run.ID, event.Day, run.TotalDays, titleCaseWord(string(event.Category))))
sb.WriteString(flavor)
sb.WriteString("\n\nVote with `!coop vote A` (or B/C). 24h or until next tick. Ties go to the leader.\n\n")
tally := map[string]int{}
for _, v := range event.Votes {
tally[v]++
}
parts := []string{}
for _, label := range coopEventOptionLabels(event.Category, event.EventIndex) {
parts = append(parts, fmt.Sprintf("%s (%d)", label, tally[label]))
}
sb.WriteString("Current votes: ")
sb.WriteString(strings.Join(parts, " · "))
abstained := []string{}
for _, m := range members {
if _, voted := event.Votes[m.UserID]; !voted {
abstained = append(abstained, string(m.UserID))
}
}
if len(abstained) > 0 && len(abstained) < len(members) {
sb.WriteString(fmt.Sprintf("\nAwaiting: %d of %d", len(abstained), len(members)))
}
return sb.String()
}
func titleCaseWord(s string) string {
if s == "" {
return s
}
return strings.ToUpper(s[:1]) + s[1:]
}
func renderCoopStatus(p *AdventurePlugin, run *CoopRun, members []CoopMember) string {
var sb strings.Builder
def := coopTierTable[run.Tier]
sb.WriteString(fmt.Sprintf("⚔️ Co-op #%d — Tier %d (%s)\n", run.ID, run.Tier, def.difficulty))
sb.WriteString(fmt.Sprintf("Status: %s. Day %d/%d. Pool: €%d.\n\nParty:\n",
run.Status, run.CurrentDay, run.TotalDays, run.GoldPool))
for _, m := range members {
warn := ""
if m.IsLiability {
warn = " ⚠️"
}
todayLabel := "—"
if run.CurrentDay >= 1 {
if t, ok := m.DailyFunding[run.CurrentDay]; ok {
todayLabel = coopFundingTable[t].label
} else {
todayLabel = "(unset)"
}
}
sb.WriteString(fmt.Sprintf(" %d. %s%s — today: %s, contributed: €%d\n",
m.TurnOrder+1, coopDisplayName(p, m.UserID), warn, todayLabel, m.TotalContributed))
}
if run.Status == "open" {
closes := run.CreatedAt.Add(coopInviteWindow)
sb.WriteString(fmt.Sprintf("\nLocks in: %s.", time.Until(closes).Truncate(time.Minute)))
}
if run.Status == "open" || run.Status == "active" {
bets, _ := loadCoopBets(run.ID)
sb.WriteString("\n")
sb.WriteString(renderCoopOddsLine(run, members, bets))
}
return sb.String()
}