mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
Add !coop stats — public telemetry for runs, gold flow, betting, gifts
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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 <run_id> <basket|mimic>`" + ` — Send a gift (1 harvest action)
|
||||
` + "`!coop giftvote <id> <open|leave>`" + ` — 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:**
|
||||
|
||||
245
internal/plugin/coop_dungeon_stats.go
Normal file
245
internal/plugin/coop_dungeon_stats.go
Normal file
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user