mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 00:32:40 +00:00
Add games system: euro economy, Hangman, Blackjack, coin flip
Euro economy: virtual currency earned passively per message (tiered by word count, 30s cooldown). Starting balance seeded from corpus character count. Balance, leaderboard, and transfer commands. Debt system with configurable limit. Hangman: collaborative game with phrase pool loaded from file. ASCII gallows display, tiered scoring (Easy/Medium/Hard/Extreme), early solution bonus, participant tracking with DM verification. Community phrase submission with LLM screening. Blackjack: 1-2 players vs dealer. Standard casino rules (hit soft 17). Auto-play on timeout. Bet validation against balance and debt limit. Blackjack pays 1.5x. Separate leaderboard. Games channel restriction: all game commands (trivia, hangman, blackjack, flip) restricted to GAMES_ROOM. Dice (!roll) exempt. Trivia also restricted. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
14
.env.example
14
.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)
|
||||
|
||||
|
||||
48
README.md
48
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
|
||||
|
||||
@@ -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,
|
||||
|
||||
711
internal/plugin/blackjack.go
Normal file
711
internal/plugin/blackjack.go
Normal file
@@ -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
|
||||
}
|
||||
367
internal/plugin/euro.go
Normal file
367
internal/plugin/euro.go
Normal file
@@ -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
|
||||
}
|
||||
80
internal/plugin/flip.go
Normal file
80
internal/plugin/flip.go
Normal file
@@ -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!"
|
||||
}
|
||||
728
internal/plugin/hangman.go
Normal file
728
internal/plugin/hangman.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
7
main.go
7
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))
|
||||
|
||||
Reference in New Issue
Block a user