Files
gogobee/internal/plugin/uno_multi.go
prosolis 0d3485c7c2 Add market plugin, arena, holidays, UNO stacking fix, and multiple UX improvements
- Market plugin (#49): daily index snapshots (Yahoo Finance + Finnhub fallback),
  Ollama-generated summaries, !howsthemarket, !marketstatus, !marketreport commands,
  exchange hours with DST support, 30-min room cooldown
- Adventure arena: 5-tier combat gauntlet with 20 monsters, risk-reward cashout,
  death lockout changed from 24h to midnight UTC across all code and flavor text
- Adventure holidays: double daily actions on holidays
- Adventure DM fix: 15-minute response window prevents bare numbers from triggering
  adventure during UNO games
- Adventure scheduler: jitter between morning DMs to avoid Matrix rate limits
- Adventure revive: wire up adv_revived achievement on admin revive
- Column migration system for existing databases (holiday_action_taken)
- Fix italic markdown rendering after newlines
- Fix holdem DMs broken by space groups including DM rooms
- UNO No Mercy: remove stacking escalation rule (any draw card stacks on any other)
- UNO multiplayer: add turn announcements after stack absorption and turn passes,
  jitter between rapid-fire messages with safe mutex handling
- Birthday: add €1,000 gift and 10 XP + €100 community celebration bonus
- Market: guard against division by zero, nil pointer, and timezone errors
- README updates: plugin count 48→49, all new commands, feature flags, architecture

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-27 21:26:19 -07:00

2665 lines
76 KiB
Go
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
unoMultiPhaseChallenge // next player may challenge a Wild Draw Four
unoMultiPhaseChooseSwapTarget // No Mercy: player played a 7, must choose swap target
)
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
// Wild Draw Four challenge state
wd4Player *unoMultiPlayer // who played the WD4
wd4Victim *unoMultiPlayer // who can challenge
wd4PrevColor unoColor // color before the wild was played
turns int
turnID int // monotonic, used to invalidate stale timers
startedAt time.Time
done bool
bookDown bool
// No Mercy mode
noMercy bool
sevenZeroRule bool
stackTotal int // cumulative draw penalty during stacking
stackMinValue int // minimum draw value to stack (0 = not stacking)
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
noMercy bool
sevenZeroRule bool
}
// ---------------------------------------------------------------------------
// 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() {
var fresh []unoCard
if g.noMercy {
fresh = newNoMercyDeck()
} else {
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, noMercy, sevenZeroRule bool) 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(),
noMercy: noMercy,
sevenZeroRule: sevenZeroRule,
}
lobby.timer = time.AfterFunc(time.Duration(timeout)*time.Second, func() {
p.lobbyExpired(ctx.RoomID)
})
p.lobbies[ctx.RoomID] = lobby
p.mu.Unlock()
creatorName := p.DisplayName(ctx.Sender)
modeTag := ""
if noMercy {
modeTag = " 🔥 NO MERCY"
if sevenZeroRule {
modeTag += " (7-0)"
}
}
return p.SendMessage(ctx.RoomID, fmt.Sprintf(
"🃏 **UNO Lobby**%s — Ante: €%d\nPlayers (1/4):\n 1. %s (host)\n\nType `!uno join` to join or `!uno go` to start!",
modeTag, 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
lobbyModeTag := ""
if lobby.noMercy {
lobbyModeTag = " 🔥 NO MERCY"
if lobby.sevenZeroRule {
lobbyModeTag += " (7-0)"
}
}
sb.WriteString(fmt.Sprintf("🃏 **UNO Lobby**%s — Ante: €%d\nPlayers (%d/4):\n", lobbyModeTag, int(lobby.ante), count))
for i, uid := range lobby.players {
name := p.DisplayName(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.DisplayName(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
noMercy := lobby.noMercy
sevenZeroRule := lobby.sevenZeroRule
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.DisplayName(uid)))
}
resolved = append(resolved, playerDMPair{uid, dmRoom})
}
// Build game
game := p.initMultiGame(resolved, roomID, ante, noMercy, sevenZeroRule)
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()
modeTag := ""
if noMercy {
modeTag = " 🔥 NO MERCY"
if sevenZeroRule {
modeTag += " (7-0)"
}
}
sb.WriteString(fmt.Sprintf("🃏 **Multiplayer UNO!**%s Pot: €%d\n\nPlayers:\n", modeTag, int(ante)*len(players)))
for i, pl := range game.players {
name := p.DisplayName(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))
}
startComment := pickCommentary("start")
if noMercy {
startComment = pickNoMercyCommentary("nomercy_start")
}
sb.WriteString(fmt.Sprintf("\nStarting card: %s\n%s\n\n[Check your DMs!]",
game.discardTop.DisplayWithColor(game.topColor), startComment))
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, noMercy, sevenZeroRule bool) *unoMultiGame {
var deck []unoCard
if noMercy {
deck = newNoMercyDeck()
} else {
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 be a number card
var startCard unoCard
startIdx := -1
for i, c := range remaining {
if !c.Value.isAction() && !c.isWild() {
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(),
noMercy: noMercy,
sevenZeroRule: sevenZeroRule,
}
}
// ---------------------------------------------------------------------------
// 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
loopMsgsSent := 0 // track messages sent across loop iterations to add jitter
for {
if game.done {
return
}
// Jitter between rapid-fire messages to avoid Matrix rate limits.
// Drop the lock during sleep so DM handlers aren't blocked.
if loopMsgsSent > 0 {
savedTurn := game.turnID
game.mu.Unlock()
time.Sleep(time.Duration(300+rand.IntN(400)) * time.Millisecond)
game.mu.Lock()
// A DM handler may have advanced the game while we slept.
if game.done || game.turnID != savedTurn {
return
}
}
player := game.currentPlayer()
// Skip eliminated players (e.g. mercy-killed during their own turn)
if !player.active {
game.currentIdx = game.nextActiveIdx()
game.turnID++
continue
}
if player.isBot {
botTurnsInRow++
if botTurnsInRow > 10 {
// Safety: shouldn't happen, but prevent infinite loops
break
}
p.botMultiTurn(game, &roomBuf)
loopMsgsSent++
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()
}
// No Mercy stacking: check if player must absorb
if game.noMercy && game.stackMinValue > 0 {
if !hasStackableCard(player.hand, game.topColor, game.stackMinValue) {
name := p.DisplayName(player.userID)
drawn := game.draw(game.stackTotal)
player.hand = append(player.hand, drawn...)
p.SendMessage(player.dmRoomID, fmt.Sprintf("💥 No stackable card! You draw %d cards.\n%s",
game.stackTotal, pickNoMercyCommentary("stack_absorbed")))
absorbMsg := fmt.Sprintf("💥 %s absorbs the stack! Draws %d cards. (%d cards now)",
name, game.stackTotal, len(player.hand))
game.stackTotal = 0
game.stackMinValue = 0
if p.checkMultiMercyElimination(game, player) {
p.SendMessage(game.roomID, absorbMsg)
loopMsgsSent++
if game.done {
return
}
game.currentIdx = game.nextActiveIdx()
game.turnID++
continue
}
// Skip this player's turn
game.currentIdx = game.nextActiveIdx()
game.turnID++
p.SendMessage(game.roomID, absorbMsg+"\n"+p.nextTurnLabel(game))
loopMsgsSent++
continue
}
// Player has stackable cards — show stacking prompt
game.phase = unoMultiPhasePlay
p.sendMultiHandDisplayStacking(game, player)
p.startMultiAutoPlayTimer(game)
return
}
// Auto-draw if no playable cards
if !game.hasPlayable(player.hand) {
if game.noMercy {
// Draw until playable
var allDrawn []unoCard
var playableCard *unoCard
for {
cards := game.draw(1)
if len(cards) == 0 {
break
}
card := cards[0]
player.hand = append(player.hand, card)
allDrawn = append(allDrawn, card)
if p.checkMultiMercyElimination(game, player) {
if game.done {
return
}
game.currentIdx = game.nextActiveIdx()
game.turnID++
break
}
if card.canPlayOn(game.discardTop, game.topColor) {
playableCard = &card
break
}
}
if !player.active {
continue // mercy-killed
}
name := p.DisplayName(player.userID)
if len(allDrawn) == 0 {
p.SendMessage(player.dmRoomID, "No playable cards and deck is empty. Turn passes.")
game.currentIdx = game.nextActiveIdx()
game.turnID++
p.SendMessage(game.roomID, fmt.Sprintf("🃏 %s has no playable cards and deck is empty. Turn passes. (%d cards)\n%s", name, len(player.hand), p.nextTurnLabel(game)))
loopMsgsSent += 2
continue
}
if playableCard != nil {
game.drawnCard = playableCard
game.phase = unoMultiPhaseDrawnPlayable
p.SendMessage(player.dmRoomID,
fmt.Sprintf("No playable cards — drew %d card(s): %s\nLast card is playable! Play it? (**yes** / **no**)",
len(allDrawn), formatDrawnCards(allDrawn)))
p.SendMessage(game.roomID, fmt.Sprintf("🃏 %s draws %d card(s). (%d cards)", name, len(allDrawn), len(player.hand)))
p.startMultiAutoPlayTimer(game)
return
}
p.SendMessage(player.dmRoomID,
fmt.Sprintf("No playable cards — drew %d card(s). None playable. Turn passes.", len(allDrawn)))
game.currentIdx = game.nextActiveIdx()
game.turnID++
p.SendMessage(game.roomID, fmt.Sprintf("🃏 %s draws %d card(s). Turn passes. (%d cards)\n%s", name, len(allDrawn), len(player.hand), p.nextTurnLabel(game)))
loopMsgsSent += 2
continue
}
// Classic: draw 1
drawn := game.draw(1)
if len(drawn) == 0 {
name := p.DisplayName(player.userID)
p.SendMessage(player.dmRoomID, "No playable cards and deck is empty. Turn passes.")
game.currentIdx = game.nextActiveIdx()
game.turnID++
p.SendMessage(game.roomID, fmt.Sprintf("🃏 %s has no playable cards and deck is empty. Turn passes. (%d cards)\n%s", name, len(player.hand), p.nextTurnLabel(game)))
loopMsgsSent += 2
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.DisplayName(player.userID)
p.SendMessage(player.dmRoomID,
fmt.Sprintf("No playable cards — drew automatically: %s\nNot playable. Turn passes.", card.Display()))
game.currentIdx = game.nextActiveIdx()
game.turnID++
p.SendMessage(game.roomID, fmt.Sprintf("🃏 %s draws a card. Turn passes. (%d cards)\n%s", name, len(player.hand), p.nextTurnLabel(game)))
loopMsgsSent += 2
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)
}
// nextTurnLabel returns "It's X's turn." for the current player.
func (p *UnoPlugin) nextTurnLabel(game *unoMultiGame) string {
next := game.currentPlayer()
name := p.DisplayName(next.userID)
if next.isBot {
name = unoBotName()
}
return fmt.Sprintf("It's %s's turn.", name)
}
// ---------------------------------------------------------------------------
// 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 unoMultiPhaseChallenge:
return p.handleMultiChallengeInput(game, player, input)
case unoMultiPhaseChooseColor:
return p.handleMultiColorChoice(game, player, input)
case unoMultiPhaseDrawnPlayable:
return p.handleMultiDrawnPlayable(game, player, input)
case unoMultiPhaseChooseSwapTarget:
return p.handleMultiSwapChoice(game, player, input)
case unoMultiPhasePlay:
if input == "uno" {
player.calledUno = true
p.SendMessage(player.dmRoomID, "✅ UNO called!")
return nil
}
// No Mercy stacking: accept
if game.noMercy && game.stackMinValue > 0 && (input == "accept" || input == "a") {
name := p.DisplayName(player.userID)
drawn := game.draw(game.stackTotal)
player.hand = append(player.hand, drawn...)
p.SendMessage(player.dmRoomID, fmt.Sprintf("💥 You accept the stack and draw %d cards.\n%s",
game.stackTotal, pickNoMercyCommentary("stack_absorbed")))
absorbMsg := fmt.Sprintf("💥 %s absorbs the stack! Draws %d cards. (%d cards now)",
name, game.stackTotal, len(player.hand))
game.stackTotal = 0
game.stackMinValue = 0
if p.checkMultiMercyElimination(game, player) {
p.SendMessage(game.roomID, absorbMsg)
if game.done {
return nil
}
p.advanceAndExecute(game)
return nil
}
game.currentIdx = game.nextActiveIdx()
game.turnID++
game.turns++
p.SendMessage(game.roomID, absorbMsg+"\n"+p.nextTurnLabel(game))
p.executeMultiTurn(game)
return nil
}
// "accept" alias handled above; "draw" during stacking = same as accept
if input == "draw" {
if game.noMercy && game.stackMinValue > 0 {
p.SendMessage(player.dmRoomID, "You must play a draw card to stack, or type **accept** to draw the stack.")
return nil
}
return p.handleMultiPlayerDraw(game, player)
}
var cardIdx int
if _, err := fmt.Sscanf(input, "%d", &cardIdx); err != nil || cardIdx < 1 || cardIdx > len(player.hand) {
if game.noMercy && game.stackMinValue > 0 {
p.SendMessage(player.dmRoomID,
fmt.Sprintf("Reply with a card number (1-%d) to stack, or **accept** to draw %d cards.", len(player.hand), game.stackTotal))
} else {
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) handleMultiChallengeInput(game *unoMultiGame, player *unoMultiPlayer, input string) error {
switch input {
case "challenge", "c":
name := p.DisplayName(player.userID)
p.SendMessage(game.roomID, fmt.Sprintf("⚡ %s challenges the Wild Draw Four!", name))
p.resolveWD4Challenge(game, true)
case "accept", "a":
p.resolveWD4Challenge(game, false)
default:
p.SendMessage(player.dmRoomID, "Type **challenge** or **accept**.")
}
return nil
}
func (p *UnoPlugin) handleMultiPlayerPlay(game *unoMultiGame, player *unoMultiPlayer, idx int) error {
card := player.hand[idx]
// No Mercy stacking validation
if game.noMercy && game.stackMinValue > 0 {
if !card.canPlayOnStacking(game.topColor, game.stackMinValue) {
p.SendMessage(player.dmRoomID,
fmt.Sprintf("You can't stack %s — need a draw card (matching color or wild). Type **accept** to draw %d.",
card.Display(), game.stackTotal))
return nil
}
} else 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 (skip during stacking)
if game.stackMinValue == 0 && 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.DisplayName(player.userID)
p.SendMessage(game.roomID, fmt.Sprintf("🃏 %s forgot to call UNO! 2 card penalty.", name))
if game.noMercy {
if p.checkMultiMercyElimination(game, player) {
if game.done {
return nil
}
p.advanceAndExecute(game)
return nil
}
}
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
}
// No Mercy: Discard All — remove remaining cards of same color
if game.noMercy && card.Value == unoDiscardAll {
discarded := discardAllOfColor(&player.hand, card.Color)
if discarded > 0 {
p.SendMessage(player.dmRoomID, fmt.Sprintf("Discarded %d additional %s cards!", discarded, card.Color))
}
}
// Wild — need color choice
if card.isWild() {
game.pendingCard = &card
if card.Value == unoWildDrawFour {
game.wd4PrevColor = game.topColor
}
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
// No Mercy: 7-0 rule
if game.noMercy && game.sevenZeroRule {
if card.Value == unoSeven && len(game.activePlayers()) > 2 {
// Choose swap target
game.phase = unoMultiPhaseChooseSwapTarget
var sb strings.Builder
sb.WriteString("You played a **7**! Choose a player to swap hands with:\n")
i := 1
for _, pl := range game.players {
if pl == player || !pl.active {
continue
}
name := p.DisplayName(pl.userID)
if pl.isBot {
name = unoBotName()
}
sb.WriteString(fmt.Sprintf("%d. %s (%d cards)\n", i, name, len(pl.hand)))
i++
}
p.SendMessage(player.dmRoomID, sb.String())
p.startMultiAutoPlayTimer(game)
return nil
}
if card.Value == unoSeven {
// 2-player: swap with the other player
for _, pl := range game.players {
if pl != player && pl.active {
swapHandsMulti(player, pl)
name := p.DisplayName(player.userID)
otherName := p.DisplayName(pl.userID)
if pl.isBot {
otherName = unoBotName()
}
p.SendMessage(game.roomID, fmt.Sprintf("🔄 %s swaps hands with %s! %s",
name, otherName, pickNoMercyCommentary("hand_swap")))
if !pl.isBot {
p.SendMessage(pl.dmRoomID, fmt.Sprintf("🔄 %s swapped hands with you! You now have %d cards.", name, len(pl.hand)))
}
break
}
}
}
if card.Value == unoZero {
rotateHandsMulti(game)
p.SendMessage(game.roomID, fmt.Sprintf("🔄 Hands rotated! %s", pickNoMercyCommentary("hand_rotate")))
// Notify all active human players about their new hand size
for _, pl := range game.players {
if pl.active && !pl.isBot {
p.SendMessage(pl.dmRoomID, fmt.Sprintf("🔄 Hands rotated! You now have %d cards.", len(pl.hand)))
}
}
}
}
// Check win (after discard all, after swap)
if len(player.hand) == 0 {
name := p.DisplayName(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.DisplayName(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
}
// No Mercy: Color Roulette — next player flips until chosen color
if game.noMercy && pendingCard != nil && pendingCard.Value == unoWildColorRoulette {
name := p.DisplayName(player.userID)
nextIdx := game.nextActiveIdx()
target := game.players[nextIdx]
targetName := p.DisplayName(target.userID)
if target.isBot {
targetName = unoBotName()
}
flipped := p.executeColorRouletteMulti(game, target, color)
p.SendMessage(game.roomID, fmt.Sprintf("🃏 %s plays: %s (chose %s %s)\n🎰 Color Roulette! %s flips %d cards until finding %s.\n%s",
name, pendingCard.Display(), color.Emoji(), color,
targetName, len(flipped), color, pickNoMercyCommentary("color_roulette")))
if !target.isBot {
p.SendMessage(target.dmRoomID, fmt.Sprintf("🎰 Color Roulette! You drew %d cards. You now have %d cards.", len(flipped), len(target.hand)))
}
if p.checkMultiMercyElimination(game, target) {
if game.done {
return nil
}
}
// Skip the target player, advance to next
game.currentIdx = nextIdx
game.currentIdx = game.nextActiveIdx()
game.turnID++
p.executeMultiTurn(game)
return nil
}
// Apply effects
p.applyAndAnnounce(game, player, *pendingCard)
return nil
}
func (p *UnoPlugin) handleMultiPlayerDraw(game *unoMultiGame, player *unoMultiPlayer) error {
// No Mercy: draw until playable
if game.noMercy {
return p.handleMultiPlayerDrawNoMercy(game, player)
}
drawn := game.draw(1)
if len(drawn) == 0 {
p.SendMessage(player.dmRoomID, "No cards left to draw! Turn passes.")
name := p.DisplayName(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.DisplayName(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) handleMultiPlayerDrawNoMercy(game *unoMultiGame, player *unoMultiPlayer) error {
var allDrawn []unoCard
var playableCard *unoCard
for {
drawn := game.draw(1)
if len(drawn) == 0 {
break
}
card := drawn[0]
player.hand = append(player.hand, card)
allDrawn = append(allDrawn, card)
if p.checkMultiMercyElimination(game, player) {
if game.done {
return nil
}
p.advanceAndExecute(game)
return nil
}
if card.canPlayOn(game.discardTop, game.topColor) {
playableCard = &card
break
}
}
name := p.DisplayName(player.userID)
if len(allDrawn) == 0 {
p.SendMessage(player.dmRoomID, "No cards left to draw! Turn passes.")
p.SendMessage(game.roomID, fmt.Sprintf("🃏 %s can't draw — deck is empty. Turn passes.", name))
p.advanceAndExecute(game)
return nil
}
if playableCard != nil {
game.drawnCard = playableCard
game.phase = unoMultiPhaseDrawnPlayable
p.SendMessage(player.dmRoomID,
fmt.Sprintf("Drew %d card(s): %s\nLast card is playable! Play it? (**yes** / **no**)",
len(allDrawn), formatDrawnCards(allDrawn)))
p.SendMessage(game.roomID, fmt.Sprintf("🃏 %s draws %d card(s). (%d cards)", name, len(allDrawn), len(player.hand)))
p.startMultiAutoPlayTimer(game)
return nil
}
p.SendMessage(player.dmRoomID,
fmt.Sprintf("Drew %d card(s). None playable. Turn passes.", len(allDrawn)))
p.SendMessage(game.roomID, fmt.Sprintf("🃏 %s draws %d card(s). Turn passes. (%d cards)", name, len(allDrawn), 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.DisplayName(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.isWild() {
game.pendingCard = &drawnCard
if drawnCard.Value == unoWildDrawFour {
game.wd4PrevColor = game.topColor
}
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.DisplayName(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)
needsChallenge bool // true if WD4 challenge phase should start
stackPending bool // true if draw stacking is in progress (No Mercy)
}
// 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.DisplayName(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 unoSkipEveryone:
if game.noMercy {
// Skip all others — current player goes again
var skippedNames []string
for _, pl := range game.players {
if pl != game.currentPlayer() && pl.active {
name := p.DisplayName(pl.userID)
if pl.isBot {
name = unoBotName()
}
skippedNames = append(skippedNames, name)
}
}
result.skippedName = strings.Join(skippedNames, ", ")
// Don't advance — current player keeps their turn
game.turnID++
} else {
// Treat as skip in classic
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, unoDrawFour:
if game.noMercy {
// No Mercy: stacking
dv := cardDrawValue(card.Value)
game.stackTotal += dv
game.stackMinValue = dv
result.stackPending = true
result.skippedName = nextName
result.drawnCount = game.stackTotal
game.currentIdx = game.nextActiveIdx()
game.turnID++
} else if card.Value == unoDrawTwo {
// Classic Draw Two
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++
} else {
// DrawFour shouldn't appear in classic, but handle gracefully
game.currentIdx = game.nextActiveIdx()
game.turnID++
}
case unoWildDrawFour:
if game.noMercy {
// Should not appear in No Mercy deck, but handle gracefully
game.currentIdx = game.nextActiveIdx()
game.turnID++
} else {
result.needsChallenge = true
result.skippedName = nextName
}
case unoWildReverseDraw4:
if game.noMercy {
game.direction *= -1
result.reversed = true
dv := cardDrawValue(card.Value)
game.stackTotal += dv
game.stackMinValue = dv
result.stackPending = true
// After reverse, get next player in new direction
nextIdx = game.nextActiveIdx()
nextPlayer = game.players[nextIdx]
nextName = p.DisplayName(nextPlayer.userID)
if nextPlayer.isBot {
nextName = unoBotName()
}
result.skippedName = nextName
result.drawnCount = game.stackTotal
game.currentIdx = game.nextActiveIdx()
game.turnID++
}
case unoWildDrawSix, unoWildDrawTen:
if game.noMercy {
dv := cardDrawValue(card.Value)
game.stackTotal += dv
game.stackMinValue = dv
result.stackPending = true
result.skippedName = nextName
result.drawnCount = game.stackTotal
game.currentIdx = game.nextActiveIdx()
game.turnID++
}
case unoWildColorRoulette:
// Color roulette effect is handled in color choice handler, not here
game.currentIdx = game.nextActiveIdx()
game.turnID++
case unoDiscardAll:
// Discard effect already applied before calling this function
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.needsChallenge {
sb.WriteString(fmt.Sprintf("\n %s may challenge! ⚡", eff.skippedName))
} else if eff.stackPending {
sb.WriteString(fmt.Sprintf("\n 🔥 Stack incoming! (+%d) — %s must stack or absorb!", eff.drawnCount, eff.skippedName))
if eff.reversed {
sb.WriteString("\n Direction reversed!")
}
} else 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.DisplayName(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)
// UNO announcement
if len(player.hand) == 1 && player.calledUno {
roomMsg.WriteString(fmt.Sprintf("\n %s calls UNO! 🔥", name))
}
// Wild Draw Four — enter challenge phase
if eff.needsChallenge {
p.SendMessage(game.roomID, roomMsg.String())
p.startWD4Challenge(game, player)
return
}
// 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"))
}
}
}
// Next player
nextUp := game.currentPlayer()
nextUpName := p.DisplayName(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)
}
// ---------------------------------------------------------------------------
// Wild Draw Four challenge
// ---------------------------------------------------------------------------
// startWD4Challenge enters the challenge phase. The victim can challenge or accept.
// Caller must hold game.mu.
func (p *UnoPlugin) startWD4Challenge(game *unoMultiGame, wd4Player *unoMultiPlayer) {
nextIdx := game.nextActiveIdx()
victim := game.players[nextIdx]
game.wd4Player = wd4Player
game.wd4Victim = victim
game.phase = unoMultiPhaseChallenge
game.currentIdx = nextIdx
game.turnID++
playerName := p.DisplayName(wd4Player.userID)
if wd4Player.isBot {
playerName = unoBotName()
}
if victim.isBot {
// Bot decides whether to challenge
p.botHandleWD4Challenge(game)
return
}
p.SendMessage(victim.dmRoomID,
fmt.Sprintf("⚡ **%s** played Wild Draw Four!\nYou can **challenge** — if they had a %s %s card, they draw 4 instead.\nIf the challenge fails, you draw 6.\n\nType **challenge** or **accept**.",
playerName, game.wd4PrevColor.Emoji(), game.wd4PrevColor))
p.startMultiAutoPlayTimer(game)
}
// resolveWD4Challenge resolves the challenge. Caller must hold game.mu.
func (p *UnoPlugin) resolveWD4Challenge(game *unoMultiGame, challenged bool) {
wd4Player := game.wd4Player
victim := game.wd4Victim
wd4Name := p.DisplayName(wd4Player.userID)
if wd4Player.isBot {
wd4Name = unoBotName()
}
victimName := p.DisplayName(victim.userID)
if victim.isBot {
victimName = unoBotName()
}
// Clear challenge state
game.wd4Player = nil
game.wd4Victim = nil
game.phase = unoMultiPhasePlay
if !challenged {
// Victim accepts — draw 4, get skipped
drawn := game.draw(4)
victim.hand = append(victim.hand, drawn...)
p.SendMessage(game.roomID,
fmt.Sprintf("🃏 %s accepts the Wild Draw Four. Draws 4 and is skipped!", victimName))
game.currentIdx = game.nextActiveIdx()
game.turnID++
p.executeMultiTurn(game)
return
}
// Check if the WD4 player had a card matching the previous color
hadMatch := false
for _, c := range wd4Player.hand {
if c.Color == game.wd4PrevColor {
hadMatch = true
break
}
}
if hadMatch {
// Challenge succeeds — WD4 player drew illegally, they draw 4
drawn := game.draw(4)
wd4Player.hand = append(wd4Player.hand, drawn...)
p.SendMessage(game.roomID,
fmt.Sprintf("⚡ **Challenge successful!** %s had a %s %s card. %s draws 4!",
wd4Name, game.wd4PrevColor.Emoji(), game.wd4PrevColor, wd4Name))
// Victim is NOT skipped — turn continues from victim
game.turnID++
p.executeMultiTurn(game)
} else {
// Challenge fails — victim draws 6 (4 + 2 penalty)
drawn := game.draw(6)
victim.hand = append(victim.hand, drawn...)
p.SendMessage(game.roomID,
fmt.Sprintf("⚡ **Challenge failed!** %s played legally. %s draws 6!",
wd4Name, victimName))
// Victim is skipped
game.currentIdx = game.nextActiveIdx()
game.turnID++
p.executeMultiTurn(game)
}
}
// botHandleWD4Challenge decides whether the bot challenges a WD4. Caller must hold game.mu.
func (p *UnoPlugin) botHandleWD4Challenge(game *unoMultiGame) {
// More cards = more likely they had a matching color = more likely to challenge.
// 1-3 cards: 10%, 4-5: 30%, 6-7: 50%, 8+: 70%
cards := len(game.wd4Player.hand)
threshold := 10
if cards >= 8 {
threshold = 70
} else if cards >= 6 {
threshold = 50
} else if cards >= 4 {
threshold = 30
}
challenged := rand.IntN(100) < threshold
bn := unoBotName()
if challenged {
p.SendMessage(game.roomID, fmt.Sprintf("⚡ %s challenges the Wild Draw Four!", bn))
} else {
p.SendMessage(game.roomID, fmt.Sprintf("🃏 %s accepts the Wild Draw Four.", bn))
}
p.resolveWD4Challenge(game, challenged)
}
// ---------------------------------------------------------------------------
// Bot turn (multiplayer)
// ---------------------------------------------------------------------------
func (p *UnoPlugin) botMultiTurn(game *unoMultiGame, roomBuf *strings.Builder) {
bot := game.currentPlayer()
bn := unoBotName()
// No Mercy stacking: bot must stack or absorb
if game.noMercy && game.stackMinValue > 0 {
stackCard, sIdx := botPickStackCard(bot.hand, game.topColor, game.stackMinValue)
if sIdx < 0 {
// Bot absorbs the stack
drawn := game.draw(game.stackTotal)
bot.hand = append(bot.hand, drawn...)
if roomBuf.Len() > 0 {
roomBuf.WriteString("\n")
}
roomBuf.WriteString(fmt.Sprintf("💥 %s absorbs the stack! Draws %d cards. (%d cards now)",
bn, game.stackTotal, len(bot.hand)))
game.stackTotal = 0
game.stackMinValue = 0
if p.checkMultiMercyElimination(game, bot) {
if roomBuf.Len() > 0 {
p.SendMessage(game.roomID, roomBuf.String())
roomBuf.Reset()
}
return
}
game.currentIdx = game.nextActiveIdx()
game.turnID++
return
}
// Bot stacks
bot.hand = append(bot.hand[:sIdx], bot.hand[sIdx+1:]...)
game.discardTop = stackCard
dv := cardDrawValue(stackCard.Value)
game.stackTotal += dv
game.stackMinValue = dv
if stackCard.isWild() {
game.topColor = botPickColor(bot.hand)
} else {
game.topColor = stackCard.Color
}
if roomBuf.Len() > 0 {
roomBuf.WriteString("\n")
}
roomBuf.WriteString(fmt.Sprintf("🔥 %s stacks: %s! Total penalty: +%d",
bn, stackCard.DisplayWithColor(game.topColor), game.stackTotal))
if stackCard.Value == unoWildReverseDraw4 {
game.direction *= -1
roomBuf.WriteString(" (direction reversed!)")
}
game.currentIdx = game.nextActiveIdx()
game.turnID++
game.turns++
return
}
var card unoCard
var idx int
if game.noMercy {
card, idx = botPickCardNoMercy(bot.hand, game.discardTop, game.topColor, game.bookDown, game.minOpponentCards(game.currentIdx), 0)
} else {
card, idx = botPickCard(bot.hand, game.discardTop, game.topColor, game.bookDown, game.minOpponentCards(game.currentIdx))
}
if idx < 0 {
// Bot draws
if game.noMercy {
// Draw until playable
var allDrawn []unoCard
var playableCard *unoCard
for {
cards := game.draw(1)
if len(cards) == 0 {
break
}
c := cards[0]
bot.hand = append(bot.hand, c)
allDrawn = append(allDrawn, c)
if p.checkMultiMercyElimination(game, bot) {
if roomBuf.Len() > 0 {
p.SendMessage(game.roomID, roomBuf.String())
roomBuf.Reset()
}
return
}
if c.canPlayOn(game.discardTop, game.topColor) {
playableCard = &c
break
}
}
if len(allDrawn) == 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
}
if playableCard != nil {
// Remove from hand and play
for i := len(bot.hand) - 1; i >= 0; i-- {
if bot.hand[i] == *playableCard {
bot.hand = append(bot.hand[:i], bot.hand[i+1:]...)
break
}
}
if roomBuf.Len() > 0 {
roomBuf.WriteString("\n")
}
roomBuf.WriteString(fmt.Sprintf("🃏 %s draws %d card(s).", bn, len(allDrawn)))
card = *playableCard
// Fall through to play the card below
} else {
if roomBuf.Len() > 0 {
roomBuf.WriteString("\n")
}
roomBuf.WriteString(fmt.Sprintf("🃏 %s draws %d card(s). No playable card.", bn, len(allDrawn)))
game.currentIdx = game.nextActiveIdx()
game.turnID++
return
}
} else {
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 {
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
// No Mercy: Discard All — bot discards remaining cards of same color
if game.noMercy && card.Value == unoDiscardAll {
discardAllOfColor(&bot.hand, card.Color)
}
// Wild color choice
if card.isWild() {
if card.Value == unoWildDrawFour {
game.wd4PrevColor = game.topColor
}
if game.noMercy && card.Value == unoWildColorRoulette {
game.topColor = botRouletteColor(bot.hand)
} else {
game.topColor = botPickColor(bot.hand)
}
} else {
game.topColor = card.Color
}
// No Mercy: 7-0 rule
if game.noMercy && game.sevenZeroRule {
if card.Value == unoSeven {
target := botChooseSwapTarget(game, bot)
if target != nil {
swapHandsMulti(bot, target)
if !target.isBot {
p.SendMessage(target.dmRoomID, fmt.Sprintf("🔄 %s swapped hands with you! You now have %d cards.", bn, len(target.hand)))
}
}
}
if card.Value == unoZero {
rotateHandsMulti(game)
for _, pl := range game.players {
if pl.active && !pl.isBot {
p.SendMessage(pl.dmRoomID, fmt.Sprintf("🔄 Hands rotated! You now have %d cards.", len(pl.hand)))
}
}
}
}
// 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)
// WD4 challenge — flush buffer and enter challenge phase
if eff.needsChallenge {
if roomBuf.Len() > 0 {
p.SendMessage(game.roomID, roomBuf.String())
roomBuf.Reset()
}
game.turns++
p.startWD4Challenge(game, bot)
return
}
// No Mercy: Color Roulette — next player flips cards
// applyCardEffects already advanced currentIdx to the victim
if game.noMercy && card.Value == unoWildColorRoulette {
target := game.players[game.currentIdx]
targetName := p.DisplayName(target.userID)
if target.isBot {
targetName = bn
}
flipped := p.executeColorRouletteMulti(game, target, game.topColor)
roomBuf.WriteString(fmt.Sprintf("\n 🎰 Color Roulette! %s flips %d cards until finding %s %s.",
targetName, len(flipped), game.topColor.Emoji(), game.topColor))
if !target.isBot {
p.SendMessage(target.dmRoomID, fmt.Sprintf("🎰 Color Roulette! You drew %d cards. You now have %d cards.", len(flipped), len(target.hand)))
}
if p.checkMultiMercyElimination(game, target) {
if roomBuf.Len() > 0 {
p.SendMessage(game.roomID, roomBuf.String())
roomBuf.Reset()
}
if game.done {
return
}
}
// Skip past the roulette victim
game.currentIdx = game.nextActiveIdx()
game.turnID++
}
// No Mercy: 7-0 room announcements (swap already happened above)
if game.noMercy && game.sevenZeroRule {
if card.Value == unoSeven {
roomBuf.WriteString("\n 🔄 Hands swapped!")
}
if card.Value == unoZero {
roomBuf.WriteString("\n 🔄 Hands rotated!")
}
}
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.DisplayName(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.DisplayName(player.userID)
switch game.phase {
case unoMultiPhaseChallenge:
// Auto-play: accept the WD4 (safe default)
p.SendMessage(player.dmRoomID, "*Auto-played:* Accepted Wild Draw Four.")
p.SendMessage(game.roomID, fmt.Sprintf("🃏 *%s was auto-played.* Accepts the Wild Draw Four.", name))
p.resolveWD4Challenge(game, false)
return
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))
// No Mercy: Color Roulette — apply roulette effect before generic effects
if game.noMercy && pendingCard != nil && pendingCard.Value == unoWildColorRoulette {
nextIdx := game.nextActiveIdx()
target := game.players[nextIdx]
targetName := p.DisplayName(target.userID)
if target.isBot {
targetName = unoBotName()
}
flipped := p.executeColorRouletteMulti(game, target, color)
p.SendMessage(game.roomID, fmt.Sprintf("🎰 Color Roulette! %s flips %d cards until finding %s.\n%s",
targetName, len(flipped), color, pickNoMercyCommentary("color_roulette")))
if !target.isBot {
p.SendMessage(target.dmRoomID, fmt.Sprintf("🎰 Color Roulette! You drew %d cards. You now have %d cards.", len(flipped), len(target.hand)))
}
if p.checkMultiMercyElimination(game, target) {
if game.done {
return
}
}
// Skip past the roulette victim
game.currentIdx = nextIdx
game.currentIdx = game.nextActiveIdx()
game.turnID++
p.executeMultiTurn(game)
return
}
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.isWild() {
if drawnCard.Value == unoWildDrawFour {
game.wd4PrevColor = game.topColor
}
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 unoMultiPhaseChooseSwapTarget:
// Auto-play: swap with player who has fewest cards
target := botChooseSwapTarget(game, player)
if target != nil {
swapHandsMulti(player, target)
targetName := p.DisplayName(target.userID)
if target.isBot {
targetName = unoBotName()
}
p.SendMessage(player.dmRoomID, fmt.Sprintf("*Auto-played:* Swapped hands with %s.", targetName))
p.SendMessage(game.roomID, fmt.Sprintf("🔄 *%s was auto-played.* Swaps hands with %s!", name, targetName))
}
game.phase = unoMultiPhasePlay
if len(player.hand) == 0 {
p.multiPlayerWins(game, player)
return
}
p.advanceAndExecute(game)
return
case unoMultiPhasePlay:
// No Mercy stacking: auto-accept
if game.noMercy && game.stackMinValue > 0 {
drawn := game.draw(game.stackTotal)
player.hand = append(player.hand, drawn...)
p.SendMessage(player.dmRoomID, fmt.Sprintf("*Auto-played:* Accepted stack. Drew %d cards.", game.stackTotal))
p.SendMessage(game.roomID, fmt.Sprintf("💥 *%s was auto-played.* Absorbs the stack! Draws %d cards. (%d cards now)",
name, game.stackTotal, len(player.hand)))
game.stackTotal = 0
game.stackMinValue = 0
if p.checkMultiMercyElimination(game, player) {
if game.done {
return
}
}
p.advanceAndExecute(game)
return
}
// 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.isWild() {
if card.Value == unoWildDrawFour {
game.wd4PrevColor = game.topColor
}
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.isWild() {
if card.Value == unoWildDrawFour {
game.wd4PrevColor = game.topColor
}
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, player *unoMultiPlayer, card unoCard) {
eff := p.applyCardEffects(game, card)
if eff.needsChallenge {
p.startWD4Challenge(game, player)
return
}
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.DisplayName(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")))
for _, pl := range game.players {
if !pl.isBot {
recordBotDefeat(pl.userID, "uno_multi")
}
}
p.recordMultiGame(game, id.UserID("bot"), "bot_win")
p.cleanupMultiGame(game)
}
func (p *UnoPlugin) multiPlayerForfeit(game *unoMultiGame, player *unoMultiPlayer) {
player.active = false
name := p.DisplayName(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) {
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)
db.Exec("uno_multi: record game",
`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"),
)
}
// ---------------------------------------------------------------------------
// Display
// ---------------------------------------------------------------------------
func (p *UnoPlugin) sendMultiStatus(game *unoMultiGame, player *unoMultiPlayer) {
var sb strings.Builder
bn := unoBotName()
current := game.currentPlayer()
currentName := p.DisplayName(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.DisplayName(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.DisplayName(pl.userID)
if pl.isBot {
name = bn
}
counts = append(counts, fmt.Sprintf("%s (%d)", name, len(pl.hand)))
}
sb.WriteString(strings.Join(counts, " | "))
if game.noMercy && len(player.hand) >= 20 {
sb.WriteString(fmt.Sprintf("\n⚠ **You have %d cards! (25 = eliminated)**", len(player.hand)))
}
sb.WriteString("\n\nReply with a card number to play, or **draw** to draw.")
p.SendMessage(player.dmRoomID, sb.String())
}
// sendMultiHandDisplayStacking shows hand during stacking — only stackable cards are playable.
func (p *UnoPlugin) sendMultiHandDisplayStacking(game *unoMultiGame, player *unoMultiPlayer) {
var sb strings.Builder
sb.WriteString(fmt.Sprintf("⚠️ **Stack incoming: +%d!**\nDiscard pile: %s\n\n**Your hand:**\n",
game.stackTotal, game.discardTop.DisplayWithColor(game.topColor)))
for i, c := range player.hand {
marker := ""
if c.canPlayOnStacking(game.topColor, game.stackMinValue) {
marker = " ✅"
}
sb.WriteString(fmt.Sprintf("%d. %s%s\n", i+1, c.Display(), marker))
}
sb.WriteString("\nCard counts: ")
counts := make([]string, 0)
bn := unoBotName()
for _, pl := range game.players {
if pl == player || !pl.active {
continue
}
name := p.DisplayName(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(fmt.Sprintf("\n\nPlay a draw card to stack, or type **accept** to draw %d cards.", game.stackTotal))
p.SendMessage(player.dmRoomID, sb.String())
}
// handleMultiSwapChoice handles the player choosing a swap target for the 7-0 rule.
func (p *UnoPlugin) handleMultiSwapChoice(game *unoMultiGame, player *unoMultiPlayer, input string) error {
// Parse target number
var targetIdx int
if _, err := fmt.Sscanf(input, "%d", &targetIdx); err != nil || targetIdx < 1 {
p.SendMessage(player.dmRoomID, "Choose a player number to swap hands with.")
return nil
}
// Map number to active player
i := 1
var target *unoMultiPlayer
for _, pl := range game.players {
if pl == player || !pl.active {
continue
}
if i == targetIdx {
target = pl
break
}
i++
}
if target == nil {
p.SendMessage(player.dmRoomID, "Invalid player number. Try again.")
return nil
}
swapHandsMulti(player, target)
game.phase = unoMultiPhasePlay
name := p.DisplayName(player.userID)
targetName := p.DisplayName(target.userID)
if target.isBot {
targetName = unoBotName()
}
p.SendMessage(player.dmRoomID, fmt.Sprintf("Swapped hands with %s! You now have %d cards.", targetName, len(player.hand)))
if !target.isBot {
p.SendMessage(target.dmRoomID, fmt.Sprintf("🔄 %s swapped hands with you! You now have %d cards.", name, len(target.hand)))
}
p.SendMessage(game.roomID, fmt.Sprintf("🔄 %s swaps hands with %s! %s",
name, targetName, pickNoMercyCommentary("hand_swap")))
// Check win (if player got an empty hand from swap)
if len(player.hand) == 0 {
p.multiPlayerWins(game, player)
return nil
}
// Continue to next turn
p.advanceAndExecute(game)
return nil
}