package plugin import ( "fmt" "math/rand/v2" "strings" "maunium.net/go/mautrix" "maunium.net/go/mautrix/id" "gogobee/internal/db" ) // FlipPlugin handles !flip (coin flip) restricted to the games room. type FlipPlugin struct { Base } func NewFlipPlugin(client *mautrix.Client) *FlipPlugin { return &FlipPlugin{Base: NewBase(client)} } func (p *FlipPlugin) Name() string { return "flip" } func (p *FlipPlugin) Commands() []CommandDef { return []CommandDef{ {Name: "flip", Description: "Flip a coin", Usage: "!flip", Category: "Games"}, {Name: "games", Description: "List available games", Usage: "!games", Category: "Games"}, {Name: "twinbeeboard", Description: "GogoBee's victory record", Usage: "!twinbeeboard", Category: "Games"}, } } func (p *FlipPlugin) Init() error { return nil } func (p *FlipPlugin) OnReaction(_ ReactionContext) error { return nil } func (p *FlipPlugin) OnMessage(ctx MessageContext) error { switch { case p.IsCommand(ctx.Body, "flip"): if !isGamesRoom(ctx.RoomID) { gr := gamesRoom() if gr != "" { return p.SendReply(ctx.RoomID, ctx.EventID, "Games are only available in the games channel!") } } return p.handleFlip(ctx) case p.IsCommand(ctx.Body, "games"): return p.handleGames(ctx) case p.IsCommand(ctx.Body, "twinbeeboard"): return p.handleLoseboard(ctx) } return nil } func (p *FlipPlugin) handleFlip(ctx MessageContext) error { if rand.IntN(2) == 0 { return p.SendReply(ctx.RoomID, ctx.EventID, "๐Ÿช™ **Heads!**") } return p.SendReply(ctx.RoomID, ctx.EventID, "๐Ÿช™ **Tails!**") } func (p *FlipPlugin) handleGames(ctx MessageContext) error { gr := gamesRoom() roomNote := "" if gr != "" { roomNote = "\n\nAll games are played in the games channel." } return p.SendReply(ctx.RoomID, ctx.EventID, "๐ŸŽฎ **Available Games**\n\n"+ "**!flip** โ€” Coin flip\n"+ "**!hangman start** โ€” Collaborative Hangman\n"+ "**!blackjack โ‚ฌamount** โ€” Blackjack (1-4 players vs dealer)\n"+ "**!uno โ‚ฌamount** โ€” UNO (solo or multiplayer, classic or No Mercy)\n"+ "**!holdem join** โ€” Texas Hold'em poker (2-9 players)\n"+ "**!wordle ** โ€” Daily cooperative Wordle\n"+ "**!trivia** โ€” Trivia questions\n\n"+ "**Economy:**\n"+ "**!balance** โ€” Check your euros\n"+ "**!baltop** โ€” Euro leaderboard\n"+ "**!baltransfer @user โ‚ฌamount** โ€” Send euros\n"+ "**!hangboard** โ€” Hangman leaderboard\n"+ "**!bjboard** โ€” Blackjack leaderboard\n"+ "**!twinbeeboard** โ€” Bot defeat leaderboard"+ roomNote) } func (p *FlipPlugin) handleLoseboard(ctx MessageContext) error { d := db.Get() rows, err := d.Query( `SELECT user_id, game, losses FROM bot_defeats WHERE losses > 0 ORDER BY losses DESC LIMIT 15`) if err != nil { return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load twinbeeboard.") } defer rows.Close() // Aggregate total losses per user and track per-game breakdown type userStats struct { total int breakdown map[string]int } users := make(map[string]*userStats) var order []string for rows.Next() { var uid, game string var losses int if err := rows.Scan(&uid, &game, &losses); err != nil { continue } if _, ok := users[uid]; !ok { users[uid] = &userStats{breakdown: make(map[string]int)} order = append(order, uid) } users[uid].total += losses users[uid].breakdown[game] = losses } if len(order) == 0 { return p.SendReply(ctx.RoomID, ctx.EventID, "๐Ÿ No victories yet. GogoBee is patient.") } // Sort by total losses descending for i := 0; i < len(order); i++ { for j := i + 1; j < len(order); j++ { if users[order[j]].total > users[order[i]].total { order[i], order[j] = order[j], order[i] } } } if len(order) > 10 { order = order[:10] } gameLabels := map[string]string{ "blackjack": "BJ", "uno": "UNO", "uno_multi": "UNO MP", } var sb strings.Builder sb.WriteString("๐Ÿ **GogoBee's Trophy Wall**\n\n") for i, uid := range order { name := p.DisplayName(id.UserID(uid)) stats := users[uid] var parts []string for game, count := range stats.breakdown { label := gameLabels[game] if label == "" { label = game } parts = append(parts, fmt.Sprintf("%s: %d", label, count)) } sb.WriteString(fmt.Sprintf("%d. **%s** โ€” %d losses (%s)\n", i+1, name, stats.total, strings.Join(parts, ", "))) } return p.SendReply(ctx.RoomID, ctx.EventID, sb.String()) } // recordBotDefeat increments the bot defeat counter for a user in a specific game. // This is a package-level function so all game plugins can call it. func recordBotDefeat(userID id.UserID, game string) { db.Exec("flip: record bot defeat", `INSERT INTO bot_defeats (user_id, game, losses) VALUES (?, ?, 1) ON CONFLICT(user_id, game) DO UPDATE SET losses = losses + 1`, string(userID), game, ) } // redirectToGamesRoom returns the room ID for games-restricted redirect. func redirectToGamesRoom(sender id.UserID) string { _ = sender return "Games are only available in the games channel!" }