diff --git a/.env.example b/.env.example index 3acc703..fae06d9 100644 --- a/.env.example +++ b/.env.example @@ -38,6 +38,20 @@ FEATURE_TRIVIA=true # set to "false" to disable trivia FEATURE_ESTEEMED= # set to anything to enable satirical esteemed member posts ESTEEMED_ROOM= # room ID for esteemed member posts (separate from broadcast rooms) +# ---- Games & Economy ---- +GAMES_ROOM= # room ID where game commands work (trivia, hangman, blackjack, flip) +EURO_COOLDOWN_SECONDS=30 # cooldown between passive euro earning +EURO_DEBT_REMINDER=true # weekly DM reminder if in debt +EURO_STARTING_CAP=2500 # max starting balance from corpus +HANGMAN_MAX_WRONG_GUESSES=6 +HANGMAN_SOLUTION_BONUS_MULTIPLIER=2 +HANGMAN_PHRASE_FILE= # path to newline-delimited phrase file +BLACKJACK_TIMEOUT_SECONDS=60 # auto-play timeout per player turn +BLACKJACK_AUTOPLAY_THRESHOLD=15 +BLACKJACK_MIN_BET=1 +BLACKJACK_MAX_BET=500 +BLACKJACK_DEBT_LIMIT=1000 # max debt before betting disabled + # ---- Space Groups (automatic room grouping for community-wide leaderboards) ---- SPACE_GROUP_THRESHOLD=50 # % of smaller room's members that must overlap (1-100, default 50) diff --git a/README.md b/README.md index 23ac908..5b40c7e 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,8 @@ 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. -- **38 plugins** with dependency injection and ordered registration +- **42 plugins** with dependency injection and ordered registration +- **Games & economy** - Euro virtual currency, Hangman (collaborative, tiered scoring), Blackjack (1-2 players, auto-play timeout), 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 @@ -139,6 +140,23 @@ Everything is configured through environment variables or a `.env` file. | `ESTEEMED_ROOM` | Room ID for esteemed member posts (separate from broadcast rooms) | | `FEATURE_MODERATION` | Set to `true` to enable the moderation system (disabled by default) | +### Games & Economy + +| Variable | Default | Description | +|----------|---------|-------------| +| `GAMES_ROOM` | | Room ID where game commands work (trivia, hangman, blackjack, flip) | +| `EURO_COOLDOWN_SECONDS` | `30` | Cooldown between passive euro earning per user | +| `EURO_DEBT_REMINDER` | `true` | Weekly DM reminder if player is in debt | +| `EURO_STARTING_CAP` | `2500` | Max starting balance seeded from corpus | +| `HANGMAN_MAX_WRONG_GUESSES` | `6` | Lives before game over | +| `HANGMAN_SOLUTION_BONUS_MULTIPLIER` | `2` | Bonus multiplier for early solution | +| `HANGMAN_PHRASE_FILE` | | Path to newline-delimited phrase file | +| `BLACKJACK_TIMEOUT_SECONDS` | `60` | Auto-play timeout per turn | +| `BLACKJACK_AUTOPLAY_THRESHOLD` | `15` | Stand at or above, hit below | +| `BLACKJACK_MIN_BET` | `1` | Minimum bet in euros | +| `BLACKJACK_MAX_BET` | `500` | Maximum bet per hand | +| `BLACKJACK_DEBT_LIMIT` | `1000` | Maximum debt before betting disabled | + ### Moderation All moderation settings are optional. The system is disabled unless `FEATURE_MODERATION=true`. @@ -255,6 +273,30 @@ Rep is earned when someone thanks you. The bot detects this automatically. | `!trivia fastest` | Fastest answers | | `!trivia stop` | End current question | +### Economy +| Command | Description | +|---------|-------------| +| `!balance` | Check your euro balance | +| `!baltop` | Euro leaderboard | +| `!baltransfer @user €amount` | Send euros to another player | + +### Games (games channel only) +| Command | Description | +|---------|-------------| +| `!flip` | Coin flip | +| `!games` | List available games | +| `!hangman start` | Start a Hangman game | +| `!hangman [letter]` | Guess a letter | +| `!hangman [phrase]` | Attempt full solution | +| `!hangman submit [phrase]` | Submit a phrase to the pool | +| `!hangman skip` | Skip game (admin only) | +| `!hangboard` | Hangman leaderboard | +| `!blackjack €amount` | Start/join a Blackjack table | +| `!hit` | Take a card | +| `!stand` | End your turn | +| `!blackjack leave` | Leave before game starts | +| `!bjboard` | Blackjack leaderboard | + ### Reminders | Command | Description | |---------|-------------| @@ -573,6 +615,10 @@ gogobee/ │ │ ├── quotewall.go # Encrypted quote wall (AES-256-GCM) │ │ ├── tarot.go # LLM-powered tarot readings │ │ ├── horoscope.go # Daily horoscopes +│ │ ├── euro.go # Euro virtual currency +│ │ ├── flip.go # Coin flip, !games +│ │ ├── hangman.go # Collaborative Hangman +│ │ ├── blackjack.go # Multiplayer Blackjack │ │ ├── esteemed.go # Satirical esteemed member posts │ │ ├── moderation.go # Moderation system (strikes, word list, flood detection) │ │ └── ratelimits.go # Rate limiting diff --git a/internal/db/db.go b/internal/db/db.go index 308af2f..26c4fb4 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -621,6 +621,38 @@ CREATE TABLE IF NOT EXISTS api_cache ( cached_at INTEGER DEFAULT (unixepoch()) ); +-- Euro economy +CREATE TABLE IF NOT EXISTS euro_balances ( + user_id TEXT PRIMARY KEY, + balance REAL NOT NULL DEFAULT 0, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS euro_transactions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + amount REAL NOT NULL, + reason TEXT NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS idx_euro_tx_user ON euro_transactions(user_id, created_at); + +-- Hangman scores +CREATE TABLE IF NOT EXISTS hangman_scores ( + user_id TEXT PRIMARY KEY, + total_earned REAL NOT NULL DEFAULT 0, + games_played INTEGER NOT NULL DEFAULT 0, + games_won INTEGER NOT NULL DEFAULT 0 +); + +-- Blackjack scores +CREATE TABLE IF NOT EXISTS blackjack_scores ( + user_id TEXT PRIMARY KEY, + total_earned REAL NOT NULL DEFAULT 0, + games_played INTEGER NOT NULL DEFAULT 0, + games_won INTEGER NOT NULL DEFAULT 0 +); + -- Moderation: strikes CREATE TABLE IF NOT EXISTS mod_strikes ( id INTEGER PRIMARY KEY AUTOINCREMENT, diff --git a/internal/plugin/blackjack.go b/internal/plugin/blackjack.go new file mode 100644 index 0000000..d32e2e3 --- /dev/null +++ b/internal/plugin/blackjack.go @@ -0,0 +1,711 @@ +package plugin + +import ( + "context" + "fmt" + "math/rand/v2" + "strings" + "sync" + "time" + + "gogobee/internal/db" + + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/id" +) + +// --------------------------------------------------------------------------- +// Card types +// --------------------------------------------------------------------------- + +type suit int + +const ( + spades suit = iota + hearts + diamonds + clubs +) + +var suitSymbols = [4]string{"♠", "♥", "♦", "♣"} + +type card struct { + Rank int // 1=Ace, 2-10, 11=J, 12=Q, 13=K + Suit suit +} + +func (c card) String() string { + ranks := [14]string{"", "A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K"} + return ranks[c.Rank] + suitSymbols[c.Suit] +} + +func (c card) value() int { + if c.Rank >= 10 { + return 10 + } + return c.Rank // Ace = 1, handled in handValue +} + +type deck struct { + cards []card +} + +func newDeck() *deck { + d := &deck{cards: make([]card, 0, 52)} + for s := spades; s <= clubs; s++ { + for r := 1; r <= 13; r++ { + d.cards = append(d.cards, card{r, s}) + } + } + // Shuffle + rand.Shuffle(len(d.cards), func(i, j int) { + d.cards[i], d.cards[j] = d.cards[j], d.cards[i] + }) + return d +} + +func (d *deck) draw() card { + c := d.cards[0] + d.cards = d.cards[1:] + return c +} + +func handValue(cards []card) (int, bool) { + total := 0 + aces := 0 + for _, c := range cards { + if c.Rank == 1 { + aces++ + total += 11 + } else { + total += c.value() + } + } + soft := aces > 0 && total <= 21 + for total > 21 && aces > 0 { + total -= 10 + aces-- + } + if aces == 0 { + soft = false + } + return total, soft +} + +func handStr(cards []card) string { + parts := make([]string, len(cards)) + for i, c := range cards { + parts[i] = c.String() + } + return strings.Join(parts, " ") +} + +func isBlackjack(cards []card) bool { + if len(cards) != 2 { + return false + } + v, _ := handValue(cards) + return v == 21 +} + +// --------------------------------------------------------------------------- +// Blackjack game state +// --------------------------------------------------------------------------- + +type bjPlayer struct { + UserID id.UserID + Bet float64 + Hand []card + Done bool + Bust bool +} + +func (p *bjPlayer) value() int { + v, _ := handValue(p.Hand) + return v +} + +type bjTable struct { + players []*bjPlayer + dealer []card + deck *deck + joinTimer *time.Timer + turnTimer *time.Timer + currentTurn int + phase string // "joining", "playing", "done" + roomID id.RoomID +} + +// --------------------------------------------------------------------------- +// Blackjack config +// --------------------------------------------------------------------------- + +type bjConfig struct { + TimeoutSeconds int + AutoplayThreshold int + MinBet float64 + MaxBet float64 + DebtLimit float64 +} + +func loadBJConfig() bjConfig { + return bjConfig{ + TimeoutSeconds: envInt("BLACKJACK_TIMEOUT_SECONDS", 60), + AutoplayThreshold: envInt("BLACKJACK_AUTOPLAY_THRESHOLD", 15), + MinBet: envFloat("BLACKJACK_MIN_BET", 1), + MaxBet: envFloat("BLACKJACK_MAX_BET", 500), + DebtLimit: envFloat("BLACKJACK_DEBT_LIMIT", 1000), + } +} + +// --------------------------------------------------------------------------- +// Plugin +// --------------------------------------------------------------------------- + +type BlackjackPlugin struct { + Base + euro *EuroPlugin + cfg bjConfig + mu sync.Mutex + tables map[id.RoomID]*bjTable +} + +func NewBlackjackPlugin(client *mautrix.Client, euro *EuroPlugin) *BlackjackPlugin { + return &BlackjackPlugin{ + Base: Base{Client: client}, + euro: euro, + cfg: loadBJConfig(), + tables: make(map[id.RoomID]*bjTable), + } +} + +func (p *BlackjackPlugin) Name() string { return "blackjack" } + +func (p *BlackjackPlugin) Commands() []CommandDef { + return []CommandDef{ + {Name: "blackjack", Description: "Start or join a Blackjack table", Usage: "!blackjack €amount | !blackjack leave", Category: "Games"}, + {Name: "hit", Description: "Take a card in Blackjack", Usage: "!hit", Category: "Games"}, + {Name: "stand", Description: "End your turn in Blackjack", Usage: "!stand", Category: "Games"}, + {Name: "bjboard", Description: "Blackjack leaderboard", Usage: "!bjboard", Category: "Games"}, + } +} + +func (p *BlackjackPlugin) Init() error { return nil } + +func (p *BlackjackPlugin) OnReaction(_ ReactionContext) error { return nil } + +func (p *BlackjackPlugin) OnMessage(ctx MessageContext) error { + switch { + case p.IsCommand(ctx.Body, "bjboard"): + return p.handleBoard(ctx) + case p.IsCommand(ctx.Body, "blackjack"): + if !isGamesRoom(ctx.RoomID) { + return p.SendReply(ctx.RoomID, ctx.EventID, "Games are only available in the games channel!") + } + return p.handleBlackjack(ctx) + case p.IsCommand(ctx.Body, "hit"): + if !isGamesRoom(ctx.RoomID) { + return nil + } + return p.handleHit(ctx) + case p.IsCommand(ctx.Body, "stand"): + if !isGamesRoom(ctx.RoomID) { + return nil + } + return p.handleStand(ctx) + } + return nil +} + +// --------------------------------------------------------------------------- +// Blackjack command handlers +// --------------------------------------------------------------------------- + +func (p *BlackjackPlugin) handleBlackjack(ctx MessageContext) error { + args := strings.TrimSpace(p.GetArgs(ctx.Body, "blackjack")) + + if strings.EqualFold(args, "leave") { + return p.handleLeave(ctx) + } + + // Parse bet amount + amountStr := strings.TrimPrefix(args, "€") + var bet float64 + if _, err := fmt.Sscanf(amountStr, "%f", &bet); err != nil || bet <= 0 { + return p.SendReply(ctx.RoomID, ctx.EventID, + fmt.Sprintf("Usage: `!blackjack €amount` (min €%d, max €%d)", int(p.cfg.MinBet), int(p.cfg.MaxBet))) + } + + if bet < p.cfg.MinBet { + return p.SendReply(ctx.RoomID, ctx.EventID, + fmt.Sprintf("Minimum bet is €%d.", int(p.cfg.MinBet))) + } + if bet > p.cfg.MaxBet { + bet = p.cfg.MaxBet + } + + // Check balance + balance := p.euro.GetBalance(ctx.Sender) + maxAvailable := balance + p.cfg.DebtLimit + if maxAvailable <= 0 { + return p.SendReply(ctx.RoomID, ctx.EventID, + "🚫 You're at your debt limit. Earn some euros before playing.") + } + if bet > maxAvailable { + return p.SendReply(ctx.RoomID, ctx.EventID, + fmt.Sprintf("You can bet up to €%d (balance: €%d).", int(maxAvailable), int(balance))) + } + + p.mu.Lock() + defer p.mu.Unlock() + + table, exists := p.tables[ctx.RoomID] + + if exists && table.phase == "joining" { + // Join existing table + for _, pl := range table.players { + if pl.UserID == ctx.Sender { + return p.SendReply(ctx.RoomID, ctx.EventID, "You're already at the table!") + } + } + if len(table.players) >= 2 { + return p.SendReply(ctx.RoomID, ctx.EventID, "Table is full (max 2 players).") + } + + if !p.euro.Debit(ctx.Sender, bet, "blackjack_bet") { + return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to place bet.") + } + + table.players = append(table.players, &bjPlayer{UserID: ctx.Sender, Bet: bet}) + name := p.bjDisplayName(ctx.Sender) + _ = p.SendMessage(ctx.RoomID, + fmt.Sprintf("🃏 **%s** joins the table! Bet: €%d\nTable is full — dealing!", name, int(bet))) + + if table.joinTimer != nil { + table.joinTimer.Stop() + } + p.startRound(ctx.RoomID, table) + return nil + } + + if exists && table.phase == "playing" { + return p.SendReply(ctx.RoomID, ctx.EventID, "A round is in progress. Wait for it to finish.") + } + + // Create new table + if !p.euro.Debit(ctx.Sender, bet, "blackjack_bet") { + return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to place bet.") + } + + table = &bjTable{ + players: []*bjPlayer{{UserID: ctx.Sender, Bet: bet}}, + deck: newDeck(), + phase: "joining", + roomID: ctx.RoomID, + } + p.tables[ctx.RoomID] = table + + name := p.bjDisplayName(ctx.Sender) + _ = p.SendMessage(ctx.RoomID, + fmt.Sprintf("🃏 **%s** opens a Blackjack table! Bet: €%d\nJoin with `!blackjack €amount` (60 seconds to join)", + name, int(bet))) + + // Start join timer + table.joinTimer = time.AfterFunc(60*time.Second, func() { + p.mu.Lock() + defer p.mu.Unlock() + if table.phase == "joining" { + p.startRound(ctx.RoomID, table) + } + }) + + return nil +} + +func (p *BlackjackPlugin) handleLeave(ctx MessageContext) error { + p.mu.Lock() + defer p.mu.Unlock() + + table, exists := p.tables[ctx.RoomID] + if !exists || table.phase != "joining" { + return p.SendReply(ctx.RoomID, ctx.EventID, "No table to leave, or the round has started.") + } + + for i, pl := range table.players { + if pl.UserID == ctx.Sender { + // Return bet + p.euro.Credit(ctx.Sender, pl.Bet, "blackjack_leave_refund") + table.players = append(table.players[:i], table.players[i+1:]...) + + if len(table.players) == 0 { + if table.joinTimer != nil { + table.joinTimer.Stop() + } + delete(p.tables, ctx.RoomID) + return p.SendMessage(ctx.RoomID, "🃏 Table closed — all players left.") + } + name := p.bjDisplayName(ctx.Sender) + return p.SendMessage(ctx.RoomID, + fmt.Sprintf("🃏 **%s** left the table. Bet refunded.", name)) + } + } + + return p.SendReply(ctx.RoomID, ctx.EventID, "You're not at the table.") +} + +// startRound must be called with p.mu held. +func (p *BlackjackPlugin) startRound(roomID id.RoomID, table *bjTable) { + table.phase = "playing" + + // Deal 2 cards to each player and dealer + for range 2 { + for _, pl := range table.players { + pl.Hand = append(pl.Hand, table.deck.draw()) + } + table.dealer = append(table.dealer, table.deck.draw()) + } + + // Check for immediate blackjacks + for _, pl := range table.players { + if isBlackjack(pl.Hand) { + pl.Done = true + } + } + + // Display initial state + _ = p.SendMessage(roomID, p.renderTable(table, false)) + + // Find first active player + table.currentTurn = -1 + p.advanceTurn(roomID, table) +} + +func (p *BlackjackPlugin) handleHit(ctx MessageContext) error { + p.mu.Lock() + defer p.mu.Unlock() + + table, exists := p.tables[ctx.RoomID] + if !exists || table.phase != "playing" { + return nil + } + + if table.currentTurn < 0 || table.currentTurn >= len(table.players) { + return nil + } + + player := table.players[table.currentTurn] + if player.UserID != ctx.Sender { + return nil // Not your turn + } + + if table.turnTimer != nil { + table.turnTimer.Stop() + } + + player.Hand = append(player.Hand, table.deck.draw()) + v := player.value() + + if v > 21 { + player.Bust = true + player.Done = true + name := p.bjDisplayName(player.UserID) + _ = p.SendMessage(ctx.RoomID, + fmt.Sprintf("💥 **%s** busts with %s (%d)!", name, handStr(player.Hand), v)) + p.advanceTurn(ctx.RoomID, table) + return nil + } + + if v == 21 { + player.Done = true + _ = p.SendMessage(ctx.RoomID, p.renderTable(table, false)) + p.advanceTurn(ctx.RoomID, table) + return nil + } + + _ = p.SendMessage(ctx.RoomID, p.renderTable(table, false)) + p.startTurnTimer(ctx.RoomID, table) + return nil +} + +func (p *BlackjackPlugin) handleStand(ctx MessageContext) error { + p.mu.Lock() + defer p.mu.Unlock() + + table, exists := p.tables[ctx.RoomID] + if !exists || table.phase != "playing" { + return nil + } + + if table.currentTurn < 0 || table.currentTurn >= len(table.players) { + return nil + } + + player := table.players[table.currentTurn] + if player.UserID != ctx.Sender { + return nil + } + + if table.turnTimer != nil { + table.turnTimer.Stop() + } + + player.Done = true + p.advanceTurn(ctx.RoomID, table) + return nil +} + +// advanceTurn moves to the next player or dealer. Must be called with p.mu held. +func (p *BlackjackPlugin) advanceTurn(roomID id.RoomID, table *bjTable) { + // Find next active player + for i := table.currentTurn + 1; i < len(table.players); i++ { + if !table.players[i].Done { + table.currentTurn = i + name := p.bjDisplayName(table.players[i].UserID) + _ = p.SendMessage(roomID, + fmt.Sprintf("👉 **%s**'s turn. `!hit` or `!stand` (%ds)", name, p.cfg.TimeoutSeconds)) + p.startTurnTimer(roomID, table) + return + } + } + + // All players done — dealer's turn + p.playDealer(roomID, table) +} + +func (p *BlackjackPlugin) startTurnTimer(roomID id.RoomID, table *bjTable) { + turnIdx := table.currentTurn + table.turnTimer = time.AfterFunc(time.Duration(p.cfg.TimeoutSeconds)*time.Second, func() { + p.mu.Lock() + defer p.mu.Unlock() + + // Verify table still exists and it's the same turn + t, exists := p.tables[roomID] + if !exists || t != table || t.currentTurn != turnIdx { + return + } + + player := table.players[turnIdx] + v := player.value() + name := p.bjDisplayName(player.UserID) + + if v >= p.cfg.AutoplayThreshold { + _ = p.SendMessage(roomID, + fmt.Sprintf("⏱️ **%s** timed out — auto-playing (stand)", name)) + player.Done = true + p.advanceTurn(roomID, table) + } else { + _ = p.SendMessage(roomID, + fmt.Sprintf("⏱️ **%s** timed out — auto-playing (hit)", name)) + player.Hand = append(player.Hand, table.deck.draw()) + v = player.value() + if v > 21 { + player.Bust = true + player.Done = true + _ = p.SendMessage(roomID, + fmt.Sprintf("💥 **%s** busts with %s (%d)!", name, handStr(player.Hand), v)) + p.advanceTurn(roomID, table) + } else if v >= p.cfg.AutoplayThreshold { + player.Done = true + p.advanceTurn(roomID, table) + } else { + // Still below threshold, restart timer + p.startTurnTimer(roomID, table) + } + } + }) +} + +// playDealer plays the dealer hand. Must be called with p.mu held. +func (p *BlackjackPlugin) playDealer(roomID id.RoomID, table *bjTable) { + // Check if all players busted + allBust := true + for _, pl := range table.players { + if !pl.Bust { + allBust = false + break + } + } + + if !allBust { + // Dealer plays: hit on soft 17, stand on hard 17+, stand on soft 18+ + for { + v, soft := handValue(table.dealer) + if v < 17 || (v == 17 && soft) { + table.dealer = append(table.dealer, table.deck.draw()) + } else { + break + } + } + } + + p.resolveRound(roomID, table) +} + +func (p *BlackjackPlugin) resolveRound(roomID id.RoomID, table *bjTable) { + table.phase = "done" + dealerValue, _ := handValue(table.dealer) + dealerBust := dealerValue > 21 + dealerBJ := isBlackjack(table.dealer) + + var sb strings.Builder + sb.WriteString("🃏 **Round over!**\n\n") + + for _, pl := range table.players { + name := p.bjDisplayName(pl.UserID) + playerValue := pl.value() + playerBJ := isBlackjack(pl.Hand) + + var result string + var payout float64 + + switch { + case pl.Bust: + result = "Bust" + payout = 0 // Already deducted + + case playerBJ && dealerBJ: + result = "Push (both Blackjack)" + payout = pl.Bet // Return bet + p.euro.Credit(pl.UserID, payout, "blackjack_push") + + case playerBJ: + result = "Blackjack!" + payout = pl.Bet + pl.Bet*1.5 // Return bet + 1.5x + p.euro.Credit(pl.UserID, payout, "blackjack_win") + + case dealerBJ: + result = "Dealer Blackjack" + payout = 0 + + case dealerBust: + result = "Win (dealer bust)!" + payout = pl.Bet * 2 + p.euro.Credit(pl.UserID, payout, "blackjack_win") + + case playerValue > dealerValue: + result = fmt.Sprintf("Win! %d vs %d", playerValue, dealerValue) + payout = pl.Bet * 2 + p.euro.Credit(pl.UserID, payout, "blackjack_win") + + case playerValue == dealerValue: + result = "Push" + payout = pl.Bet + p.euro.Credit(pl.UserID, payout, "blackjack_push") + + default: + result = fmt.Sprintf("Loss. %d vs %d", playerValue, dealerValue) + payout = 0 + } + + net := payout - pl.Bet + newBalance := p.euro.GetBalance(pl.UserID) + netStr := fmt.Sprintf("€%d", int(net)) + if net > 0 { + netStr = fmt.Sprintf("+€%d", int(net)) + } + + sb.WriteString(fmt.Sprintf("**%s**: %s %s — %s (balance: €%d)\n", + name, handStr(pl.Hand), result, netStr, int(newBalance))) + + // Record score + p.recordBJScore(pl.UserID, net) + } + + sb.WriteString(fmt.Sprintf("\nDealer: %s (%d)\n", handStr(table.dealer), dealerValue)) + + delete(p.tables, roomID) + _ = p.SendMessage(roomID, sb.String()) +} + +// --------------------------------------------------------------------------- +// Display +// --------------------------------------------------------------------------- + +func (p *BlackjackPlugin) renderTable(table *bjTable, showDealer bool) string { + var sb strings.Builder + sb.WriteString("🃏 **Blackjack** — Round in progress\n\n") + + for _, pl := range table.players { + name := p.bjDisplayName(pl.UserID) + v := pl.value() + extra := "" + if isBlackjack(pl.Hand) { + extra = " — Blackjack!" + } else if v > 21 { + extra = " — Bust!" + } + sb.WriteString(fmt.Sprintf("**%s**: %s (%d%s)\n", name, handStr(pl.Hand), v, extra)) + } + + if showDealer { + dv, _ := handValue(table.dealer) + sb.WriteString(fmt.Sprintf("Dealer: %s (%d)\n", handStr(table.dealer), dv)) + } else { + // Show first card, hide second + if len(table.dealer) >= 2 { + sb.WriteString(fmt.Sprintf("Dealer: %s 🂠 (? — one card hidden)\n", table.dealer[0].String())) + } + } + + return sb.String() +} + +// --------------------------------------------------------------------------- +// Leaderboard and scoring +// --------------------------------------------------------------------------- + +func (p *BlackjackPlugin) handleBoard(ctx MessageContext) error { + d := db.Get() + rows, err := d.Query( + `SELECT user_id, total_earned, games_played, games_won FROM blackjack_scores + ORDER BY total_earned DESC LIMIT 10`, + ) + if err != nil { + return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to fetch leaderboard.") + } + defer rows.Close() + + var sb strings.Builder + sb.WriteString("🃏 **Blackjack Leaderboard**\n\n") + rank := 0 + for rows.Next() { + var userID string + var earned float64 + var played, won int + rows.Scan(&userID, &earned, &played, &won) + rank++ + name := p.bjDisplayName(id.UserID(userID)) + sb.WriteString(fmt.Sprintf("%d. **%s** — €%d (%d/%d W/L)\n", rank, name, int(earned), won, played-won)) + } + + if rank == 0 { + sb.WriteString("No games played yet.") + } + + return p.SendReply(ctx.RoomID, ctx.EventID, sb.String()) +} + +func (p *BlackjackPlugin) recordBJScore(userID id.UserID, net float64) { + d := db.Get() + won := 0 + if net > 0 { + won = 1 + } + _, _ = d.Exec( + `INSERT INTO blackjack_scores (user_id, total_earned, games_played, games_won) + VALUES (?, ?, 1, ?) + ON CONFLICT(user_id) DO UPDATE SET + total_earned = total_earned + ?, + games_played = games_played + 1, + games_won = games_won + ?`, + string(userID), net, won, net, won, + ) +} + +func (p *BlackjackPlugin) bjDisplayName(userID id.UserID) string { + resp, err := p.Client.GetDisplayName(context.Background(), userID) + if err != nil || resp.DisplayName == "" { + return string(userID) + } + return resp.DisplayName +} diff --git a/internal/plugin/euro.go b/internal/plugin/euro.go new file mode 100644 index 0000000..2d0ea43 --- /dev/null +++ b/internal/plugin/euro.go @@ -0,0 +1,367 @@ +package plugin + +import ( + "context" + "fmt" + "log/slog" + "os" + "strings" + "sync" + "time" + + "gogobee/internal/db" + + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/id" +) + +// gamesRoom returns the configured GAMES_ROOM, or empty if unset. +func gamesRoom() id.RoomID { + return id.RoomID(os.Getenv("GAMES_ROOM")) +} + +// isGamesRoom checks whether the given room is the games room. +// Returns true if no games room is configured (unrestricted). +func isGamesRoom(roomID id.RoomID) bool { + gr := gamesRoom() + return gr == "" || roomID == gr +} + +// --------------------------------------------------------------------------- +// Euro config +// --------------------------------------------------------------------------- + +type euroConfig struct { + CooldownSeconds int + DebtReminder bool + StartingCap float64 +} + +func loadEuroConfig() euroConfig { + return euroConfig{ + CooldownSeconds: envInt("EURO_COOLDOWN_SECONDS", 30), + DebtReminder: envOrDefault("EURO_DEBT_REMINDER", "true") == "true", + StartingCap: envFloat("EURO_STARTING_CAP", 2500), + } +} + +// --------------------------------------------------------------------------- +// Euro Plugin +// --------------------------------------------------------------------------- + +type EuroPlugin struct { + Base + cfg euroConfig + cooldowns map[id.UserID]time.Time + mu sync.Mutex +} + +func NewEuroPlugin(client *mautrix.Client) *EuroPlugin { + return &EuroPlugin{ + Base: Base{Client: client}, + cfg: loadEuroConfig(), + cooldowns: make(map[id.UserID]time.Time), + } +} + +func (p *EuroPlugin) Name() string { return "euro" } + +func (p *EuroPlugin) Commands() []CommandDef { + return []CommandDef{ + {Name: "balance", Description: "Check your euro balance", Usage: "!balance", Category: "Economy"}, + {Name: "baltop", Description: "Euro leaderboard", Usage: "!baltop", Category: "Economy"}, + {Name: "baltransfer", Description: "Send euros to another player", Usage: "!baltransfer @user €amount", Category: "Economy"}, + } +} + +func (p *EuroPlugin) Init() error { return nil } + +func (p *EuroPlugin) OnReaction(_ ReactionContext) error { return nil } + +func (p *EuroPlugin) OnMessage(ctx MessageContext) error { + // Passive euro earning (all rooms, not just games room) + if !ctx.IsCommand { + p.awardPassiveEuros(ctx) + } + + switch { + case p.IsCommand(ctx.Body, "balance"): + return p.handleBalance(ctx) + case p.IsCommand(ctx.Body, "baltop"): + return p.handleBaltop(ctx) + case p.IsCommand(ctx.Body, "baltransfer"): + return p.handleTransfer(ctx) + } + return nil +} + +// --------------------------------------------------------------------------- +// Passive earning +// --------------------------------------------------------------------------- + +func (p *EuroPlugin) awardPassiveEuros(ctx MessageContext) { + p.mu.Lock() + last, ok := p.cooldowns[ctx.Sender] + now := time.Now() + if ok && now.Sub(last) < time.Duration(p.cfg.CooldownSeconds)*time.Second { + p.mu.Unlock() + return + } + p.cooldowns[ctx.Sender] = now + // Periodic cleanup + if len(p.cooldowns) > 1000 { + for uid, t := range p.cooldowns { + if now.Sub(t) > time.Duration(p.cfg.CooldownSeconds)*time.Second { + delete(p.cooldowns, uid) + } + } + } + p.mu.Unlock() + + words := len(strings.Fields(ctx.Body)) + var amount float64 + switch { + case words >= 51: + amount = 10.00 + case words >= 26: + amount = 5.00 + case words >= 11: + amount = 2.50 + case words >= 4: + amount = 1.25 + default: + amount = 0.50 + } + + p.ensureBalance(ctx.Sender) + p.credit(ctx.Sender, amount, "message") +} + +// --------------------------------------------------------------------------- +// Balance management +// --------------------------------------------------------------------------- + +// ensureBalance creates a balance row if none exists, seeding from corpus. +func (p *EuroPlugin) ensureBalance(userID id.UserID) { + d := db.Get() + var exists int + _ = d.QueryRow("SELECT 1 FROM euro_balances WHERE user_id = ?", string(userID)).Scan(&exists) + if exists == 1 { + return + } + + // Calculate starting balance from corpus character count + var totalChars float64 + _ = d.QueryRow("SELECT COALESCE(SUM(total_chars), 0) FROM user_stats WHERE user_id = ?", + string(userID)).Scan(&totalChars) + + starting := totalChars / 1000.0 + if starting > p.cfg.StartingCap { + starting = p.cfg.StartingCap + } + + _, err := d.Exec( + "INSERT OR IGNORE INTO euro_balances (user_id, balance) VALUES (?, ?)", + string(userID), starting, + ) + if err != nil { + slog.Error("euro: failed to create balance", "user", userID, "err", err) + } + if starting > 0 { + p.logTransaction(userID, starting, "starting_balance") + } +} + +func (p *EuroPlugin) credit(userID id.UserID, amount float64, reason string) { + d := db.Get() + _, err := d.Exec( + "UPDATE euro_balances SET balance = balance + ?, updated_at = CURRENT_TIMESTAMP WHERE user_id = ?", + amount, string(userID), + ) + if err != nil { + slog.Error("euro: credit failed", "user", userID, "amount", amount, "err", err) + return + } + p.logTransaction(userID, amount, reason) +} + +// Debit subtracts euros. Returns false if this would exceed debt limit. +func (p *EuroPlugin) Debit(userID id.UserID, amount float64, reason string) bool { + p.ensureBalance(userID) + d := db.Get() + + debtLimit := envFloat("BLACKJACK_DEBT_LIMIT", 1000) + var balance float64 + _ = d.QueryRow("SELECT balance FROM euro_balances WHERE user_id = ?", string(userID)).Scan(&balance) + + if balance-amount < -debtLimit { + return false + } + + _, err := d.Exec( + "UPDATE euro_balances SET balance = balance - ?, updated_at = CURRENT_TIMESTAMP WHERE user_id = ?", + amount, string(userID), + ) + if err != nil { + slog.Error("euro: debit failed", "user", userID, "amount", amount, "err", err) + return false + } + p.logTransaction(userID, -amount, reason) + return true +} + +// Credit adds euros (exported for other plugins). +func (p *EuroPlugin) Credit(userID id.UserID, amount float64, reason string) { + p.ensureBalance(userID) + p.credit(userID, amount, reason) +} + +// GetBalance returns current balance for a user. +func (p *EuroPlugin) GetBalance(userID id.UserID) float64 { + p.ensureBalance(userID) + d := db.Get() + var balance float64 + _ = d.QueryRow("SELECT balance FROM euro_balances WHERE user_id = ?", string(userID)).Scan(&balance) + return balance +} + +func (p *EuroPlugin) logTransaction(userID id.UserID, amount float64, reason string) { + d := db.Get() + _, _ = d.Exec( + "INSERT INTO euro_transactions (user_id, amount, reason) VALUES (?, ?, ?)", + string(userID), amount, reason, + ) +} + +// --------------------------------------------------------------------------- +// Commands +// --------------------------------------------------------------------------- + +func (p *EuroPlugin) handleBalance(ctx MessageContext) error { + p.ensureBalance(ctx.Sender) + d := db.Get() + + var balance float64 + _ = d.QueryRow("SELECT balance FROM euro_balances WHERE user_id = ?", + string(ctx.Sender)).Scan(&balance) + + debtLimit := envFloat("BLACKJACK_DEBT_LIMIT", 1000) + + var sb strings.Builder + sb.WriteString(fmt.Sprintf("💰 **Your Balance:** €%d\n", int(balance))) + + if balance < 0 { + sb.WriteString(fmt.Sprintf("⚠️ You are in debt! (limit: €%d)\n", int(debtLimit))) + if balance <= -debtLimit { + sb.WriteString("🚫 Betting disabled until you earn your way out of debt.\n") + } + } + + // Recent transactions + rows, err := d.Query( + `SELECT amount, reason, created_at FROM euro_transactions + WHERE user_id = ? ORDER BY created_at DESC LIMIT 5`, + string(ctx.Sender), + ) + if err == nil { + defer rows.Close() + sb.WriteString("\n**Recent transactions:**\n") + for rows.Next() { + var amount float64 + var reason, createdAt string + rows.Scan(&amount, &reason, &createdAt) + sign := "+" + if amount < 0 { + sign = "" + } + sb.WriteString(fmt.Sprintf(" %s€%.0f — %s\n", sign, amount, reason)) + } + } + + return p.SendReply(ctx.RoomID, ctx.EventID, sb.String()) +} + +func (p *EuroPlugin) handleBaltop(ctx MessageContext) error { + d := db.Get() + rows, err := d.Query( + `SELECT user_id, balance FROM euro_balances ORDER BY balance DESC LIMIT 10`, + ) + if err != nil { + return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to fetch leaderboard.") + } + defer rows.Close() + + var sb strings.Builder + sb.WriteString("💰 **Euro Leaderboard**\n\n") + rank := 0 + for rows.Next() { + var userID string + var balance float64 + rows.Scan(&userID, &balance) + rank++ + name := p.displayName(id.UserID(userID)) + medal := "" + switch rank { + case 1: + medal = "🥇" + case 2: + medal = "🥈" + case 3: + medal = "🥉" + } + sb.WriteString(fmt.Sprintf("%s %d. **%s** — €%d\n", medal, rank, name, int(balance))) + } + + if rank == 0 { + sb.WriteString("No balances yet.") + } + + return p.SendReply(ctx.RoomID, ctx.EventID, sb.String()) +} + +func (p *EuroPlugin) handleTransfer(ctx MessageContext) error { + args := p.GetArgs(ctx.Body, "baltransfer") + parts := strings.Fields(args) + if len(parts) < 2 { + return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: `!baltransfer @user €amount`") + } + + targetID, ok := p.ResolveUser(parts[0], ctx.RoomID) + if !ok { + return p.SendReply(ctx.RoomID, ctx.EventID, "Could not resolve user.") + } + if targetID == ctx.Sender { + return p.SendReply(ctx.RoomID, ctx.EventID, "You can't transfer to yourself.") + } + + amountStr := strings.TrimPrefix(parts[1], "€") + amount := 0.0 + fmt.Sscanf(amountStr, "%f", &amount) + if amount < 1 { + return p.SendReply(ctx.RoomID, ctx.EventID, "Minimum transfer is €1.") + } + + balance := p.GetBalance(ctx.Sender) + if balance < amount { + return p.SendReply(ctx.RoomID, ctx.EventID, + fmt.Sprintf("Insufficient balance. You have €%d.", int(balance))) + } + + if !p.Debit(ctx.Sender, amount, fmt.Sprintf("transfer to %s", targetID)) { + return p.SendReply(ctx.RoomID, ctx.EventID, "Transfer failed.") + } + p.Credit(targetID, amount, fmt.Sprintf("transfer from %s", ctx.Sender)) + + senderName := p.displayName(ctx.Sender) + targetName := p.displayName(targetID) + return p.SendReply(ctx.RoomID, ctx.EventID, + fmt.Sprintf("💸 **%s** sent €%d to **%s**.", senderName, int(amount), targetName)) +} + +func (p *EuroPlugin) displayName(userID id.UserID) string { + resp, err := p.Client.GetDisplayName(context.Background(), userID) + if err != nil || resp.DisplayName == "" { + return string(userID) + } + return resp.DisplayName +} diff --git a/internal/plugin/flip.go b/internal/plugin/flip.go new file mode 100644 index 0000000..3cf25e3 --- /dev/null +++ b/internal/plugin/flip.go @@ -0,0 +1,80 @@ +package plugin + +import ( + "math/rand/v2" + + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/id" +) + +// FlipPlugin handles !flip (coin flip) restricted to the games room. +type FlipPlugin struct { + Base +} + +func NewFlipPlugin(client *mautrix.Client) *FlipPlugin { + return &FlipPlugin{Base: Base{Client: 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"}, + } +} + +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) + } + 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-2 players vs dealer)\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"+ + roomNote) +} + +// 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!" +} diff --git a/internal/plugin/hangman.go b/internal/plugin/hangman.go new file mode 100644 index 0000000..2d948c4 --- /dev/null +++ b/internal/plugin/hangman.go @@ -0,0 +1,728 @@ +package plugin + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "log/slog" + "math/rand/v2" + "os" + "strings" + "sync" + "unicode" + + "gogobee/internal/db" + + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/id" +) + +// --------------------------------------------------------------------------- +// ASCII Gallows +// --------------------------------------------------------------------------- + +var gallows = [7]string{ + // Stage 0 + ` + +---+ + | + | + | + | + ===`, + // Stage 1 + ` + +---+ + | | + | + | + | + ===`, + // Stage 2 + ` + +---+ + | | + | O + | + | + ===`, + // Stage 3 + ` + +---+ + | | + | O + | | + | + ===`, + // Stage 4 + ` + +---+ + | | + | O + | /| + | + ===`, + // Stage 5 + ` + +---+ + | | + | O + | /|\ + | + ===`, + // Stage 6 (dead) + ` + +---+ + | | + | O + | /|\ + | / \ + ===`, +} + +// --------------------------------------------------------------------------- +// Hangman config and types +// --------------------------------------------------------------------------- + +type hangmanTier struct { + Name string + Min int + Max int + Bonus float64 +} + +var hangmanTiers = []hangmanTier{ + {"Easy", 8, 20, 25}, + {"Medium", 21, 40, 75}, + {"Hard", 41, 80, 200}, + {"Extreme", 81, 9999, 500}, +} + +func getTier(phrase string) hangmanTier { + n := len(phrase) + for _, t := range hangmanTiers { + if n >= t.Min && n <= t.Max { + return t + } + } + return hangmanTiers[0] +} + +type hangmanGame struct { + phrase string + tier hangmanTier + revealed []bool // true for each char that is revealed + wrongGuesses []rune + maxWrong int + participants map[id.UserID]bool + dmVerified map[id.UserID]bool // true = DM succeeded + solved bool + solvedBy id.UserID + earlySolve bool +} + +func newHangmanGame(phrase string, maxWrong int) *hangmanGame { + revealed := make([]bool, len(phrase)) + // Auto-reveal spaces and punctuation + for i, ch := range phrase { + if !unicode.IsLetter(ch) && !unicode.IsDigit(ch) { + revealed[i] = true + } + } + return &hangmanGame{ + phrase: phrase, + tier: getTier(phrase), + revealed: revealed, + maxWrong: maxWrong, + participants: make(map[id.UserID]bool), + dmVerified: make(map[id.UserID]bool), + } +} + +func (g *hangmanGame) wrongCount() int { + return len(g.wrongGuesses) +} + +func (g *hangmanGame) remaining() int { + return g.maxWrong - g.wrongCount() +} + +func (g *hangmanGame) isDead() bool { + return g.wrongCount() >= g.maxWrong +} + +func (g *hangmanGame) isFullyRevealed() bool { + for _, r := range g.revealed { + if !r { + return false + } + } + return true +} + +func (g *hangmanGame) revealedCount() int { + letterTotal := 0 + letterRevealed := 0 + for i, ch := range g.phrase { + if unicode.IsLetter(ch) || unicode.IsDigit(ch) { + letterTotal++ + if g.revealed[i] { + letterRevealed++ + } + } + } + if letterTotal == 0 { + return 0 + } + return letterRevealed +} + +func (g *hangmanGame) letterCount() int { + count := 0 + for _, ch := range g.phrase { + if unicode.IsLetter(ch) || unicode.IsDigit(ch) { + count++ + } + } + return count +} + +func (g *hangmanGame) displayPhrase() string { + var sb strings.Builder + for i, ch := range g.phrase { + if g.revealed[i] { + sb.WriteRune(ch) + } else { + sb.WriteRune('_') + } + // Add space between characters for readability + if i < len(g.phrase)-1 { + next := rune(g.phrase[i+1]) + if !g.revealed[i] || !g.revealed[i+1] || unicode.IsLetter(ch) || unicode.IsLetter(next) { + if ch != ' ' && next != ' ' { + sb.WriteRune(' ') + } + } + } + } + return sb.String() +} + +func (g *hangmanGame) guessLetter(ch rune) (hit bool, alreadyGuessed bool) { + ch = unicode.ToUpper(ch) + lower := unicode.ToLower(ch) + + // Check if already guessed (wrong list or already revealed) + for _, w := range g.wrongGuesses { + if unicode.ToUpper(w) == ch { + return false, true + } + } + + hit = false + for i, c := range g.phrase { + if unicode.ToUpper(c) == ch || unicode.ToLower(c) == lower { + if g.revealed[i] { + // Already revealed — check if ALL instances are revealed + allRevealed := true + for j, cc := range g.phrase { + if (unicode.ToUpper(cc) == ch || unicode.ToLower(cc) == lower) && !g.revealed[j] { + allRevealed = false + } + } + if allRevealed { + return false, true + } + } + g.revealed[i] = true + hit = true + } + } + + // Reveal adjacent punctuation when a letter is revealed + if hit { + for i, ch := range g.phrase { + if g.revealed[i] { + // Reveal adjacent non-letter chars + if i > 0 && !unicode.IsLetter(rune(g.phrase[i-1])) && !unicode.IsDigit(rune(g.phrase[i-1])) { + g.revealed[i-1] = true + } + if i < len(g.phrase)-1 && !unicode.IsLetter(rune(g.phrase[i+1])) && !unicode.IsDigit(rune(g.phrase[i+1])) { + g.revealed[i+1] = true + } + _ = ch + } + } + } + + if !hit { + g.wrongGuesses = append(g.wrongGuesses, ch) + } + return hit, false +} + +func (g *hangmanGame) guessSolution(attempt string) bool { + return strings.EqualFold(strings.TrimSpace(attempt), g.phrase) +} + +func (g *hangmanGame) wrongGuessStr() string { + if len(g.wrongGuesses) == 0 { + return "none" + } + parts := make([]string, len(g.wrongGuesses)) + for i, r := range g.wrongGuesses { + parts[i] = string(unicode.ToUpper(r)) + } + return strings.Join(parts, ", ") +} + +// --------------------------------------------------------------------------- +// Plugin +// --------------------------------------------------------------------------- + +type HangmanPlugin struct { + Base + euro *EuroPlugin + phrases []string + maxWrong int + bonusMul float64 + + mu sync.Mutex + games map[id.RoomID]*hangmanGame +} + +func NewHangmanPlugin(client *mautrix.Client, euro *EuroPlugin) *HangmanPlugin { + return &HangmanPlugin{ + Base: Base{Client: client}, + euro: euro, + maxWrong: envInt("HANGMAN_MAX_WRONG_GUESSES", 6), + bonusMul: envFloat("HANGMAN_SOLUTION_BONUS_MULTIPLIER", 2), + games: make(map[id.RoomID]*hangmanGame), + } +} + +func (p *HangmanPlugin) Name() string { return "hangman" } + +func (p *HangmanPlugin) Commands() []CommandDef { + return []CommandDef{ + {Name: "hangman", Description: "Collaborative Hangman game", Usage: "!hangman start | !hangman [letter/phrase] | !hangman submit [phrase]", Category: "Games"}, + {Name: "hangboard", Description: "Hangman leaderboard", Usage: "!hangboard", Category: "Games"}, + } +} + +func (p *HangmanPlugin) Init() error { + path := os.Getenv("HANGMAN_PHRASE_FILE") + if path != "" { + p.loadPhrases(path) + } + return nil +} + +func (p *HangmanPlugin) OnReaction(_ ReactionContext) error { return nil } + +func (p *HangmanPlugin) OnMessage(ctx MessageContext) error { + if p.IsCommand(ctx.Body, "hangboard") { + return p.handleBoard(ctx) + } + + if !p.IsCommand(ctx.Body, "hangman") { + return nil + } + + if !isGamesRoom(ctx.RoomID) { + return p.SendReply(ctx.RoomID, ctx.EventID, "Games are only available in the games channel!") + } + + args := strings.TrimSpace(p.GetArgs(ctx.Body, "hangman")) + + switch { + case args == "" || strings.EqualFold(args, "start"): + return p.handleStart(ctx) + case strings.HasPrefix(strings.ToLower(args), "submit "): + return p.handleSubmit(ctx, strings.TrimSpace(args[7:])) + case strings.EqualFold(args, "skip"): + return p.handleSkip(ctx) + default: + return p.handleGuess(ctx, args) + } +} + +// --------------------------------------------------------------------------- +// Phrase management +// --------------------------------------------------------------------------- + +func (p *HangmanPlugin) loadPhrases(path string) { + f, err := os.Open(path) + if err != nil { + slog.Warn("hangman: failed to load phrases", "path", path, "err", err) + return + } + defer f.Close() + + var phrases []string + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line != "" && !strings.HasPrefix(line, "#") { + phrases = append(phrases, line) + } + } + p.phrases = phrases + slog.Info("hangman: phrases loaded", "count", len(phrases)) +} + +func (p *HangmanPlugin) addPhrase(phrase string) error { + path := os.Getenv("HANGMAN_PHRASE_FILE") + if path == "" { + return fmt.Errorf("no phrase file configured") + } + + // Duplicate check + for _, existing := range p.phrases { + if strings.EqualFold(existing, phrase) { + return fmt.Errorf("duplicate phrase") + } + } + + f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0644) + if err != nil { + return err + } + defer f.Close() + + _, err = fmt.Fprintln(f, phrase) + if err != nil { + return err + } + + p.phrases = append(p.phrases, phrase) + return nil +} + +// --------------------------------------------------------------------------- +// Game commands +// --------------------------------------------------------------------------- + +func (p *HangmanPlugin) handleStart(ctx MessageContext) error { + p.mu.Lock() + defer p.mu.Unlock() + + if _, active := p.games[ctx.RoomID]; active { + return p.SendReply(ctx.RoomID, ctx.EventID, "A Hangman game is already in progress! Guess with `!hangman [letter]`") + } + + if len(p.phrases) == 0 { + return p.SendReply(ctx.RoomID, ctx.EventID, "No phrases loaded. Ask an admin to set up HANGMAN_PHRASE_FILE.") + } + + phrase := p.phrases[rand.IntN(len(p.phrases))] + game := newHangmanGame(phrase, p.maxWrong) + p.games[ctx.RoomID] = game + + return p.SendMessage(ctx.RoomID, fmt.Sprintf( + "🎮 **Hangman!** Tier: **%s** | %d guesses allowed\n\n```\n%s\n```\n%s\n\nGuess with `!hangman [letter]` or `!hangman [full phrase]`", + game.tier.Name, game.maxWrong, gallows[0], game.displayPhrase(), + )) +} + +func (p *HangmanPlugin) handleGuess(ctx MessageContext, guess string) error { + p.mu.Lock() + game, active := p.games[ctx.RoomID] + if !active { + p.mu.Unlock() + return p.SendReply(ctx.RoomID, ctx.EventID, "No Hangman game in progress. Start one with `!hangman start`") + } + p.mu.Unlock() + + // Register participant + game.participants[ctx.Sender] = true + + // DM verification on first guess + if _, checked := game.dmVerified[ctx.Sender]; !checked { + err := p.SendDM(ctx.Sender, fmt.Sprintf("🎮 You've joined the Hangman game!\nPhrase: %s", game.displayPhrase())) + game.dmVerified[ctx.Sender] = err == nil + } + + guess = strings.TrimSpace(guess) + + // Single letter guess + if len([]rune(guess)) == 1 { + ch := []rune(guess)[0] + if !unicode.IsLetter(ch) { + return p.SendReply(ctx.RoomID, ctx.EventID, "Please guess a letter.") + } + return p.processLetterGuess(ctx, game, ch) + } + + // Full solution attempt + return p.processSolutionGuess(ctx, game, guess) +} + +func (p *HangmanPlugin) processLetterGuess(ctx MessageContext, game *hangmanGame, ch rune) error { + hit, already := game.guessLetter(ch) + + if already { + return p.SendReply(ctx.RoomID, ctx.EventID, + fmt.Sprintf("'%c' was already guessed.", unicode.ToUpper(ch))) + } + + // DM the guesser + if game.dmVerified[ctx.Sender] { + result := "❌" + if hit { + result = "✅" + } + _ = p.SendDM(ctx.Sender, fmt.Sprintf( + "🎮 Hangman update\nPhrase: %s\nYour guess: %c %s\nWrong guesses so far: %s\nGuesses remaining: %d", + game.displayPhrase(), unicode.ToUpper(ch), result, game.wrongGuessStr(), game.remaining(), + )) + } + + if hit { + if game.isFullyRevealed() { + game.solved = true + game.solvedBy = ctx.Sender + return p.endGame(ctx.RoomID, game) + } + return p.SendMessage(ctx.RoomID, fmt.Sprintf( + "✅ '%c' is in the phrase! (%d guesses remaining)\n%s\n\nWrong guesses: %s", + unicode.ToUpper(ch), game.remaining(), game.displayPhrase(), game.wrongGuessStr(), + )) + } + + // Wrong guess + if game.isDead() { + return p.endGame(ctx.RoomID, game) + } + + return p.SendMessage(ctx.RoomID, fmt.Sprintf( + "❌ Wrong! (%d guesses remaining)\n```\n%s\n```\n%s\n\nWrong guesses: %s", + game.remaining(), gallows[game.wrongCount()], game.displayPhrase(), game.wrongGuessStr(), + )) +} + +func (p *HangmanPlugin) processSolutionGuess(ctx MessageContext, game *hangmanGame, guess string) error { + if game.guessSolution(guess) { + game.solved = true + game.solvedBy = ctx.Sender + // Check for early solve (less than half letters revealed) + if game.revealedCount() < game.letterCount()/2 { + game.earlySolve = true + } + return p.endGame(ctx.RoomID, game) + } + + // Wrong solution — costs a life + game.wrongGuesses = append(game.wrongGuesses, '?') // placeholder for wrong solution + + if game.isDead() { + return p.endGame(ctx.RoomID, game) + } + + return p.SendMessage(ctx.RoomID, fmt.Sprintf( + "❌ Wrong solution! (%d guesses remaining)\n```\n%s\n```\n%s\n\nWrong guesses: %s", + game.remaining(), gallows[game.wrongCount()], game.displayPhrase(), game.wrongGuessStr(), + )) +} + +func (p *HangmanPlugin) endGame(roomID id.RoomID, game *hangmanGame) error { + p.mu.Lock() + delete(p.games, roomID) + p.mu.Unlock() + + if !game.solved { + return p.SendMessage(roomID, fmt.Sprintf( + "💀 Game over! The phrase was:\n\"%s\"\n```\n%s\n```\nNobody solved it this time.", + game.phrase, gallows[6], + )) + } + + // Calculate payouts + eligibleParticipants := make([]id.UserID, 0) + for uid := range game.participants { + if game.dmVerified[uid] { + eligibleParticipants = append(eligibleParticipants, uid) + } + } + + solverName := p.displayName(game.solvedBy) + participantCount := len(eligibleParticipants) + + var sb strings.Builder + sb.WriteString(fmt.Sprintf("🎉 Solved by **%s**!\n\"%s\"\nTier: %s | Participants: %d\n\n", + solverName, game.phrase, game.tier.Name, participantCount, + )) + + if participantCount > 0 { + basePot := game.tier.Bonus + share := basePot / float64(participantCount) + + sb.WriteString("**Payouts:**\n") + for _, uid := range eligibleParticipants { + payout := share + label := "" + if uid == game.solvedBy && game.earlySolve { + payout = share * p.bonusMul + label = " (early solve bonus!)" + } + p.euro.Credit(uid, payout, "hangman_win") + p.recordHangmanScore(uid, payout) + name := p.displayName(uid) + sb.WriteString(fmt.Sprintf(" **%s**: +€%d%s\n", name, int(payout), label)) + } + } + + return p.SendMessage(roomID, sb.String()) +} + +func (p *HangmanPlugin) handleSkip(ctx MessageContext) error { + if !p.IsAdmin(ctx.Sender) { + return nil + } + + p.mu.Lock() + game, active := p.games[ctx.RoomID] + if !active { + p.mu.Unlock() + return p.SendReply(ctx.RoomID, ctx.EventID, "No game in progress.") + } + delete(p.games, ctx.RoomID) + p.mu.Unlock() + + return p.SendMessage(ctx.RoomID, fmt.Sprintf( + "⏭️ Game skipped! The phrase was:\n\"%s\"", game.phrase)) +} + +func (p *HangmanPlugin) handleSubmit(ctx MessageContext, phrase string) error { + if len(phrase) < 8 { + return p.SendReply(ctx.RoomID, ctx.EventID, "Phrase must be at least 8 characters.") + } + + // LLM screening + ollamaHost := os.Getenv("OLLAMA_HOST") + ollamaModel := os.Getenv("OLLAMA_MODEL") + if ollamaHost == "" || ollamaModel == "" { + // No LLM available — add directly + if err := p.addPhrase(phrase); err != nil { + if err.Error() == "duplicate phrase" { + return p.SendReply(ctx.RoomID, ctx.EventID, "That phrase already exists.") + } + return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to save phrase.") + } + _ = p.SendDM(ctx.Sender, "Your phrase has been added to the Hangman pool. Thanks!") + return p.SendReply(ctx.RoomID, ctx.EventID, "Phrase submitted and added!") + } + + prompt := fmt.Sprintf(`You are screening community submissions for a Hangman game. Evaluate only whether the phrase is offensive, hateful, sexually explicit, or otherwise inappropriate for a general adult audience. + +Respond only in JSON: +{ "approved": true } +or +{ "approved": false, "reason": "one sentence explanation" } + +Phrase: %s`, phrase) + + result, err := callOllama(ollamaHost, ollamaModel, prompt) + if err != nil { + slog.Error("hangman: LLM screening failed", "err", err) + // Fail open — add it + if addErr := p.addPhrase(phrase); addErr != nil { + return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to save phrase.") + } + _ = p.SendDM(ctx.Sender, "Your phrase has been added to the Hangman pool. Thanks!") + return p.SendReply(ctx.RoomID, ctx.EventID, "Phrase submitted and added!") + } + + var screening struct { + Approved bool `json:"approved"` + Reason string `json:"reason"` + } + + // Extract JSON from response + jsonStr := result + if idx := strings.Index(result, "{"); idx >= 0 { + if end := strings.LastIndex(result, "}"); end >= idx { + jsonStr = result[idx : end+1] + } + } + + if err := json.Unmarshal([]byte(jsonStr), &screening); err != nil { + // Parse failed — add it + if addErr := p.addPhrase(phrase); addErr != nil { + return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to save phrase.") + } + _ = p.SendDM(ctx.Sender, "Your phrase has been added to the Hangman pool. Thanks!") + return p.SendReply(ctx.RoomID, ctx.EventID, "Phrase submitted and added!") + } + + if !screening.Approved { + _ = p.SendDM(ctx.Sender, fmt.Sprintf("Your Hangman phrase was not approved: %s", screening.Reason)) + return p.SendReply(ctx.RoomID, ctx.EventID, "Phrase reviewed — check your DMs for details.") + } + + if err := p.addPhrase(phrase); err != nil { + if err.Error() == "duplicate phrase" { + return p.SendReply(ctx.RoomID, ctx.EventID, "That phrase already exists.") + } + return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to save phrase.") + } + + _ = p.SendDM(ctx.Sender, "Your phrase has been added to the Hangman pool. Thanks!") + return p.SendReply(ctx.RoomID, ctx.EventID, "Phrase submitted and added!") +} + +// --------------------------------------------------------------------------- +// Leaderboard +// --------------------------------------------------------------------------- + +func (p *HangmanPlugin) handleBoard(ctx MessageContext) error { + d := db.Get() + rows, err := d.Query( + `SELECT user_id, total_earned, games_won FROM hangman_scores ORDER BY total_earned DESC LIMIT 10`, + ) + if err != nil { + return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to fetch leaderboard.") + } + defer rows.Close() + + var sb strings.Builder + sb.WriteString("🎮 **Hangman Leaderboard**\n\n") + rank := 0 + for rows.Next() { + var userID string + var earned float64 + var won int + rows.Scan(&userID, &earned, &won) + rank++ + name := p.displayName(id.UserID(userID)) + sb.WriteString(fmt.Sprintf("%d. **%s** — €%d earned (%d wins)\n", rank, name, int(earned), won)) + } + + if rank == 0 { + sb.WriteString("No games played yet.") + } + + return p.SendReply(ctx.RoomID, ctx.EventID, sb.String()) +} + +func (p *HangmanPlugin) recordHangmanScore(userID id.UserID, earned float64) { + d := db.Get() + _, _ = d.Exec( + `INSERT INTO hangman_scores (user_id, total_earned, games_played, games_won) + VALUES (?, ?, 1, 1) + ON CONFLICT(user_id) DO UPDATE SET + total_earned = total_earned + ?, + games_played = games_played + 1, + games_won = games_won + 1`, + string(userID), earned, earned, + ) +} + +func (p *HangmanPlugin) displayName(userID id.UserID) string { + resp, err := p.Client.GetDisplayName(context.Background(), userID) + if err != nil || resp.DisplayName == "" { + return string(userID) + } + return resp.DisplayName +} diff --git a/internal/plugin/trivia.go b/internal/plugin/trivia.go index 9856d4e..a9a73b2 100644 --- a/internal/plugin/trivia.go +++ b/internal/plugin/trivia.go @@ -121,6 +121,9 @@ func (p *TriviaPlugin) OnMessage(ctx MessageContext) error { } if p.IsCommand(ctx.Body, "trivia") { + if !isGamesRoom(ctx.RoomID) { + return p.SendReply(ctx.RoomID, ctx.EventID, "Games are only available in the games channel!") + } args := p.GetArgs(ctx.Body, "trivia") return p.handleTrivia(ctx, args) } diff --git a/main.go b/main.go index a9ae2e0..0a69295 100644 --- a/main.go +++ b/main.go @@ -106,6 +106,13 @@ func main() { moviesPlugin := plugin.NewMoviesPlugin(client) registry.Register(moviesPlugin) + // Games & Economy + euroPlugin := plugin.NewEuroPlugin(client) + registry.Register(euroPlugin) + registry.Register(plugin.NewFlipPlugin(client)) + registry.Register(plugin.NewHangmanPlugin(client, euroPlugin)) + registry.Register(plugin.NewBlackjackPlugin(client, euroPlugin)) + // Community registry.Register(plugin.NewMilkCartonPlugin(client, ratePlugin)) registry.Register(plugin.NewQuoteWallPlugin(client, ratePlugin))