mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
Add bot defeat tracking, audit fixes, and README UNO section
Add !twinbeeboard command (GogoBee's trophy wall) with unified bot_defeats table tracking losses across Blackjack, UNO solo, and UNO multiplayer. Fix No Mercy audit bugs: wild card color choice routing, bot Color Roulette double-advance, missing DM notifications for roulette victims and 7-0 swap targets, autoplay Color Roulette effect. Remove dead code. Update README with full UNO documentation. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
53
README.md
53
README.md
@@ -30,7 +30,7 @@ Written in Go using [mautrix-go](https://github.com/mautrix/go) for encryption a
|
||||
- **E2EE that actually works** - mautrix-go with goolm (pure Go). Crypto state lives in SQLite so device keys survive restarts. Cross-signing bootstraps on first run — the bot self-verifies its own device.
|
||||
- **No CGo, no system deps** - builds to a single static binary. Cross-compile to whatever you want.
|
||||
- **43 plugins** with dependency injection and ordered registration
|
||||
- **Games & economy** - Euro virtual currency, Hangman (collaborative, threaded, tiered scoring), Blackjack (1-2 players, auto-play timeout), UNO (solo vs bot or 2–4 player multiplayer via DMs), all with channel restriction
|
||||
- **Games & economy** - Euro virtual currency, Hangman (collaborative, threaded, tiered scoring), Blackjack (1-4 players, auto-play timeout), UNO (solo vs bot or 2–4 player multiplayer via DMs, with optional No Mercy mode), all with channel restriction
|
||||
- **Moderation system** (optional) - deterministic detection only, no LLM. Word list with leetspeak variation matching, text/image flood, repeated messages, mention flooding, link rate limiting, invite flooding, join/leave cycling. Three-strike ladder (warn → mute → ban). Admin room notifications, DMs over public callouts.
|
||||
- **Passive tracking** - XP, stats, streaks, achievements, markov corpus, keyword alerts, all running silently
|
||||
- **Scheduled posts** via [robfig/cron](https://github.com/robfig/cron) - WOTD, holidays, game releases, birthdays, anime/movie releases, concert digests, esteemed members
|
||||
@@ -302,22 +302,64 @@ Rep is earned when someone thanks you. The bot detects this automatically.
|
||||
### Blackjack (games channel only)
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `!blackjack €amount` | Start/join a Blackjack table (1-2 players) |
|
||||
| `!blackjack €amount` | Start/join a Blackjack table (1-4 players) |
|
||||
| `!hit` | Take a card |
|
||||
| `!stand` | End your turn |
|
||||
| `!blackjack leave` | Leave before game starts |
|
||||
| `!bjboard` | Blackjack leaderboard |
|
||||
| `!twinbeeboard` | GogoBee's victory record against players |
|
||||
|
||||
### UNO (games channel only)
|
||||
|
||||
UNO can be played solo (vs GogoBee) or multiplayer (2-4 players + bot). All gameplay happens in DMs. The games channel is used for lobby management and public announcements.
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `!uno €amount` | Start a solo UNO game vs GogoBee (played in DMs) |
|
||||
| `!uno start €amount` | Create a multiplayer UNO lobby (2-4 players) |
|
||||
| `!uno join` | Join an open UNO lobby |
|
||||
| `!uno €amount` | Start a solo game vs GogoBee |
|
||||
| `!uno nomercy €amount` | Start a solo No Mercy game |
|
||||
| `!uno nomercy 7-0 €amount` | Solo No Mercy with 7-0 rule |
|
||||
| `!uno start €amount` | Create a multiplayer lobby |
|
||||
| `!uno start nomercy €amount` | Multiplayer No Mercy lobby |
|
||||
| `!uno start nomercy 7-0 €amount` | Multiplayer No Mercy with 7-0 rule |
|
||||
| `!uno join` | Join an open lobby |
|
||||
| `!uno go` | Start the game (host only, 2+ players required) |
|
||||
| `!uno leave` | Leave the lobby (refunds ante) |
|
||||
| `!uno cancel` | Cancel the lobby (host/admin, refunds all) |
|
||||
|
||||
**DM commands during gameplay:** reply with a card number to play, `draw` to draw, `uno` to call UNO (required when you have 2 cards), `quit` to forfeit. During draw stacking, type `accept` to absorb the stack.
|
||||
|
||||
#### Classic Mode
|
||||
|
||||
Standard 108-card UNO deck. Draw one card per turn (pass if unplayable). Wild Draw Four can be challenged — if the challenger catches a bluff, the player draws 4 instead.
|
||||
|
||||
GogoBee has a personality system during solo play: she reads a book while playing, and puts it down when the game gets serious (opponent has few cards). Commentary changes based on book state.
|
||||
|
||||
#### No Mercy Mode
|
||||
|
||||
Based on UNO Show 'Em No Mercy (2023, Mattel). Bigger 168-card deck, meaner rules.
|
||||
|
||||
**New cards:**
|
||||
| Card | Type | Effect |
|
||||
|------|------|--------|
|
||||
| Skip Everyone | Colored | Skips all other players — you go again |
|
||||
| Discard All | Colored | Play it and discard every other card of that color from your hand |
|
||||
| Draw Four | Colored | Like Wild Draw Four but matches by color (not wild) |
|
||||
| Wild Reverse Draw Four | Wild | Reverse direction + draw 4 + pick a color |
|
||||
| Wild Draw Six | Wild | Draw 6 + pick a color |
|
||||
| Wild Draw Ten | Wild | Draw 10 + pick a color |
|
||||
| Wild Color Roulette | Wild | Pick a color — next player flips cards from the deck until that color appears, keeping all flipped cards |
|
||||
|
||||
**Rule changes:**
|
||||
- **Draw stacking** — when hit with a draw card, play a draw card of equal or higher value to pass the penalty to the next player. The stack keeps growing until someone can't (or won't) stack back. That player draws the entire total.
|
||||
- **Draw until playable** — no more drawing one and passing. You keep drawing until you find a playable card.
|
||||
- **Mercy rule** — reach 25 cards in your hand and you're eliminated.
|
||||
|
||||
#### 7-0 Rule (optional, No Mercy only)
|
||||
|
||||
Enabled by adding `7-0` to the command. When active:
|
||||
- **Play a 7** — swap hands with another player of your choice (in multiplayer, you pick the target)
|
||||
- **Play a 0** — all players pass their hand to the next player in play direction
|
||||
|
||||
### Reminders
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
@@ -643,6 +685,7 @@ gogobee/
|
||||
│ │ ├── blackjack.go # Multiplayer Blackjack
|
||||
│ │ ├── uno.go # Solo UNO vs bot (DM-based)
|
||||
│ │ ├── uno_multi.go # Multiplayer UNO (lobby + DM turns)
|
||||
│ │ ├── uno_nomercy.go # No Mercy mode (deck, stacking, mercy rule, 7-0, bot AI)
|
||||
│ │ ├── esteemed.go # Satirical esteemed member posts
|
||||
│ │ ├── moderation.go # Moderation system (strikes, word list, flood detection)
|
||||
│ │ └── ratelimits.go # Rate limiting
|
||||
|
||||
@@ -723,6 +723,14 @@ CREATE TABLE IF NOT EXISTS uno_multi_games (
|
||||
ended_at DATETIME NOT NULL
|
||||
);
|
||||
|
||||
-- Bot defeat tracking (unified across all games)
|
||||
CREATE TABLE IF NOT EXISTS bot_defeats (
|
||||
user_id TEXT NOT NULL,
|
||||
game TEXT NOT NULL,
|
||||
losses INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (user_id, game)
|
||||
);
|
||||
|
||||
-- Space groups (rooms with overlapping membership)
|
||||
CREATE TABLE IF NOT EXISTS space_groups (
|
||||
room_id TEXT PRIMARY KEY,
|
||||
|
||||
@@ -868,6 +868,9 @@ func (p *BlackjackPlugin) recordBJScore(userID id.UserID, net float64) {
|
||||
games_won = games_won + ?`,
|
||||
string(userID), net, won, net, won,
|
||||
)
|
||||
if net < 0 {
|
||||
recordBotDefeat(userID, "blackjack")
|
||||
}
|
||||
}
|
||||
|
||||
func (p *BlackjackPlugin) bjDisplayName(userID id.UserID) string {
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"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.
|
||||
@@ -22,6 +27,7 @@ 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"},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +46,8 @@ func (p *FlipPlugin) OnMessage(ctx MessageContext) error {
|
||||
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
|
||||
}
|
||||
@@ -62,17 +70,115 @@ func (p *FlipPlugin) handleGames(ctx MessageContext) error {
|
||||
"🎮 **Available Games**\n\n"+
|
||||
"**!flip** — Coin flip\n"+
|
||||
"**!hangman start** — Collaborative Hangman\n"+
|
||||
"**!blackjack €amount** — Blackjack (1-2 players vs dealer)\n"+
|
||||
"**!blackjack €amount** — Blackjack (1-4 players vs dealer)\n"+
|
||||
"**!uno €amount** — UNO (solo or multiplayer, classic or No Mercy)\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"+
|
||||
"**!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.flipDisplayName(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) {
|
||||
d := db.Get()
|
||||
_, _ = d.Exec(
|
||||
`INSERT INTO bot_defeats (user_id, game, losses)
|
||||
VALUES (?, ?, 1)
|
||||
ON CONFLICT(user_id, game) DO UPDATE SET
|
||||
losses = losses + 1`,
|
||||
string(userID), game,
|
||||
)
|
||||
}
|
||||
|
||||
func (p *FlipPlugin) flipDisplayName(userID id.UserID) string {
|
||||
resp, err := p.Client.GetDisplayName(context.Background(), userID)
|
||||
if err != nil || resp.DisplayName == "" {
|
||||
return string(userID)
|
||||
}
|
||||
return resp.DisplayName
|
||||
}
|
||||
|
||||
// redirectToGamesRoom returns the room ID for games-restricted redirect.
|
||||
func redirectToGamesRoom(sender id.UserID) string {
|
||||
_ = sender
|
||||
|
||||
@@ -1779,6 +1779,7 @@ func (p *UnoPlugin) botWins(game *unoGame) error {
|
||||
"💀 "+unoBotName()+" wins. **%s**'s €%d has been added to the community pot.\n(Community pot: €%d)\n\n%s",
|
||||
playerName, int(game.wager), int(newPot), pickCommentary("bot_win")))
|
||||
|
||||
recordBotDefeat(game.playerID, "uno")
|
||||
p.recordGame(game, "gogobee_win", potBefore)
|
||||
p.cleanupGame(game)
|
||||
return nil
|
||||
@@ -1809,6 +1810,7 @@ func (p *UnoPlugin) forfeitGame(game *unoGame, timeout bool) error {
|
||||
fmt.Sprintf("🚪 **%s** has quit. €%d added to the pot. 💛", playerName, int(game.wager)))
|
||||
}
|
||||
|
||||
recordBotDefeat(game.playerID, "uno")
|
||||
p.recordGame(game, "gogobee_win", potBefore)
|
||||
p.cleanupGame(game)
|
||||
return nil
|
||||
|
||||
@@ -2367,6 +2367,11 @@ func (p *UnoPlugin) multiBotWins(game *unoMultiGame) {
|
||||
"💀 **%s wins Multiplayer UNO!**\n€%d goes to the community pot.\n\n%s",
|
||||
bn, int(totalPot), pickCommentary("bot_win")))
|
||||
|
||||
for _, pl := range game.players {
|
||||
if !pl.isBot {
|
||||
recordBotDefeat(pl.userID, "uno_multi")
|
||||
}
|
||||
}
|
||||
p.recordMultiGame(game, id.UserID("bot"), "bot_win")
|
||||
p.cleanupMultiGame(game)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user