Add UNO game, hangman threading, DM room reuse, and gameplay fixes

- Add full UNO game plugin (1v1 vs bot via DM, community pot, bot personality)
- Move hangman into threads with direct reply guessing (no !hangman prefix needed)
- Fix DM room duplication by checking m.direct account data with in-memory cache
- Auto-draw when player has no playable cards instead of requiring manual draw
- Remove forced UNO call phase — players are penalized naturally if they forget
- Fix mutex held during network I/O in hangman start
- Fix infinite loop when both sides have no playable cards and deck is empty
- Fix Wild Draw Four penalty being skipped when UNO prompt triggered
- Fix hangman displayPhrase word boundary clarity (triple-wide gaps for spaces)
- Add UNO env vars, db tables, and README updates

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
prosolis
2026-03-11 23:52:38 -07:00
parent 992b62c51f
commit 7c9dd28021
7 changed files with 1466 additions and 63 deletions

View File

@@ -51,6 +51,8 @@ BLACKJACK_AUTOPLAY_THRESHOLD=15
BLACKJACK_MIN_BET=1 BLACKJACK_MIN_BET=1
BLACKJACK_MAX_BET=500 BLACKJACK_MAX_BET=500
BLACKJACK_DEBT_LIMIT=1000 # max debt before betting disabled BLACKJACK_DEBT_LIMIT=1000 # max debt before betting disabled
UNO_MIN_BET=10 # minimum wager in euros
UNO_POT_TAUNT_THRESHOLD=500 # pot size at which GogoBee starts taunting
# ---- Space Groups (automatic room grouping for community-wide leaderboards) ---- # ---- 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) SPACE_GROUP_THRESHOLD=50 # % of smaller room's members that must overlap (1-100, default 50)

View File

@@ -285,7 +285,7 @@ Rep is earned when someone thanks you. The bot detects this automatically.
|---------|-------------| |---------|-------------|
| `!flip` | Coin flip | | `!flip` | Coin flip |
| `!games` | List available games | | `!games` | List available games |
| `!hangman start` | Start a Hangman game | | `!hangman start [easy\|medium\|hard\|extreme]` | Start a Hangman game (optional difficulty) |
| `!hangman [letter]` | Guess a letter | | `!hangman [letter]` | Guess a letter |
| `!hangman [phrase]` | Attempt full solution | | `!hangman [phrase]` | Attempt full solution |
| `!hangman submit [phrase]` | Submit a phrase to the pool | | `!hangman submit [phrase]` | Submit a phrase to the pool |
@@ -450,6 +450,7 @@ All of these run in the background without any commands:
- **Keyword alerts** - DMs you when someone says your watched keywords - **Keyword alerts** - DMs you when someone says your watched keywords
- **Presence** - auto-clears away/afk when you send a message - **Presence** - auto-clears away/afk when you send a message
- **Room milestones** - announces at 1k, 5k, 10k, 25k, 50k, 100k, 250k, 500k, 1M messages - **Room milestones** - announces at 1k, 5k, 10k, 25k, 50k, 100k, 250k, 500k, 1M messages
- **Euro earning** - earn €0.50€10 per message based on word count (30s cooldown). Starting balance seeded from corpus history (capped at €2,500)
- **URL previews** - OG tag extraction (feature-flagged, off by default) - **URL previews** - OG tag extraction (feature-flagged, off by default)
- **Reactions** - logs all reactions for `!emojiboard` - **Reactions** - logs all reactions for `!emojiboard`
- **Space groups** - rooms with sufficient member overlap are automatically grouped. Leaderboards, trivia scores, and other per-room features aggregate across the group. Recomputed hourly; persisted to SQLite. Uses strict clique-based grouping (every room must meet the threshold with every other room in the group). - **Space groups** - rooms with sufficient member overlap are automatically grouped. Leaderboards, trivia scores, and other per-room features aggregate across the group. Recomputed hourly; persisted to SQLite. Uses strict clique-based grouping (every room must meet the threshold with every other room in the group).

View File

