package plugin import ( "fmt" "log/slog" "math/rand/v2" "strings" "sync" "time" "gogobee/internal/db" "maunium.net/go/mautrix/id" ) // --------------------------------------------------------------------------- // Multiplayer UNO types // --------------------------------------------------------------------------- type unoMultiPhase int const ( unoMultiPhasePlay unoMultiPhase = iota unoMultiPhaseChooseColor // active player must pick a color unoMultiPhaseDrawnPlayable // active player drew a playable card, yes/no ) type unoMultiPlayer struct { userID id.UserID dmRoomID id.RoomID hand []unoCard calledUno bool isBot bool active bool // false if forfeited/left autoPlays int // consecutive auto-plays } type unoMultiGame struct { id string roomID id.RoomID // games room ante float64 players []*unoMultiPlayer // in turn order (includes bot) currentIdx int direction int // +1 or -1 drawPile []unoCard discardTop unoCard topColor unoColor phase unoMultiPhase drawnCard *unoCard // card drawn this turn pendingCard *unoCard // wild waiting for color turns int turnID int // monotonic, used to invalidate stale timers startedAt time.Time done bool bookDown bool timer *time.Timer inactiveTimer *time.Timer // 10-minute game timeout mu sync.Mutex // per-game lock } type unoMultiLobby struct { roomID id.RoomID creator id.UserID ante float64 players []id.UserID createdAt time.Time timer *time.Timer } // --------------------------------------------------------------------------- // Game helpers // --------------------------------------------------------------------------- func (g *unoMultiGame) currentPlayer() *unoMultiPlayer { return g.players[g.currentIdx] } func (g *unoMultiGame) nextActiveIdx() int { idx := g.currentIdx n := len(g.players) for i := 0; i < n; i++ { idx = (idx + g.direction + n) % n if g.players[idx].active { return idx } } return g.currentIdx // only one left } func (g *unoMultiGame) activePlayers() []*unoMultiPlayer { var active []*unoMultiPlayer for _, p := range g.players { if p.active { active = append(active, p) } } return active } func (g *unoMultiGame) activeHumanCount() int { count := 0 for _, p := range g.players { if p.active && !p.isBot { count++ } } return count } func (g *unoMultiGame) minOpponentCards(excludeIdx int) int { min := 999 for i, p := range g.players { if i != excludeIdx && p.active && len(p.hand) < min { min = len(p.hand) } } return min } func (g *unoMultiGame) playerByUserID(userID id.UserID) *unoMultiPlayer { for _, p := range g.players { if p.userID == userID { return p } } return nil } func (g *unoMultiGame) draw(n int) []unoCard { var drawn []unoCard for i := 0; i < n; i++ { if len(g.drawPile) == 0 { g.reshuffleDiscard() } if len(g.drawPile) == 0 { break } drawn = append(drawn, g.drawPile[0]) g.drawPile = g.drawPile[1:] } return drawn } func (g *unoMultiGame) reshuffleDiscard() { fresh := newUnoDeck() inPlay := make(map[unoCard]int) for _, p := range g.players { for _, c := range p.hand { inPlay[c]++ } } inPlay[g.discardTop]++ var pile []unoCard for _, c := range fresh { if inPlay[c] > 0 { inPlay[c]-- continue } pile = append(pile, c) } rand.Shuffle(len(pile), func(i, j int) { pile[i], pile[j] = pile[j], pile[i] }) g.drawPile = pile } func (g *unoMultiGame) hasPlayable(hand []unoCard) bool { for _, c := range hand { if c.canPlayOn(g.discardTop, g.topColor) { return true } } return false } func (g *unoMultiGame) updateBookState() bool { // Bot pays attention when any opponent has <= 3 cards shouldBeDown := false for _, p := range g.players { if !p.isBot && p.active && len(p.hand) <= 3 { shouldBeDown = true break } } if shouldBeDown != g.bookDown { g.bookDown = shouldBeDown return true } return false } // --------------------------------------------------------------------------- // Lobby commands // --------------------------------------------------------------------------- func (p *UnoPlugin) handleMultiStart(ctx MessageContext, amountStr string) error { if !isGamesRoom(ctx.RoomID) { return p.SendReply(ctx.RoomID, ctx.EventID, "Multiplayer Uno can only be started in the games channel!") } amountStr = strings.TrimPrefix(amountStr, "โ‚ฌ") var amount float64 fmt.Sscanf(amountStr, "%f", &amount) minBet := envFloat("UNO_MULTI_MIN_BET", envFloat("UNO_MIN_BET", 10)) maxBet := envFloat("UNO_MULTI_MAX_BET", 500) if amount < minBet { return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Minimum ante is โ‚ฌ%d. Usage: `!uno start โ‚ฌamount`", int(minBet))) } if amount > maxBet { return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Maximum ante is โ‚ฌ%d.", int(maxBet))) } p.mu.Lock() if _, exists := p.lobbies[ctx.RoomID]; exists { p.mu.Unlock() return p.SendReply(ctx.RoomID, ctx.EventID, "A lobby is already open! Use `!uno join` to join or `!uno cancel` to cancel it.") } // Check player isn't already in a game if _, active := p.games[ctx.Sender]; active { p.mu.Unlock() return p.SendReply(ctx.RoomID, ctx.EventID, "You already have a solo Uno game in progress!") } for _, mg := range p.multiGames { if !mg.done { for _, pl := range mg.players { if pl.userID == ctx.Sender && pl.active && !pl.isBot { p.mu.Unlock() return p.SendReply(ctx.RoomID, ctx.EventID, "You're already in a multiplayer Uno game!") } } } } // Debit ante while still holding the lock if !p.euro.Debit(ctx.Sender, amount, "uno_multi_ante") { p.mu.Unlock() return p.SendReply(ctx.RoomID, ctx.EventID, "Insufficient balance for that ante.") } timeout := envInt("UNO_MULTI_LOBBY_TIMEOUT", 300) lobby := &unoMultiLobby{ roomID: ctx.RoomID, creator: ctx.Sender, ante: amount, players: []id.UserID{ctx.Sender}, createdAt: time.Now(), } lobby.timer = time.AfterFunc(time.Duration(timeout)*time.Second, func() { p.lobbyExpired(ctx.RoomID) }) p.lobbies[ctx.RoomID] = lobby p.mu.Unlock() creatorName := p.unoDisplayName(ctx.Sender) return p.SendMessage(ctx.RoomID, fmt.Sprintf( "๐Ÿƒ **UNO Lobby** โ€” Ante: โ‚ฌ%d\nPlayers (1/4):\n 1. %s (host)\n\nType `!uno join` to join or `!uno go` to start!", int(amount), creatorName)) } func (p *UnoPlugin) handleMultiJoin(ctx MessageContext) error { if !isGamesRoom(ctx.RoomID) { return p.SendReply(ctx.RoomID, ctx.EventID, "Use this command in the games channel!") } p.mu.Lock() lobby, exists := p.lobbies[ctx.RoomID] if !exists { p.mu.Unlock() return p.SendReply(ctx.RoomID, ctx.EventID, "No lobby open. Start one with `!uno start โ‚ฌamount`.") } // Check if already in lobby for _, uid := range lobby.players { if uid == ctx.Sender { p.mu.Unlock() return p.SendReply(ctx.RoomID, ctx.EventID, "You're already in the lobby!") } } if len(lobby.players) >= 4 { p.mu.Unlock() return p.SendReply(ctx.RoomID, ctx.EventID, "Lobby is full! (4/4 players)") } // Check player isn't in another game if _, active := p.games[ctx.Sender]; active { p.mu.Unlock() return p.SendReply(ctx.RoomID, ctx.EventID, "You already have a solo Uno game in progress!") } for _, mg := range p.multiGames { if !mg.done { for _, pl := range mg.players { if pl.userID == ctx.Sender && pl.active && !pl.isBot { p.mu.Unlock() return p.SendReply(ctx.RoomID, ctx.EventID, "You're already in a multiplayer Uno game!") } } } } // Debit ante while still holding the lock if !p.euro.Debit(ctx.Sender, lobby.ante, "uno_multi_ante") { p.mu.Unlock() return p.SendReply(ctx.RoomID, ctx.EventID, "Insufficient balance for the ante.") } lobby.players = append(lobby.players, ctx.Sender) count := len(lobby.players) p.mu.Unlock() // Build player list var sb strings.Builder sb.WriteString(fmt.Sprintf("๐Ÿƒ **UNO Lobby** โ€” Ante: โ‚ฌ%d\nPlayers (%d/4):\n", int(lobby.ante), count)) for i, uid := range lobby.players { name := p.unoDisplayName(uid) label := "" if uid == lobby.creator { label = " (host)" } sb.WriteString(fmt.Sprintf(" %d. %s%s\n", i+1, name, label)) } sb.WriteString("\nType `!uno join` to join or `!uno go` to start!") return p.SendMessage(ctx.RoomID, sb.String()) } func (p *UnoPlugin) handleMultiLeave(ctx MessageContext) error { p.mu.Lock() lobby, exists := p.lobbies[ctx.RoomID] if !exists { p.mu.Unlock() return p.SendReply(ctx.RoomID, ctx.EventID, "No lobby open.") } found := false for i, uid := range lobby.players { if uid == ctx.Sender { lobby.players = append(lobby.players[:i], lobby.players[i+1:]...) found = true break } } if !found { p.mu.Unlock() return p.SendReply(ctx.RoomID, ctx.EventID, "You're not in the lobby.") } // If creator leaves, cancel the lobby if ctx.Sender == lobby.creator || len(lobby.players) == 0 { lobby.timer.Stop() delete(p.lobbies, ctx.RoomID) // Refund remaining players for _, uid := range lobby.players { p.euro.Credit(uid, lobby.ante, "uno_multi_refund") } p.mu.Unlock() p.euro.Credit(ctx.Sender, lobby.ante, "uno_multi_refund") return p.SendMessage(ctx.RoomID, "๐Ÿƒ Lobby cancelled โ€” all antes refunded.") } p.mu.Unlock() // Refund the leaving player p.euro.Credit(ctx.Sender, lobby.ante, "uno_multi_refund") name := p.unoDisplayName(ctx.Sender) return p.SendMessage(ctx.RoomID, fmt.Sprintf("๐Ÿƒ **%s** left the lobby. (%d/4 players)", name, len(lobby.players))) } func (p *UnoPlugin) handleMultiCancel(ctx MessageContext) error { p.mu.Lock() lobby, exists := p.lobbies[ctx.RoomID] if !exists { p.mu.Unlock() return p.SendReply(ctx.RoomID, ctx.EventID, "No lobby open.") } if ctx.Sender != lobby.creator && !p.IsAdmin(ctx.Sender) { p.mu.Unlock() return p.SendReply(ctx.RoomID, ctx.EventID, "Only the host can cancel the lobby.") } lobby.timer.Stop() players := lobby.players ante := lobby.ante delete(p.lobbies, ctx.RoomID) p.mu.Unlock() for _, uid := range players { p.euro.Credit(uid, ante, "uno_multi_refund") } return p.SendMessage(ctx.RoomID, "๐Ÿƒ Lobby cancelled โ€” all antes refunded.") } func (p *UnoPlugin) lobbyExpired(roomID id.RoomID) { p.mu.Lock() lobby, exists := p.lobbies[roomID] if !exists { p.mu.Unlock() return } players := lobby.players ante := lobby.ante delete(p.lobbies, roomID) p.mu.Unlock() for _, uid := range players { p.euro.Credit(uid, ante, "uno_multi_refund") } p.SendMessage(roomID, "๐Ÿƒ Lobby expired โ€” all antes refunded.") } func (p *UnoPlugin) handleMultiGo(ctx MessageContext) error { p.mu.Lock() lobby, exists := p.lobbies[ctx.RoomID] if !exists { p.mu.Unlock() return p.SendReply(ctx.RoomID, ctx.EventID, "No lobby open.") } if ctx.Sender != lobby.creator { p.mu.Unlock() return p.SendReply(ctx.RoomID, ctx.EventID, "Only the host can start the game.") } if len(lobby.players) < 2 { p.mu.Unlock() return p.SendReply(ctx.RoomID, ctx.EventID, "Need at least 2 players to start.") } lobby.timer.Stop() players := lobby.players ante := lobby.ante roomID := lobby.roomID delete(p.lobbies, ctx.RoomID) p.mu.Unlock() // Resolve DM rooms for all players var resolved []playerDMPair for _, uid := range players { dmRoom, err := p.GetDMRoom(uid) if err != nil { slog.Error("uno_multi: failed to get DM room", "user", uid, "err", err) // Refund all and abort for _, u := range players { p.euro.Credit(u, ante, "uno_multi_refund") } return p.SendMessage(roomID, fmt.Sprintf("๐Ÿƒ Game cancelled โ€” couldn't open DMs with %s.", p.unoDisplayName(uid))) } resolved = append(resolved, playerDMPair{uid, dmRoom}) } // Build game game := p.initMultiGame(resolved, roomID, ante) p.mu.Lock() p.multiGames[game.id] = game for _, pl := range game.players { if !pl.isBot { p.dmToMulti[pl.dmRoomID] = game.id } } p.mu.Unlock() // Announce var sb strings.Builder bn := unoBotName() sb.WriteString(fmt.Sprintf("๐Ÿƒ **Multiplayer UNO!** Pot: โ‚ฌ%d\n\nPlayers:\n", int(ante)*len(players))) for i, pl := range game.players { name := p.unoDisplayName(pl.userID) if pl.isBot { name = bn } marker := "" if i == game.currentIdx { marker = " โ† first turn" } sb.WriteString(fmt.Sprintf(" %d. %s%s\n", i+1, name, marker)) } sb.WriteString(fmt.Sprintf("\nStarting card: %s\n%s\n\n[Check your DMs!]", game.discardTop.DisplayWithColor(game.topColor), pickCommentary("start"))) p.SendMessage(roomID, sb.String()) // Start first turn game.mu.Lock() defer game.mu.Unlock() p.startInactivityTimer(game) p.executeMultiTurn(game) return nil } // --------------------------------------------------------------------------- // Game initialization // --------------------------------------------------------------------------- type playerDMPair struct { userID id.UserID dmRoomID id.RoomID } func (p *UnoPlugin) initMultiGame(players []playerDMPair, roomID id.RoomID, ante float64) *unoMultiGame { deck := newUnoDeck() cardsPerPlayer := 7 cardIdx := 0 // Build players list and deal cards var unshuffled []*unoMultiPlayer for _, pd := range players { hand := make([]unoCard, cardsPerPlayer) copy(hand, deck[cardIdx:cardIdx+cardsPerPlayer]) cardIdx += cardsPerPlayer unshuffled = append(unshuffled, &unoMultiPlayer{ userID: pd.userID, dmRoomID: pd.dmRoomID, hand: hand, active: true, }) } // Bot botHand := make([]unoCard, cardsPerPlayer) copy(botHand, deck[cardIdx:cardIdx+cardsPerPlayer]) cardIdx += cardsPerPlayer bot := &unoMultiPlayer{ userID: id.UserID(unoBotName()), hand: botHand, isBot: true, active: true, } unshuffled = append(unshuffled, bot) // Shuffle turn order rand.Shuffle(len(unshuffled), func(i, j int) { unshuffled[i], unshuffled[j] = unshuffled[j], unshuffled[i] }) // Remaining deck remaining := make([]unoCard, len(deck)-cardIdx) copy(remaining, deck[cardIdx:]) // Starting card โ€” must not be Wild var startCard unoCard startIdx := -1 for i, c := range remaining { if c.Value != unoWildCard && c.Value != unoWildDrawFour { startCard = c startIdx = i break } } if startIdx >= 0 { remaining = append(remaining[:startIdx], remaining[startIdx+1:]...) } else { startCard = remaining[0] remaining = remaining[1:] } gameID := fmt.Sprintf("multi_%d", time.Now().UnixNano()) return &unoMultiGame{ id: gameID, roomID: roomID, ante: ante, players: unshuffled, currentIdx: 0, direction: 1, drawPile: remaining, discardTop: startCard, topColor: startCard.Color, phase: unoMultiPhasePlay, turns: 0, turnID: 0, startedAt: time.Now(), } } // --------------------------------------------------------------------------- // Turn execution engine // --------------------------------------------------------------------------- // executeMultiTurn runs bot turns synchronously and sets up the next human turn. // Caller must hold game.mu. func (p *UnoPlugin) executeMultiTurn(game *unoMultiGame) { var roomBuf strings.Builder botTurnsInRow := 0 for { if game.done { return } player := game.currentPlayer() if player.isBot { botTurnsInRow++ if botTurnsInRow > 10 { // Safety: shouldn't happen, but prevent infinite loops break } p.botMultiTurn(game, &roomBuf) if game.done { if roomBuf.Len() > 0 { p.SendMessage(game.roomID, roomBuf.String()) } return } continue } // Human's turn โ€” flush room buffer if roomBuf.Len() > 0 { p.SendMessage(game.roomID, roomBuf.String()) roomBuf.Reset() } // Auto-draw if no playable cards if !game.hasPlayable(player.hand) { drawn := game.draw(1) if len(drawn) == 0 { // Empty deck, no playable cards โ€” pass name := p.unoDisplayName(player.userID) p.SendMessage(game.roomID, fmt.Sprintf("๐Ÿƒ %s has no playable cards and deck is empty. Turn passes. (%d cards)", name, len(player.hand))) p.SendMessage(player.dmRoomID, "No playable cards and deck is empty. Turn passes.") game.currentIdx = game.nextActiveIdx() game.turnID++ continue } card := drawn[0] player.hand = append(player.hand, card) if card.canPlayOn(game.discardTop, game.topColor) { game.drawnCard = &card game.phase = unoMultiPhaseDrawnPlayable p.SendMessage(player.dmRoomID, fmt.Sprintf("No playable cards โ€” drew automatically: %s\nIt's playable! Play it? (**yes** / **no**)", card.Display())) p.startMultiAutoPlayTimer(game) return } name := p.unoDisplayName(player.userID) p.SendMessage(player.dmRoomID, fmt.Sprintf("No playable cards โ€” drew automatically: %s\nNot playable. Turn passes.", card.Display())) p.SendMessage(game.roomID, fmt.Sprintf("๐Ÿƒ %s draws a card. Turn passes. (%d cards)", name, len(player.hand))) game.currentIdx = game.nextActiveIdx() game.turnID++ continue } // Show hand and wait for input game.phase = unoMultiPhasePlay p.sendMultiHandDisplay(game, player) p.startMultiAutoPlayTimer(game) return } } // advanceAndExecute moves to the next player and calls executeMultiTurn. // Caller must hold game.mu. func (p *UnoPlugin) advanceAndExecute(game *unoMultiGame) { game.currentIdx = game.nextActiveIdx() game.turnID++ game.turns++ p.executeMultiTurn(game) } // --------------------------------------------------------------------------- // DM input handling // --------------------------------------------------------------------------- func (p *UnoPlugin) handleMultiDMInput(ctx MessageContext, game *unoMultiGame) error { game.mu.Lock() defer game.mu.Unlock() if game.done { return nil } // Status query โ€” available to any player, any time input := strings.TrimSpace(strings.ToLower(ctx.Body)) if strings.HasPrefix(input, "!") { input = strings.TrimPrefix(input, "!") } if input == "uno" || input == "" { // Could be status request or UNO call โ€” only treat as status if not active player current := game.currentPlayer() if current.userID != ctx.Sender { caller := game.playerByUserID(ctx.Sender) if caller != nil && caller.active { p.sendMultiStatus(game, caller) } return nil } } player := game.currentPlayer() if player.userID != ctx.Sender || player.isBot { return nil // not this player's turn } // Reset auto-play counter and inactivity timer on manual input player.autoPlays = 0 p.startInactivityTimer(game) // Cancel turn timer if game.timer != nil { game.timer.Stop() } if input == "quit" || input == "forfeit" { p.multiPlayerForfeit(game, player) return nil } switch game.phase { case unoMultiPhaseChooseColor: return p.handleMultiColorChoice(game, player, input) case unoMultiPhaseDrawnPlayable: return p.handleMultiDrawnPlayable(game, player, input) case unoMultiPhasePlay: if input == "uno" { player.calledUno = true p.SendMessage(player.dmRoomID, "โœ… UNO called!") return nil } if input == "draw" { return p.handleMultiPlayerDraw(game, player) } var cardIdx int if _, err := fmt.Sscanf(input, "%d", &cardIdx); err != nil || cardIdx < 1 || cardIdx > len(player.hand) { p.SendMessage(player.dmRoomID, fmt.Sprintf("Reply with a number (1-%d) to play, or **draw** to draw.", len(player.hand))) return nil } return p.handleMultiPlayerPlay(game, player, cardIdx-1) } return nil } // --------------------------------------------------------------------------- // Play handlers // --------------------------------------------------------------------------- func (p *UnoPlugin) handleMultiPlayerPlay(game *unoMultiGame, player *unoMultiPlayer, idx int) error { card := player.hand[idx] if !card.canPlayOn(game.discardTop, game.topColor) { p.SendMessage(player.dmRoomID, fmt.Sprintf("You can't play %s on %s.", card.Display(), game.discardTop.DisplayWithColor(game.topColor))) return nil } // UNO penalty check โ€” had 2 cards, didn't call UNO if len(player.hand) == 2 && !player.calledUno { drawn := game.draw(2) player.hand = append(player.hand, drawn...) player.calledUno = false p.SendMessage(player.dmRoomID, "โš ๏ธ You forgot to call UNO! Draw 2 as penalty.") name := p.unoDisplayName(player.userID) p.SendMessage(game.roomID, fmt.Sprintf("๐Ÿƒ %s forgot to call UNO! 2 card penalty.", name)) // Re-show hand p.sendMultiHandDisplay(game, player) p.startMultiAutoPlayTimer(game) return nil } // Play the card player.hand = append(player.hand[:idx], player.hand[idx+1:]...) game.discardTop = card game.turns++ // UNO tracking if len(player.hand) == 2 { player.calledUno = false } // Wild โ€” need color choice if card.Value == unoWildCard || card.Value == unoWildDrawFour { game.pendingCard = &card game.phase = unoMultiPhaseChooseColor p.SendMessage(player.dmRoomID, fmt.Sprintf("You played **%s**! Choose a color:\n1. ๐ŸŸฅ Red\n2. ๐ŸŸฆ Blue\n3. ๐ŸŸจ Yellow\n4. ๐ŸŸฉ Green", card.Value)) p.startMultiAutoPlayTimer(game) return nil } game.topColor = card.Color // Check win if len(player.hand) == 0 { name := p.unoDisplayName(player.userID) p.SendMessage(game.roomID, fmt.Sprintf("๐Ÿƒ %s plays: %s", name, card.Display())) p.multiPlayerWins(game, player) return nil } // Apply effects and announce p.applyAndAnnounce(game, player, card) return nil } func (p *UnoPlugin) handleMultiColorChoice(game *unoMultiGame, player *unoMultiPlayer, input string) error { var color unoColor switch input { case "1", "red": color = unoRed case "2", "blue": color = unoBlue case "3", "yellow": color = unoYellow case "4", "green": color = unoGreen default: p.SendMessage(player.dmRoomID, "Choose a color: **1** (Red), **2** (Blue), **3** (Yellow), **4** (Green)") return nil } game.topColor = color pendingCard := game.pendingCard game.pendingCard = nil game.phase = unoMultiPhasePlay p.SendMessage(player.dmRoomID, fmt.Sprintf("Color set to %s %s.", color.Emoji(), color)) // Check win if len(player.hand) == 0 { name := p.unoDisplayName(player.userID) p.SendMessage(game.roomID, fmt.Sprintf("๐Ÿƒ %s plays: %s (chose %s %s)", name, pendingCard.Display(), color.Emoji(), color)) p.multiPlayerWins(game, player) return nil } // Apply effects p.applyAndAnnounce(game, player, *pendingCard) return nil } func (p *UnoPlugin) handleMultiPlayerDraw(game *unoMultiGame, player *unoMultiPlayer) error { drawn := game.draw(1) if len(drawn) == 0 { p.SendMessage(player.dmRoomID, "No cards left to draw! Turn passes.") name := p.unoDisplayName(player.userID) p.SendMessage(game.roomID, fmt.Sprintf("๐Ÿƒ %s can't draw โ€” deck is empty. Turn passes.", name)) p.advanceAndExecute(game) return nil } card := drawn[0] player.hand = append(player.hand, card) if card.canPlayOn(game.discardTop, game.topColor) { game.drawnCard = &card game.phase = unoMultiPhaseDrawnPlayable p.SendMessage(player.dmRoomID, fmt.Sprintf("You drew: %s\nIt's playable! Play it? (**yes** / **no**)", card.Display())) p.startMultiAutoPlayTimer(game) return nil } name := p.unoDisplayName(player.userID) p.SendMessage(player.dmRoomID, fmt.Sprintf("You drew: %s\nNot playable. Turn passes.", card.Display())) p.SendMessage(game.roomID, fmt.Sprintf("๐Ÿƒ %s draws a card. Turn passes. (%d cards)", name, len(player.hand))) p.advanceAndExecute(game) return nil } func (p *UnoPlugin) handleMultiDrawnPlayable(game *unoMultiGame, player *unoMultiPlayer, input string) error { if input != "yes" && input != "y" && input != "no" && input != "n" { p.SendMessage(player.dmRoomID, "Play the drawn card? (**yes** / **no**)") return nil } drawnCard := *game.drawnCard game.drawnCard = nil game.phase = unoMultiPhasePlay if input == "no" || input == "n" { name := p.unoDisplayName(player.userID) p.SendMessage(player.dmRoomID, "Card kept. Turn passes.") p.SendMessage(game.roomID, fmt.Sprintf("๐Ÿƒ %s draws a card. Turn passes. (%d cards)", name, len(player.hand))) p.advanceAndExecute(game) return nil } // Play the drawn card for i, c := range player.hand { if c == drawnCard { player.hand = append(player.hand[:i], player.hand[i+1:]...) break } } game.discardTop = drawnCard game.turns++ if len(player.hand) == 2 { player.calledUno = false } if drawnCard.Value == unoWildCard || drawnCard.Value == unoWildDrawFour { game.pendingCard = &drawnCard game.phase = unoMultiPhaseChooseColor p.SendMessage(player.dmRoomID, fmt.Sprintf("You played **%s**! Choose a color:\n1. ๐ŸŸฅ Red\n2. ๐ŸŸฆ Blue\n3. ๐ŸŸจ Yellow\n4. ๐ŸŸฉ Green", drawnCard.Value)) p.startMultiAutoPlayTimer(game) return nil } game.topColor = drawnCard.Color if len(player.hand) == 0 { name := p.unoDisplayName(player.userID) p.SendMessage(game.roomID, fmt.Sprintf("๐Ÿƒ %s plays: %s", name, drawnCard.Display())) p.multiPlayerWins(game, player) return nil } p.applyAndAnnounce(game, player, drawnCard) return nil } // --------------------------------------------------------------------------- // Card effects & turn announcement // --------------------------------------------------------------------------- // cardEffectResult describes what happened when a card's effects were applied. type cardEffectResult struct { skippedName string // non-empty if a player was skipped reversed bool // true if direction was reversed drawnCount int // cards drawn by the victim (2 or 4) } // applyCardEffects applies skip/reverse/draw effects and advances the turn. // Caller must hold game.mu. func (p *UnoPlugin) applyCardEffects(game *unoMultiGame, card unoCard) cardEffectResult { var result cardEffectResult nextIdx := game.nextActiveIdx() nextPlayer := game.players[nextIdx] nextName := p.unoDisplayName(nextPlayer.userID) if nextPlayer.isBot { nextName = unoBotName() } switch card.Value { case unoSkip: result.skippedName = nextName game.currentIdx = nextIdx game.currentIdx = game.nextActiveIdx() game.turnID++ case unoReverse: game.direction *= -1 result.reversed = true if len(game.activePlayers()) == 2 { result.skippedName = nextName game.currentIdx = nextIdx game.currentIdx = game.nextActiveIdx() game.turnID++ } else { game.currentIdx = game.nextActiveIdx() game.turnID++ } case unoDrawTwo: drawn := game.draw(2) nextPlayer.hand = append(nextPlayer.hand, drawn...) result.skippedName = nextName result.drawnCount = 2 game.currentIdx = nextIdx game.currentIdx = game.nextActiveIdx() game.turnID++ case unoWildDrawFour: drawn := game.draw(4) nextPlayer.hand = append(nextPlayer.hand, drawn...) result.skippedName = nextName result.drawnCount = 4 game.currentIdx = nextIdx game.currentIdx = game.nextActiveIdx() game.turnID++ default: game.currentIdx = game.nextActiveIdx() game.turnID++ } return result } // writeEffectLines appends human-readable effect descriptions to a string builder. func writeEffectLines(sb *strings.Builder, eff cardEffectResult) { if eff.drawnCount > 0 { sb.WriteString(fmt.Sprintf("\n %s draws %d and is skipped!", eff.skippedName, eff.drawnCount)) } else if eff.skippedName != "" && eff.reversed { sb.WriteString(fmt.Sprintf("\n %s is skipped! (reverse)", eff.skippedName)) } else if eff.skippedName != "" { sb.WriteString(fmt.Sprintf("\n %s is skipped!", eff.skippedName)) } else if eff.reversed { sb.WriteString("\n Direction reversed!") } } func (p *UnoPlugin) applyAndAnnounce(game *unoMultiGame, player *unoMultiPlayer, card unoCard) { name := p.unoDisplayName(player.userID) var roomMsg strings.Builder roomMsg.WriteString(fmt.Sprintf("๐Ÿƒ %s plays: %s", name, card.DisplayWithColor(game.topColor))) eff := p.applyCardEffects(game, card) writeEffectLines(&roomMsg, eff) // Book state commentary (occasionally) if game.turns%4 == 0 { if changed := game.updateBookState(); changed { if game.bookDown { roomMsg.WriteString("\n\n" + pickCommentary("book_down")) } else { roomMsg.WriteString("\n\n" + pickCommentary("book_up")) } } } // UNO announcement if len(player.hand) == 1 && player.calledUno { roomMsg.WriteString(fmt.Sprintf("\n %s calls UNO! ๐Ÿ”ฅ", name)) } // Next player nextUp := game.currentPlayer() nextUpName := p.unoDisplayName(nextUp.userID) if nextUp.isBot { nextUpName = unoBotName() } roomMsg.WriteString(fmt.Sprintf("\n It's %s's turn.", nextUpName)) p.SendMessage(game.roomID, roomMsg.String()) p.executeMultiTurn(game) } // --------------------------------------------------------------------------- // Bot turn (multiplayer) // --------------------------------------------------------------------------- func (p *UnoPlugin) botMultiTurn(game *unoMultiGame, roomBuf *strings.Builder) { bot := game.currentPlayer() bn := unoBotName() card, idx := botPickCard(bot.hand, game.discardTop, game.topColor, game.bookDown, game.minOpponentCards(game.currentIdx)) if idx < 0 { // Bot draws drawn := game.draw(1) if len(drawn) == 0 { if roomBuf.Len() > 0 { roomBuf.WriteString("\n") } roomBuf.WriteString(fmt.Sprintf("๐Ÿƒ %s can't draw โ€” deck empty. Turn passes.", bn)) game.currentIdx = game.nextActiveIdx() game.turnID++ return } bot.hand = append(bot.hand, drawn[0]) if drawn[0].canPlayOn(game.discardTop, game.topColor) { bot.hand = bot.hand[:len(bot.hand)-1] card = drawn[0] } else { // Bot drew, not playable if game.turns%3 == 0 { commentKey := "bot_draw_normal" if game.bookDown { commentKey = "bot_draw_bookdown" } if roomBuf.Len() > 0 { roomBuf.WriteString("\n") } roomBuf.WriteString(pickCommentary(commentKey)) } else { if roomBuf.Len() > 0 { roomBuf.WriteString("\n") } roomBuf.WriteString(fmt.Sprintf("๐Ÿƒ %s draws a card.", bn)) } game.currentIdx = game.nextActiveIdx() game.turnID++ return } } // Play the card if idx >= 0 { bot.hand = append(bot.hand[:idx], bot.hand[idx+1:]...) } game.discardTop = card // Wild color choice if card.Value == unoWildCard || card.Value == unoWildDrawFour { game.topColor = botPickColor(bot.hand) } else { game.topColor = card.Color } // Check bot win if len(bot.hand) == 0 { if roomBuf.Len() > 0 { roomBuf.WriteString("\n") } roomBuf.WriteString(fmt.Sprintf("๐Ÿƒ %s plays: %s", bn, card.DisplayWithColor(game.topColor))) if roomBuf.Len() > 0 { p.SendMessage(game.roomID, roomBuf.String()) roomBuf.Reset() } p.multiBotWins(game) return } // Bot UNO call if len(bot.hand) == 1 { if roomBuf.Len() > 0 { roomBuf.WriteString("\n") } roomBuf.WriteString(fmt.Sprintf("๐Ÿƒ %s plays: %s\n %s calls UNO! ๐Ÿ”ฅ", bn, card.DisplayWithColor(game.topColor), bn)) } else { if roomBuf.Len() > 0 { roomBuf.WriteString("\n") } // Add commentary occasionally if game.turns%3 == 0 { commentKey := "bot_play_normal" if game.bookDown { commentKey = "bot_play_bookdown" } roomBuf.WriteString(fmt.Sprintf("๐Ÿƒ %s", fmt.Sprintf(pickCommentary(commentKey), card.DisplayWithColor(game.topColor)))) } else { roomBuf.WriteString(fmt.Sprintf("๐Ÿƒ %s plays: %s", bn, card.DisplayWithColor(game.topColor))) } } // Apply action effects eff := p.applyCardEffects(game, card) writeEffectLines(roomBuf, eff) game.turns++ } // --------------------------------------------------------------------------- // Auto-play timer // --------------------------------------------------------------------------- func (p *UnoPlugin) startMultiAutoPlayTimer(game *unoMultiGame) { if game.timer != nil { game.timer.Stop() } timeout := envInt("UNO_MULTI_TURN_TIMEOUT", 30) savedTurnID := game.turnID gameID := game.id game.timer = time.AfterFunc(time.Duration(timeout)*time.Second, func() { p.mu.Lock() mg, exists := p.multiGames[gameID] p.mu.Unlock() if !exists { return } mg.mu.Lock() defer mg.mu.Unlock() if mg.done || mg.turnID != savedTurnID { return // turn already changed } player := mg.currentPlayer() if player.isBot { return } player.autoPlays++ maxAutoPlays := envInt("UNO_MULTI_MAX_AUTOPLAY", 3) name := p.unoDisplayName(player.userID) if player.autoPlays >= maxAutoPlays { p.SendMessage(mg.roomID, fmt.Sprintf("๐Ÿƒ %s was auto-played %d times in a row โ€” forfeited!", name, maxAutoPlays)) p.multiPlayerForfeit(mg, player) return } // Auto-play: find first playable non-action card p.autoPlayMultiTurn(mg, player) }) } // startInactivityTimer starts (or resets) the 10-minute game timeout. // If no human input occurs within the window, the game ends and remaining players are refunded. // Caller must hold game.mu. func (p *UnoPlugin) startInactivityTimer(game *unoMultiGame) { if game.inactiveTimer != nil { game.inactiveTimer.Stop() } timeout := envInt("UNO_MULTI_INACTIVITY_TIMEOUT", 600) gameID := game.id game.inactiveTimer = time.AfterFunc(time.Duration(timeout)*time.Second, func() { p.mu.Lock() mg, exists := p.multiGames[gameID] p.mu.Unlock() if !exists { return } mg.mu.Lock() defer mg.mu.Unlock() if mg.done { return } mg.done = true if mg.timer != nil { mg.timer.Stop() } // Refund remaining human players for _, pl := range mg.players { if !pl.isBot && pl.active { p.euro.Credit(pl.userID, mg.ante, "uno_multi_timeout_refund") } } p.SendMessage(mg.roomID, "๐Ÿƒ **Game timed out** โ€” no human input for 10 minutes. All antes refunded.") p.recordMultiGame(mg, id.UserID(""), "timeout") p.cleanupMultiGame(mg) }) } func (p *UnoPlugin) autoPlayMultiTurn(game *unoMultiGame, player *unoMultiPlayer) { name := p.unoDisplayName(player.userID) switch game.phase { case unoMultiPhaseChooseColor: // Auto-pick most common color color := botPickColor(player.hand) game.topColor = color pendingCard := game.pendingCard game.pendingCard = nil game.phase = unoMultiPhasePlay p.SendMessage(player.dmRoomID, fmt.Sprintf("*Auto-played:* Color set to %s %s.", color.Emoji(), color)) if len(player.hand) == 0 { p.SendMessage(game.roomID, fmt.Sprintf("๐Ÿƒ *%s was auto-played.* Plays: %s (chose %s %s)", name, pendingCard.Display(), color.Emoji(), color)) p.multiPlayerWins(game, player) return } p.SendMessage(game.roomID, fmt.Sprintf("๐Ÿƒ *%s was auto-played.* Plays: %s (chose %s %s)", name, pendingCard.Display(), color.Emoji(), color)) p.applyAutoEffects(game, player, *pendingCard) return case unoMultiPhaseDrawnPlayable: // Auto-play: play the drawn card drawnCard := *game.drawnCard game.drawnCard = nil game.phase = unoMultiPhasePlay for i, c := range player.hand { if c == drawnCard { player.hand = append(player.hand[:i], player.hand[i+1:]...) break } } game.discardTop = drawnCard game.turns++ if drawnCard.Value == unoWildCard || drawnCard.Value == unoWildDrawFour { color := botPickColor(player.hand) game.topColor = color p.SendMessage(player.dmRoomID, fmt.Sprintf("*Auto-played:* %s (chose %s %s).", drawnCard.Display(), color.Emoji(), color)) if len(player.hand) == 0 { p.SendMessage(game.roomID, fmt.Sprintf("๐Ÿƒ *%s was auto-played.* Plays: %s (chose %s %s)", name, drawnCard.Display(), color.Emoji(), color)) p.multiPlayerWins(game, player) return } p.SendMessage(game.roomID, fmt.Sprintf("๐Ÿƒ *%s was auto-played.* Plays: %s (chose %s %s)", name, drawnCard.Display(), color.Emoji(), color)) p.applyAutoEffects(game, player, drawnCard) return } game.topColor = drawnCard.Color p.SendMessage(player.dmRoomID, fmt.Sprintf("*Auto-played:* %s.", drawnCard.Display())) if len(player.hand) == 0 { p.SendMessage(game.roomID, fmt.Sprintf("๐Ÿƒ *%s was auto-played.* Plays: %s", name, drawnCard.Display())) p.multiPlayerWins(game, player) return } p.SendMessage(game.roomID, fmt.Sprintf("๐Ÿƒ *%s was auto-played.* Plays: %s", name, drawnCard.Display())) p.applyAutoEffects(game, player, drawnCard) return case unoMultiPhasePlay: // Find first playable non-action card playIdx := -1 for i, c := range player.hand { if c.canPlayOn(game.discardTop, game.topColor) && !c.Value.isAction() && c.Value != unoWildDrawFour { playIdx = i break } } // If only action cards, play first playable if playIdx < 0 { for i, c := range player.hand { if c.canPlayOn(game.discardTop, game.topColor) { playIdx = i break } } } if playIdx >= 0 { card := player.hand[playIdx] player.hand = append(player.hand[:playIdx], player.hand[playIdx+1:]...) game.discardTop = card game.turns++ if card.Value == unoWildCard || card.Value == unoWildDrawFour { color := botPickColor(player.hand) game.topColor = color p.SendMessage(player.dmRoomID, fmt.Sprintf("*Auto-played:* %s (chose %s %s).", card.Display(), color.Emoji(), color)) if len(player.hand) == 0 { p.SendMessage(game.roomID, fmt.Sprintf("๐Ÿƒ *%s was auto-played.* Plays: %s (chose %s %s)", name, card.Display(), color.Emoji(), color)) p.multiPlayerWins(game, player) return } p.SendMessage(game.roomID, fmt.Sprintf("๐Ÿƒ *%s was auto-played.* Plays: %s (chose %s %s)", name, card.Display(), color.Emoji(), color)) p.applyAutoEffects(game, player, card) return } game.topColor = card.Color p.SendMessage(player.dmRoomID, fmt.Sprintf("*Auto-played:* %s.", card.Display())) if len(player.hand) == 0 { p.SendMessage(game.roomID, fmt.Sprintf("๐Ÿƒ *%s was auto-played.* Plays: %s", name, card.Display())) p.multiPlayerWins(game, player) return } p.SendMessage(game.roomID, fmt.Sprintf("๐Ÿƒ *%s was auto-played.* Plays: %s", name, card.Display())) p.applyAutoEffects(game, player, card) return } // No playable card โ€” draw drawn := game.draw(1) if len(drawn) == 0 { p.SendMessage(player.dmRoomID, "*Auto-played:* No cards to draw. Turn passes.") p.SendMessage(game.roomID, fmt.Sprintf("๐Ÿƒ *%s was auto-played.* No cards to draw.", name)) p.advanceAndExecute(game) return } card := drawn[0] player.hand = append(player.hand, card) if card.canPlayOn(game.discardTop, game.topColor) { // Auto-play the drawn card for i, c := range player.hand { if c == card { player.hand = append(player.hand[:i], player.hand[i+1:]...) break } } game.discardTop = card game.turns++ if card.Value == unoWildCard || card.Value == unoWildDrawFour { color := botPickColor(player.hand) game.topColor = color p.SendMessage(player.dmRoomID, fmt.Sprintf("*Auto-played:* Drew and played %s (chose %s %s).", card.Display(), color.Emoji(), color)) p.SendMessage(game.roomID, fmt.Sprintf("๐Ÿƒ *%s was auto-played.* Drew and played %s (chose %s %s)", name, card.Display(), color.Emoji(), color)) } else { game.topColor = card.Color p.SendMessage(player.dmRoomID, fmt.Sprintf("*Auto-played:* Drew and played %s.", card.Display())) p.SendMessage(game.roomID, fmt.Sprintf("๐Ÿƒ *%s was auto-played.* Drew and played %s", name, card.Display())) } if len(player.hand) == 0 { p.multiPlayerWins(game, player) return } p.applyAutoEffects(game, player, card) return } p.SendMessage(player.dmRoomID, fmt.Sprintf("*Auto-played:* Drew %s. Not playable. Turn passes.", card.Display())) p.SendMessage(game.roomID, fmt.Sprintf("๐Ÿƒ *%s was auto-played.* Draws a card. Turn passes. (%d cards)", name, len(player.hand))) p.advanceAndExecute(game) } } // applyAutoEffects applies card effects after an auto-play and advances the turn. func (p *UnoPlugin) applyAutoEffects(game *unoMultiGame, _ *unoMultiPlayer, card unoCard) { p.applyCardEffects(game, card) p.executeMultiTurn(game) } // --------------------------------------------------------------------------- // Win / Forfeit / Cleanup // --------------------------------------------------------------------------- func (p *UnoPlugin) multiPlayerWins(game *unoMultiGame, winner *unoMultiPlayer) { if game.done { return } game.done = true if game.timer != nil { game.timer.Stop() } if game.inactiveTimer != nil { game.inactiveTimer.Stop() } // Calculate pot (all human antes) humanCount := 0 for _, pl := range game.players { if !pl.isBot { humanCount++ } } totalPot := game.ante * float64(humanCount) name := p.unoDisplayName(winner.userID) p.euro.Credit(winner.userID, totalPot, "uno_multi_win") p.SendMessage(game.roomID, fmt.Sprintf( "๐ŸŽ‰ **%s wins Multiplayer UNO!**\nPot: โ‚ฌ%d | Turns: %d\n\n%s", name, int(totalPot), game.turns, pickCommentary("player_win"))) p.recordMultiGame(game, winner.userID, "win") p.cleanupMultiGame(game) } func (p *UnoPlugin) multiBotWins(game *unoMultiGame) { if game.done { return } game.done = true if game.timer != nil { game.timer.Stop() } if game.inactiveTimer != nil { game.inactiveTimer.Stop() } humanCount := 0 for _, pl := range game.players { if !pl.isBot { humanCount++ } } totalPot := game.ante * float64(humanCount) // Pot goes to community p.addToPot(totalPot) bn := unoBotName() p.SendMessage(game.roomID, fmt.Sprintf( "๐Ÿ’€ **%s wins Multiplayer UNO!**\nโ‚ฌ%d goes to the community pot.\n\n%s", bn, int(totalPot), pickCommentary("bot_win"))) p.recordMultiGame(game, id.UserID("bot"), "bot_win") p.cleanupMultiGame(game) } func (p *UnoPlugin) multiPlayerForfeit(game *unoMultiGame, player *unoMultiPlayer) { player.active = false name := p.unoDisplayName(player.userID) // Shuffle cards back into draw pile game.drawPile = append(game.drawPile, player.hand...) rand.Shuffle(len(game.drawPile), func(i, j int) { game.drawPile[i], game.drawPile[j] = game.drawPile[j], game.drawPile[i] }) player.hand = nil // Check if game should end active := game.activePlayers() if len(active) <= 1 { if len(active) == 1 { winner := active[0] if winner.isBot { p.SendMessage(game.roomID, fmt.Sprintf("๐Ÿƒ %s forfeited. All humans out!", name)) p.multiBotWins(game) } else { p.SendMessage(game.roomID, fmt.Sprintf("๐Ÿƒ %s forfeited. Only one player remains!", name)) p.multiPlayerWins(game, winner) } } else { game.done = true p.SendMessage(game.roomID, "๐Ÿƒ Game ended โ€” no players remaining.") p.cleanupMultiGame(game) } return } p.SendMessage(game.roomID, fmt.Sprintf("๐Ÿƒ %s forfeited! Game continues with %d players.", name, len(active))) // If it was this player's turn, advance if game.currentPlayer() == player || !game.currentPlayer().active { game.currentIdx = game.nextActiveIdx() game.turnID++ p.executeMultiTurn(game) } } func (p *UnoPlugin) cleanupMultiGame(game *unoMultiGame) { p.mu.Lock() defer p.mu.Unlock() delete(p.multiGames, game.id) for roomID, gID := range p.dmToMulti { if gID == game.id { delete(p.dmToMulti, roomID) } } } func (p *UnoPlugin) recordMultiGame(game *unoMultiGame, winnerID id.UserID, result string) { d := db.Get() playerIDs := make([]string, 0) for _, pl := range game.players { if !pl.isBot { playerIDs = append(playerIDs, string(pl.userID)) } } humanCount := len(playerIDs) totalPot := game.ante * float64(humanCount) _, err := d.Exec( `INSERT INTO uno_multi_games (room_id, ante, pot_total, winner_id, player_ids, result, turns, started_at, ended_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, string(game.roomID), game.ante, totalPot, string(winnerID), strings.Join(playerIDs, ","), result, game.turns, game.startedAt.UTC().Format("2006-01-02 15:04:05"), time.Now().UTC().Format("2006-01-02 15:04:05"), ) if err != nil { slog.Error("uno_multi: failed to record game", "err", err) } } // --------------------------------------------------------------------------- // Display // --------------------------------------------------------------------------- func (p *UnoPlugin) sendMultiStatus(game *unoMultiGame, player *unoMultiPlayer) { var sb strings.Builder bn := unoBotName() current := game.currentPlayer() currentName := p.unoDisplayName(current.userID) if current.isBot { currentName = bn } sb.WriteString(fmt.Sprintf("๐Ÿƒ **UNO Status**\nDiscard pile: %s\nCurrent turn: %s\n\n", game.discardTop.DisplayWithColor(game.topColor), currentName)) sb.WriteString("**Your hand:**\n") for i, c := range player.hand { playable := "" if c.canPlayOn(game.discardTop, game.topColor) { playable = " โœ…" } sb.WriteString(fmt.Sprintf("%d. %s%s\n", i+1, c.Display(), playable)) } sb.WriteString("\nCard counts: ") var counts []string for _, pl := range game.players { if pl == player || !pl.active { continue } name := p.unoDisplayName(pl.userID) if pl.isBot { name = bn } counts = append(counts, fmt.Sprintf("%s (%d)", name, len(pl.hand))) } sb.WriteString(strings.Join(counts, " | ")) p.SendMessage(player.dmRoomID, sb.String()) } func (p *UnoPlugin) sendMultiHandDisplay(game *unoMultiGame, player *unoMultiPlayer) { var sb strings.Builder sb.WriteString(fmt.Sprintf("๐Ÿƒ **Your turn!**\nDiscard pile: %s\n\n**Your hand:**\n", game.discardTop.DisplayWithColor(game.topColor))) for i, c := range player.hand { playable := "" if c.canPlayOn(game.discardTop, game.topColor) { playable = " โœ…" } sb.WriteString(fmt.Sprintf("%d. %s%s\n", i+1, c.Display(), playable)) } // Card counts for all opponents sb.WriteString("\nCard counts: ") counts := make([]string, 0) bn := unoBotName() for _, pl := range game.players { if pl == player || !pl.active { continue } name := p.unoDisplayName(pl.userID) if pl.isBot { name = bn } counts = append(counts, fmt.Sprintf("%s (%d)", name, len(pl.hand))) } sb.WriteString(strings.Join(counts, " | ")) sb.WriteString("\n\nReply with a card number to play, or **draw** to draw.") p.SendMessage(player.dmRoomID, sb.String()) }