mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
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:
@@ -51,6 +51,8 @@ BLACKJACK_AUTOPLAY_THRESHOLD=15
|
||||
BLACKJACK_MIN_BET=1
|
||||
BLACKJACK_MAX_BET=500
|
||||
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_GROUP_THRESHOLD=50 # % of smaller room's members that must overlap (1-100, default 50)
|
||||
|
||||
@@ -285,7 +285,7 @@ Rep is earned when someone thanks you. The bot detects this automatically.
|
||||
|---------|-------------|
|
||||
| `!flip` | Coin flip |
|
||||
| `!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 [phrase]` | Attempt full solution |
|
||||
| `!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
|
||||
- **Presence** - auto-clears away/afk when you send a message
|
||||
- **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)
|
||||
- **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).
|
||||
|
||||
@@ -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);
|
||||
|
||||
-- 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)
|
||||
CREATE TABLE IF NOT EXISTS space_groups (
|
||||
room_id TEXT PRIMARY KEY,
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"gogobee/internal/db"
|
||||
|
||||
"maunium.net/go/mautrix"
|
||||
"maunium.net/go/mautrix/event"
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
@@ -117,10 +118,10 @@ type hangmanGame struct {
|
||||
wrongGuesses []rune
|
||||
maxWrong int
|
||||
participants map[id.UserID]bool
|
||||
dmVerified map[id.UserID]bool // true = DM succeeded
|
||||
solved bool
|
||||
solvedBy id.UserID
|
||||
earlySolve bool
|
||||
threadID id.EventID // thread root event for this game
|
||||
}
|
||||
|
||||
func newHangmanGame(phrase string, maxWrong int) *hangmanGame {
|
||||
@@ -139,7 +140,6 @@ func newHangmanGame(phrase string, maxWrong int) *hangmanGame {
|
||||
revealed: revealed,
|
||||
maxWrong: maxWrong,
|
||||
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 {
|
||||
var sb strings.Builder
|
||||
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(' ')
|
||||
} else {
|
||||
sb.WriteRune('_')
|
||||
}
|
||||
// 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(' ')
|
||||
}
|
||||
}
|
||||
sb.WriteString("_ ")
|
||||
}
|
||||
}
|
||||
return sb.String()
|
||||
return strings.TrimRight(sb.String(), " ")
|
||||
}
|
||||
|
||||
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") {
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -404,13 +418,14 @@ func (p *HangmanPlugin) addPhrase(phrase string) error {
|
||||
|
||||
func (p *HangmanPlugin) handleStart(ctx MessageContext, difficulty string) 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]`")
|
||||
p.mu.Unlock()
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "A Hangman game is already in progress!")
|
||||
}
|
||||
|
||||
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.")
|
||||
}
|
||||
|
||||
@@ -425,6 +440,7 @@ func (p *HangmanPlugin) handleStart(ctx MessageContext, difficulty string) error
|
||||
}
|
||||
}
|
||||
if tier == nil {
|
||||
p.mu.Unlock()
|
||||
names := make([]string, len(hangmanTiers))
|
||||
for i, t := range hangmanTiers {
|
||||
names[i] = t.Name
|
||||
@@ -439,6 +455,7 @@ func (p *HangmanPlugin) handleStart(ctx MessageContext, difficulty string) error
|
||||
}
|
||||
}
|
||||
if len(filtered) == 0 {
|
||||
p.mu.Unlock()
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID,
|
||||
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))]
|
||||
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]`",
|
||||
// Reserve the slot so no concurrent start can race us
|
||||
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(),
|
||||
)
|
||||
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`")
|
||||
}
|
||||
|
||||
// Game exists but thread not yet created (start in progress)
|
||||
if game.threadID == "" {
|
||||
p.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Register participant (under lock)
|
||||
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()
|
||||
|
||||
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)
|
||||
|
||||
// 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.SendThread(ctx.RoomID, game.threadID, "Please guess a letter.")
|
||||
}
|
||||
return p.processLetterGuess(ctx, game, ch)
|
||||
}
|
||||
@@ -505,13 +533,13 @@ func (p *HangmanPlugin) processLetterGuess(ctx MessageContext, game *hangmanGame
|
||||
hit, already := game.guessLetter(ch)
|
||||
|
||||
if already {
|
||||
threadID := game.threadID
|
||||
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)))
|
||||
}
|
||||
|
||||
// Snapshot state for messages before unlocking
|
||||
shouldDM := game.dmVerified[ctx.Sender]
|
||||
display := game.displayPhrase()
|
||||
wrongStr := game.wrongGuessStr()
|
||||
remaining := game.remaining()
|
||||
@@ -531,23 +559,11 @@ func (p *HangmanPlugin) processLetterGuess(ctx MessageContext, game *hangmanGame
|
||||
wrongCount := game.wrongCount()
|
||||
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 fullyRevealed {
|
||||
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",
|
||||
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.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",
|
||||
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.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",
|
||||
remaining, gallows[wrongCount], display, wrongStr,
|
||||
))
|
||||
@@ -601,6 +617,7 @@ func (p *HangmanPlugin) endGame(roomID id.RoomID, game *hangmanGame) error {
|
||||
p.mu.Unlock()
|
||||
|
||||
if !game.solved {
|
||||
// Loss — broadcast to room
|
||||
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],
|
||||
@@ -608,11 +625,9 @@ func (p *HangmanPlugin) endGame(roomID id.RoomID, game *hangmanGame) error {
|
||||
}
|
||||
|
||||
// Calculate payouts
|
||||
eligibleParticipants := make([]id.UserID, 0)
|
||||
eligibleParticipants := make([]id.UserID, 0, len(game.participants))
|
||||
for uid := range game.participants {
|
||||
if game.dmVerified[uid] {
|
||||
eligibleParticipants = append(eligibleParticipants, uid)
|
||||
}
|
||||
eligibleParticipants = append(eligibleParticipants, uid)
|
||||
}
|
||||
|
||||
solverName := p.displayName(game.solvedBy)
|
||||
|
||||
@@ -55,6 +55,12 @@ type Plugin interface {
|
||||
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.
|
||||
type Base struct {
|
||||
Client *mautrix.Client
|
||||
@@ -548,8 +554,29 @@ func (b *Base) SendReact(roomID id.RoomID, eventID id.EventID, emoji string) err
|
||||
return err
|
||||
}
|
||||
|
||||
// SendDM sends a direct message to a user. Creates a DM room if needed.
|
||||
func (b *Base) SendDM(userID id.UserID, text string) error {
|
||||
// GetDMRoom returns the DM room for a user, creating one if needed.
|
||||
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{
|
||||
Preset: "trusted_private_chat",
|
||||
Invite: []id.UserID{userID},
|
||||
@@ -566,9 +593,30 @@ func (b *Base) SendDM(userID id.UserID, text string) error {
|
||||
},
|
||||
})
|
||||
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.
|
||||
|
||||
1317
internal/plugin/uno.go
Normal file
1317
internal/plugin/uno.go
Normal file
File diff suppressed because it is too large
Load Diff
1
main.go
1
main.go
@@ -112,6 +112,7 @@ func main() {
|
||||
registry.Register(plugin.NewFlipPlugin(client))
|
||||
registry.Register(plugin.NewHangmanPlugin(client, euroPlugin))
|
||||
registry.Register(plugin.NewBlackjackPlugin(client, euroPlugin))
|
||||
registry.Register(plugin.NewUnoPlugin(client, euroPlugin))
|
||||
|
||||
// Community
|
||||
registry.Register(plugin.NewMilkCartonPlugin(client, ratePlugin))
|
||||
|
||||
Reference in New Issue
Block a user