@@ -678,6 +678,25 @@ CREATE TABLE IF NOT EXISTS mod_actions (
); );
CREATE INDEX IF NOT EXISTS idx_mod_actions_user ON mod_actions(user_id, taken_at); CREATE INDEX IF NOT EXISTS idx_mod_actions_user ON mod_actions(user_id, taken_at);
-- Uno
CREATE TABLE IF NOT EXISTS uno_pot (
id INTEGER PRIMARY KEY CHECK (id = 1),
balance REAL NOT NULL DEFAULT 0,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS uno_games (
id INTEGER PRIMARY KEY AUTOINCREMENT,
player_id TEXT NOT NULL,
wager REAL NOT NULL,
result TEXT NOT NULL,
pot_before REAL NOT NULL,
pot_after REAL NOT NULL,
turns INTEGER NOT NULL,
started_at DATETIME NOT NULL,
ended_at DATETIME NOT NULL
);
-- Space groups (rooms with overlapping membership) -- Space groups (rooms with overlapping membership)
CREATE TABLE IF NOT EXISTS space_groups ( CREATE TABLE IF NOT EXISTS space_groups (
room_id TEXT PRIMARY KEY, room_id TEXT PRIMARY KEY,

View File

@@ -15,6 +15,7 @@ import (
"gogobee/internal/db" "gogobee/internal/db"
"maunium.net/go/mautrix" "maunium.net/go/mautrix"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id" "maunium.net/go/mautrix/id"
) )
@@ -117,10 +118,10 @@ type hangmanGame struct {
wrongGuesses []rune wrongGuesses []rune
maxWrong int maxWrong int
participants map[id.UserID]bool participants map[id.UserID]bool
dmVerified map[id.UserID]bool // true = DM succeeded
solved bool solved bool
solvedBy id.UserID solvedBy id.UserID
earlySolve bool earlySolve bool
threadID id.EventID // thread root event for this game
} }
func newHangmanGame(phrase string, maxWrong int) *hangmanGame { func newHangmanGame(phrase string, maxWrong int) *hangmanGame {
@@ -139,7 +140,6 @@ func newHangmanGame(phrase string, maxWrong int) *hangmanGame {
revealed: revealed, revealed: revealed,
maxWrong: maxWrong, maxWrong: maxWrong,
participants: make(map[id.UserID]bool), participants: make(map[id.UserID]bool),
dmVerified: make(map[id.UserID]bool),
} }
} }
@@ -187,22 +187,16 @@ func (g *hangmanGame) letterCount() int {
func (g *hangmanGame) displayPhrase() string { func (g *hangmanGame) displayPhrase() string {
var sb strings.Builder var sb strings.Builder
for i, ch := range g.runes { for i, ch := range g.runes {
if g.revealed[i] { if ch == ' ' {
sb.WriteString(" ") // wide gap for word boundaries
} else if g.revealed[i] {
sb.WriteRune(ch) sb.WriteRune(ch)
sb.WriteRune(' ')
} else { } else {
sb.WriteRune('_') sb.WriteString("_ ")
}
// Add space between characters for readability
if i < len(g.runes)-1 {
next := g.runes[i+1]
if !g.revealed[i] || !g.revealed[i+1] || unicode.IsLetter(ch) || unicode.IsLetter(next) {
if ch != ' ' && next != ' ' {
sb.WriteRune(' ')
}
}
} }
} }
return sb.String() return strings.TrimRight(sb.String(), " ")
} }
func (g *hangmanGame) guessLetter(ch rune) (hit bool, alreadyGuessed bool) { func (g *hangmanGame) guessLetter(ch rune) (hit bool, alreadyGuessed bool) {
@@ -322,6 +316,26 @@ func (p *HangmanPlugin) OnMessage(ctx MessageContext) error {
} }
if !p.IsCommand(ctx.Body, "hangman") { if !p.IsCommand(ctx.Body, "hangman") {
// Check if this is a thread reply to an active hangman game
if !isGamesRoom(ctx.RoomID) {
return nil
}
p.mu.Lock()
game, active := p.games[ctx.RoomID]
p.mu.Unlock()
if active && game != nil {
content := ctx.Event.Content.AsMessage()
if content != nil && content.RelatesTo != nil &&
content.RelatesTo.Type == event.RelThread &&
content.RelatesTo.EventID == game.threadID {
guess := strings.TrimSpace(ctx.Body)
if guess != "" {
return p.handleGuess(ctx, guess)
}
}
}
return nil return nil
} }
@@ -404,13 +418,14 @@ func (p *HangmanPlugin) addPhrase(phrase string) error {
func (p *HangmanPlugin) handleStart(ctx MessageContext, difficulty string) error { func (p *HangmanPlugin) handleStart(ctx MessageContext, difficulty string) error {
p.mu.Lock() p.mu.Lock()
defer p.mu.Unlock()
if _, active := p.games[ctx.RoomID]; active { 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]`") p.mu.Unlock()
return p.SendReply(ctx.RoomID, ctx.EventID, "A Hangman game is already in progress!")
} }
if len(p.phrases) == 0 { if len(p.phrases) == 0 {
p.mu.Unlock()
return p.SendReply(ctx.RoomID, ctx.EventID, "No phrases loaded. Ask an admin to set up HANGMAN_PHRASE_FILE.") return p.SendReply(ctx.RoomID, ctx.EventID, "No phrases loaded. Ask an admin to set up HANGMAN_PHRASE_FILE.")
} }
@@ -425,6 +440,7 @@ func (p *HangmanPlugin) handleStart(ctx MessageContext, difficulty string) error
} }
} }
if tier == nil { if tier == nil {
p.mu.Unlock()
names := make([]string, len(hangmanTiers)) names := make([]string, len(hangmanTiers))
for i, t := range hangmanTiers { for i, t := range hangmanTiers {
names[i] = t.Name names[i] = t.Name
@@ -439,6 +455,7 @@ func (p *HangmanPlugin) handleStart(ctx MessageContext, difficulty string) error
} }
} }
if len(filtered) == 0 { if len(filtered) == 0 {
p.mu.Unlock()
return p.SendReply(ctx.RoomID, ctx.EventID, return p.SendReply(ctx.RoomID, ctx.EventID,
fmt.Sprintf("No phrases available for **%s** difficulty.", tier.Name)) fmt.Sprintf("No phrases available for **%s** difficulty.", tier.Name))
} }
@@ -447,11 +464,33 @@ func (p *HangmanPlugin) handleStart(ctx MessageContext, difficulty string) error
phrase := pool[rand.IntN(len(pool))] phrase := pool[rand.IntN(len(pool))]
game := newHangmanGame(phrase, p.maxWrong) game := newHangmanGame(phrase, p.maxWrong)
p.games[ctx.RoomID] = game
return p.SendMessage(ctx.RoomID, fmt.Sprintf( // Reserve the slot so no concurrent start can race us
"🎮 **Hangman!** Tier: **%s** | %d guesses allowed\n\n```\n%s\n```\n%s\n\nGuess with `!hangman [letter]` or `!hangman [full phrase]`", p.games[ctx.RoomID] = game
p.mu.Unlock()
// Create thread root message (network I/O outside mutex)
threadRoot := fmt.Sprintf(
"🎮 **Hangman!** Tier: **%s** | %d guesses allowed\n\n```\n%s\n```\n%s\n\nReply in this thread with a letter or the full phrase to guess!",
game.tier.Name, game.maxWrong, gallows[0], game.displayPhrase(), game.tier.Name, game.maxWrong, gallows[0], game.displayPhrase(),
)
eventID, err := p.SendMessageID(ctx.RoomID, threadRoot)
if err != nil {
// Roll back reservation
p.mu.Lock()
delete(p.games, ctx.RoomID)
p.mu.Unlock()
return err
}
p.mu.Lock()
game.threadID = eventID
p.mu.Unlock()
// Send an initial thread reply to materialize the thread in clients
return p.SendThread(ctx.RoomID, eventID, fmt.Sprintf(
"```\n%s\n```\n%s\n\nWrong guesses: none",
gallows[0], game.displayPhrase(),
)) ))
} }
@@ -463,34 +502,23 @@ func (p *HangmanPlugin) handleGuess(ctx MessageContext, guess string) error {
return p.SendReply(ctx.RoomID, ctx.EventID, "No Hangman game in progress. Start one with `!hangman start`") return p.SendReply(ctx.RoomID, ctx.EventID, "No Hangman game in progress. Start one with `!hangman start`")
} }
// Game exists but thread not yet created (start in progress)
if game.threadID == "" {
p.mu.Unlock()
return nil
}
// Register participant (under lock) // Register participant (under lock)
game.participants[ctx.Sender] = true game.participants[ctx.Sender] = true
// DM verification on first guess — need to unlock for network call
needDM := false
if _, checked := game.dmVerified[ctx.Sender]; !checked {
needDM = true
}
displayForDM := ""
if needDM {
displayForDM = game.displayPhrase()
}
p.mu.Unlock() p.mu.Unlock()
if needDM {
err := p.SendDM(ctx.Sender, fmt.Sprintf("🎮 You've joined the Hangman game!\nPhrase: %s", displayForDM))
p.mu.Lock()
game.dmVerified[ctx.Sender] = err == nil
p.mu.Unlock()
}
guess = strings.TrimSpace(guess) guess = strings.TrimSpace(guess)
// Single letter guess // Single letter guess
if len([]rune(guess)) == 1 { if len([]rune(guess)) == 1 {
ch := []rune(guess)[0] ch := []rune(guess)[0]
if !unicode.IsLetter(ch) { if !unicode.IsLetter(ch) {
return p.SendReply(ctx.RoomID, ctx.EventID, "Please guess a letter.") return p.SendThread(ctx.RoomID, game.threadID, "Please guess a letter.")
} }
return p.processLetterGuess(ctx, game, ch) return p.processLetterGuess(ctx, game, ch)
} }
@@ -505,13 +533,13 @@ func (p *HangmanPlugin) processLetterGuess(ctx MessageContext, game *hangmanGame
hit, already := game.guessLetter(ch) hit, already := game.guessLetter(ch)
if already { if already {
threadID := game.threadID
p.mu.Unlock() p.mu.Unlock()
return p.SendReply(ctx.RoomID, ctx.EventID, return p.SendThread(ctx.RoomID, threadID,
fmt.Sprintf("'%c' was already guessed.", unicode.ToUpper(ch))) fmt.Sprintf("'%c' was already guessed.", unicode.ToUpper(ch)))
} }
// Snapshot state for messages before unlocking // Snapshot state for messages before unlocking
shouldDM := game.dmVerified[ctx.Sender]
display := game.displayPhrase() display := game.displayPhrase()
wrongStr := game.wrongGuessStr() wrongStr := game.wrongGuessStr()
remaining := game.remaining() remaining := game.remaining()
@@ -531,23 +559,11 @@ func (p *HangmanPlugin) processLetterGuess(ctx MessageContext, game *hangmanGame
wrongCount := game.wrongCount() wrongCount := game.wrongCount()
p.mu.Unlock() p.mu.Unlock()
// DM the guesser (outside lock)
if shouldDM {
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",
display, unicode.ToUpper(ch), result, wrongStr, remaining,
))
}
if hit { if hit {
if fullyRevealed { if fullyRevealed {
return p.endGame(ctx.RoomID, game) return p.endGame(ctx.RoomID, game)
} }
return p.SendMessage(ctx.RoomID, fmt.Sprintf( return p.SendThread(ctx.RoomID, game.threadID, fmt.Sprintf(
"✅ '%c' is in the phrase! (%d guesses remaining)\n%s\n\nWrong guesses: %s", "✅ '%c' is in the phrase! (%d guesses remaining)\n%s\n\nWrong guesses: %s",
unicode.ToUpper(ch), remaining, display, wrongStr, unicode.ToUpper(ch), remaining, display, wrongStr,
)) ))
@@ -558,7 +574,7 @@ func (p *HangmanPlugin) processLetterGuess(ctx MessageContext, game *hangmanGame
return p.endGame(ctx.RoomID, game) return p.endGame(ctx.RoomID, game)
} }
return p.SendMessage(ctx.RoomID, fmt.Sprintf( return p.SendThread(ctx.RoomID, game.threadID, fmt.Sprintf(
"❌ Wrong! (%d guesses remaining)\n```\n%s\n```\n%s\n\nWrong guesses: %s", "❌ Wrong! (%d guesses remaining)\n```\n%s\n```\n%s\n\nWrong guesses: %s",
remaining, gallows[wrongCount], display, wrongStr, remaining, gallows[wrongCount], display, wrongStr,
)) ))
@@ -589,7 +605,7 @@ func (p *HangmanPlugin) processSolutionGuess(ctx MessageContext, game *hangmanGa
return p.endGame(ctx.RoomID, game) return p.endGame(ctx.RoomID, game)
} }
return p.SendMessage(ctx.RoomID, fmt.Sprintf( return p.SendThread(ctx.RoomID, game.threadID, fmt.Sprintf(
"❌ Wrong solution! (%d guesses remaining)\n```\n%s\n```\n%s\n\nWrong guesses: %s", "❌ Wrong solution! (%d guesses remaining)\n```\n%s\n```\n%s\n\nWrong guesses: %s",
remaining, gallows[wrongCount], display, wrongStr, remaining, gallows[wrongCount], display, wrongStr,
)) ))
@@ -601,6 +617,7 @@ func (p *HangmanPlugin) endGame(roomID id.RoomID, game *hangmanGame) error {
p.mu.Unlock() p.mu.Unlock()
if !game.solved { if !game.solved {
// Loss — broadcast to room
return p.SendMessage(roomID, fmt.Sprintf( return p.SendMessage(roomID, fmt.Sprintf(
"💀 Game over! The phrase was:\n\"%s\"\n```\n%s\n```\nNobody solved it this time.", "💀 Game over! The phrase was:\n\"%s\"\n```\n%s\n```\nNobody solved it this time.",
game.phrase, gallows[6], game.phrase, gallows[6],
@@ -608,11 +625,9 @@ func (p *HangmanPlugin) endGame(roomID id.RoomID, game *hangmanGame) error {
} }
// Calculate payouts // Calculate payouts
eligibleParticipants := make([]id.UserID, 0) eligibleParticipants := make([]id.UserID, 0, len(game.participants))
for uid := range game.participants { for uid := range game.participants {
if game.dmVerified[uid] { eligibleParticipants = append(eligibleParticipants, uid)
eligibleParticipants = append(eligibleParticipants, uid)
}
} }
solverName := p.displayName(game.solvedBy) solverName := p.displayName(game.solvedBy)

View File

@@ -55,6 +55,12 @@ type Plugin interface {
Init() error Init() error
} }
// dmCache maps user IDs to their DM room IDs to avoid creating duplicate rooms.
var (
dmCache = make(map[id.UserID]id.RoomID)
dmCacheMu sync.Mutex
)
// Base provides common helpers for plugin implementations. // Base provides common helpers for plugin implementations.
type Base struct { type Base struct {
Client *mautrix.Client Client *mautrix.Client
@@ -548,8 +554,29 @@ func (b *Base) SendReact(roomID id.RoomID, eventID id.EventID, emoji string) err
return err return err
} }
// SendDM sends a direct message to a user. Creates a DM room if needed. // GetDMRoom returns the DM room for a user, creating one if needed.
func (b *Base) SendDM(userID id.UserID, text string) error { func (b *Base) GetDMRoom(userID id.UserID) (id.RoomID, error) {
dmCacheMu.Lock()
if roomID, ok := dmCache[userID]; ok {
dmCacheMu.Unlock()
return roomID, nil
}
dmCacheMu.Unlock()
// Check account data for existing DM rooms
var dmRooms map[id.UserID][]id.RoomID
err := b.Client.GetAccountData(context.Background(), "m.direct", &dmRooms)
if err == nil {
if rooms, ok := dmRooms[userID]; ok && len(rooms) > 0 {
roomID := rooms[len(rooms)-1] // use most recent
dmCacheMu.Lock()
dmCache[userID] = roomID
dmCacheMu.Unlock()
return roomID, nil
}
}
// No existing DM room — create one
resp, err := b.Client.CreateRoom(context.Background(), &mautrix.ReqCreateRoom{ resp, err := b.Client.CreateRoom(context.Background(), &mautrix.ReqCreateRoom{
Preset: "trusted_private_chat", Preset: "trusted_private_chat",
Invite: []id.UserID{userID}, Invite: []id.UserID{userID},
@@ -566,9 +593,30 @@ func (b *Base) SendDM(userID id.UserID, text string) error {
}, },
}) })
if err != nil { if err != nil {
return fmt.Errorf("create DM room: %w", err) return "", fmt.Errorf("create DM room: %w", err)
} }
return b.SendMessage(resp.RoomID, text)
dmCacheMu.Lock()
dmCache[userID] = resp.RoomID
dmCacheMu.Unlock()
return resp.RoomID, nil
}
// IsDMRoom checks if the given room is a known DM room for the given user.
func IsDMRoom(roomID id.RoomID, userID id.UserID) bool {
dmCacheMu.Lock()
defer dmCacheMu.Unlock()
cached, ok := dmCache[userID]
return ok && cached == roomID
}
// SendDM sends a direct message to a user. Reuses existing DM room if available.
func (b *Base) SendDM(userID id.UserID, text string) error {
roomID, err := b.GetDMRoom(userID)
if err != nil {
return err
}
return b.SendMessage(roomID, text)
} }
// UploadContent uploads data to the Matrix content repository and returns the MXC URI. // UploadContent uploads data to the Matrix content repository and returns the MXC URI.

1317
internal/plugin/uno.go Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -112,6 +112,7 @@ func main() {
registry.Register(plugin.NewFlipPlugin(client)) registry.Register(plugin.NewFlipPlugin(client))
registry.Register(plugin.NewHangmanPlugin(client, euroPlugin)) registry.Register(plugin.NewHangmanPlugin(client, euroPlugin))
registry.Register(plugin.NewBlackjackPlugin(client, euroPlugin)) registry.Register(plugin.NewBlackjackPlugin(client, euroPlugin))
registry.Register(plugin.NewUnoPlugin(client, euroPlugin))
// Community // Community
registry.Register(plugin.NewMilkCartonPlugin(client, ratePlugin)) registry.Register(plugin.NewMilkCartonPlugin(client, ratePlugin))