From 9639a862683818ab443e357efe1bee96fc566f39 Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Sun, 26 Apr 2026 09:14:45 -0700 Subject: [PATCH] =?UTF-8?q?Add=20!coop=20stats=20=E2=80=94=20public=20tele?= =?UTF-8?q?metry=20for=20runs,=20gold=20flow,=20betting,=20gifts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aggregates that surface community-level patterns: - Per-tier outcomes table (open/active/won/wiped/cancelled, win%, avg party size) - Gold flow: rewards distributed vs funding forfeited to wipes - Betting: bets placed, distinct bettors, total wagered, paid to winners, estimated house cut - Gift activity: basket/mimic sent, open rates by type - TwinBee helpfulness: last 30 events vs lifetime Public command (no admin gate) so the community can debate strategy with shared context. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/plugin/coop_dungeon.go | 3 + internal/plugin/coop_dungeon_stats.go | 245 ++++++++++++++++++++++++++ 2 files changed, 248 insertions(+) create mode 100644 internal/plugin/coop_dungeon_stats.go diff --git a/internal/plugin/coop_dungeon.go b/internal/plugin/coop_dungeon.go index a46a14c..1e3f92e 100644 --- a/internal/plugin/coop_dungeon.go +++ b/internal/plugin/coop_dungeon.go @@ -86,6 +86,8 @@ func (p *AdventurePlugin) handleCoopCmd(ctx MessageContext) error { 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"): @@ -118,6 +120,7 @@ const coopHelpText = `**Co-op Dungeon Commands** ` + "`!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:** diff --git a/internal/plugin/coop_dungeon_stats.go b/internal/plugin/coop_dungeon_stats.go new file mode 100644 index 0000000..35096f5 --- /dev/null +++ b/internal/plugin/coop_dungeon_stats.go @@ -0,0 +1,245 @@ +package plugin + +import ( + "database/sql" + "fmt" + "strings" + + "gogobee/internal/db" +) + +// ── Stats aggregation ────────────────────────────────────────────────────── + +type coopTierStats struct { + Tier int + Open int + Active int + Complete int + Wiped int + Cancelled int + AvgPartySize float64 + TotalReward int64 // Σ reward_total of completed runs + TotalForfeited int64 // Σ gold_pool of wiped runs (lost to the dungeon) +} + +type coopBetStats struct { + BetsPlaced int + TotalStake int64 + TotalPaid int64 // sum of non-NULL payouts (winners) + BettorsLT int // distinct bettors lifetime +} + +type coopGiftStats struct { + BasketSent int + MimicSent int + BasketOpened int + BasketLeft int + MimicOpened int + MimicLeft int +} + +func loadCoopTierStats() ([]coopTierStats, error) { + d := db.Get() + stats := map[int]*coopTierStats{} + for t := 1; t <= 5; t++ { + stats[t] = &coopTierStats{Tier: t} + } + + // Run outcomes by tier + rows, err := d.Query(`SELECT tier, status, COUNT(*) FROM coop_dungeon_runs + GROUP BY tier, status`) + if err != nil { + return nil, err + } + defer rows.Close() + for rows.Next() { + var tier int + var status string + var n int + if err := rows.Scan(&tier, &status, &n); err != nil { + continue + } + s, ok := stats[tier] + if !ok { + continue + } + switch status { + case "open": + s.Open = n + case "active": + s.Active = n + case "complete": + s.Complete = n + case "wiped": + s.Wiped = n + case "cancelled": + s.Cancelled = n + } + } + + // Avg party size by tier (over locked runs only — open invites with one + // member would bias the number). + rows2, err := d.Query(`SELECT r.tier, AVG(m.cnt) FROM coop_dungeon_runs r + JOIN (SELECT run_id, COUNT(*) cnt FROM coop_dungeon_members GROUP BY run_id) m + ON m.run_id = r.id + WHERE r.status IN ('active','complete','wiped') + GROUP BY r.tier`) + if err == nil { + defer rows2.Close() + for rows2.Next() { + var tier int + var avg sql.NullFloat64 + if err := rows2.Scan(&tier, &avg); err == nil && avg.Valid { + if s, ok := stats[tier]; ok { + s.AvgPartySize = avg.Float64 + } + } + } + } + + // Reward / forfeit totals per tier + rows3, err := d.Query(`SELECT tier, status, SUM(reward_total), SUM(gold_pool) + FROM coop_dungeon_runs WHERE status IN ('complete','wiped') + GROUP BY tier, status`) + if err == nil { + defer rows3.Close() + for rows3.Next() { + var tier int + var status string + var reward, pool sql.NullInt64 + if err := rows3.Scan(&tier, &status, &reward, &pool); err != nil { + continue + } + s, ok := stats[tier] + if !ok { + continue + } + if status == "complete" && reward.Valid { + s.TotalReward += reward.Int64 + } + if status == "wiped" && pool.Valid { + s.TotalForfeited += pool.Int64 + } + } + } + + out := make([]coopTierStats, 0, 5) + for t := 1; t <= 5; t++ { + out = append(out, *stats[t]) + } + return out, nil +} + +func loadCoopBetStats() (coopBetStats, error) { + d := db.Get() + var s coopBetStats + var stake, paid sql.NullInt64 + err := d.QueryRow(`SELECT COUNT(*), COALESCE(SUM(amount),0), + COALESCE(SUM(CASE WHEN payout > 0 THEN payout ELSE 0 END), 0) + FROM coop_dungeon_bets`).Scan(&s.BetsPlaced, &stake, &paid) + if err != nil { + return s, err + } + s.TotalStake = stake.Int64 + s.TotalPaid = paid.Int64 + _ = d.QueryRow(`SELECT COUNT(DISTINCT player_id) FROM coop_dungeon_bets`).Scan(&s.BettorsLT) + return s, nil +} + +func loadCoopGiftStats() (coopGiftStats, error) { + d := db.Get() + var s coopGiftStats + rows, err := d.Query(`SELECT gift_type, COALESCE(vote_result, 'pending'), COUNT(*) + FROM coop_dungeon_gifts GROUP BY gift_type, vote_result`) + if err != nil { + return s, err + } + defer rows.Close() + for rows.Next() { + var giftType, voteResult string + var n int + if err := rows.Scan(&giftType, &voteResult, &n); err != nil { + continue + } + switch giftType { + case coopGiftBasket: + s.BasketSent += n + if voteResult == "opened" { + s.BasketOpened = n + } else if voteResult == "left" { + s.BasketLeft = n + } + case coopGiftMimic: + s.MimicSent += n + if voteResult == "opened" { + s.MimicOpened = n + } else if voteResult == "left" { + s.MimicLeft = n + } + } + } + return s, nil +} + +// ── Render ────────────────────────────────────────────────────────────────── + +func renderCoopStats() string { + tiers, _ := loadCoopTierStats() + bets, _ := loadCoopBetStats() + gifts, _ := loadCoopGiftStats() + help30 := coopTwinBeeHelpfulness(30) + helpAll := coopTwinBeeHelpfulness(10000) // effectively lifetime + + var sb strings.Builder + sb.WriteString("⚔️ **Co-op Dungeon Stats**\n\n") + + sb.WriteString("**Runs by tier**\n") + sb.WriteString("```\n") + sb.WriteString(fmt.Sprintf("%-6s %-6s %-6s %-6s %-6s %-6s %-7s %-12s\n", + "tier", "open", "active", "won", "wiped", "canc", "win%", "avg party")) + for _, t := range tiers { + finished := t.Complete + t.Wiped + winPct := 0.0 + if finished > 0 { + winPct = float64(t.Complete) / float64(finished) * 100 + } + sb.WriteString(fmt.Sprintf("T%-5d %-6d %-6d %-6d %-6d %-6d %-6.1f%% %-12.2f\n", + t.Tier, t.Open, t.Active, t.Complete, t.Wiped, t.Cancelled, winPct, t.AvgPartySize)) + } + sb.WriteString("```\n\n") + + sb.WriteString("**Gold flow**\n") + var totalReward, totalForfeit int64 + for _, t := range tiers { + totalReward += t.TotalReward + totalForfeit += t.TotalForfeited + } + sb.WriteString(fmt.Sprintf(" Rewards distributed: €%d\n", totalReward)) + sb.WriteString(fmt.Sprintf(" Funding forfeited (wipes): €%d\n\n", totalForfeit)) + + sb.WriteString("**Spectator betting**\n") + rake := int64(float64(bets.TotalStake) * coopBetRake) + sb.WriteString(fmt.Sprintf(" Bets placed: %d (by %d distinct bettors)\n", bets.BetsPlaced, bets.BettorsLT)) + sb.WriteString(fmt.Sprintf(" Total wagered: €%d\n", bets.TotalStake)) + sb.WriteString(fmt.Sprintf(" Paid to winners: €%d\n", bets.TotalPaid)) + sb.WriteString(fmt.Sprintf(" House cut (TwinBee): ~€%d\n\n", rake)) + + sb.WriteString("**Gifts**\n") + openRate := func(opened, total int) float64 { + if total == 0 { + return 0 + } + return float64(opened) / float64(total) * 100 + } + sb.WriteString(fmt.Sprintf(" Baskets sent: %d (opened %d, left %d, %.0f%% open rate)\n", + gifts.BasketSent, gifts.BasketOpened, gifts.BasketLeft, + openRate(gifts.BasketOpened, gifts.BasketOpened+gifts.BasketLeft))) + sb.WriteString(fmt.Sprintf(" Mimics sent: %d (opened %d, left %d, %.0f%% open rate)\n\n", + gifts.MimicSent, gifts.MimicOpened, gifts.MimicLeft, + openRate(gifts.MimicOpened, gifts.MimicOpened+gifts.MimicLeft))) + + sb.WriteString("**TwinBee helpfulness**\n") + sb.WriteString(fmt.Sprintf(" Last 30 events: %.0f%%\n", help30*100)) + sb.WriteString(fmt.Sprintf(" Lifetime: %.0f%%\n", helpAll*100)) + return sb.String() +}