Files
gogobee/internal/plugin/uno_multi.go
T
prosolis 3369d7d8fe gofmt: bring internal/ and cmd/ back to gofmt -l clean
Mechanical `gofmt -w ./internal ./cmd`. Mostly struct-field realignment that
had drifted, plus a few trailing-newline fixes. No behaviour change — gofmt is
semantics-preserving, and build/vet/test are green either side.

Split out from the code-review fixes that follow so those stay reviewable
instead of hiding inside a wall of realignment.
2026-07-10 07:18:07 -07:00

2887 lines
83 KiB
Go

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
displayName string // set at creation; bots use their assigned name
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)
// Antes paid by addbot bots (funded from community pot)
botAntes int
// Sudden death (long-game point scoring)
suddenDeath bool
suddenDeathTurn int
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
botNames []string // bots added via !uno addbot
createdAt time.Time
timer *time.Timer
noMercy bool
sevenZeroRule bool
}
// unoExtraBotNames are the names available for !uno addbot (in order).
// The default bot name comes from unoBotName() and is always present.
var unoExtraBotNames = []string{"WinBee", "GwinBee"}
// lobbySlots returns the number of lobby slots taken (humans + addbot bots).
func (l *unoMultiLobby) lobbySlots() int {
return len(l.players) + len(l.botNames)
}
// buildLobbyDisplay renders the lobby message. Caller should hold p.mu or
// have a stable snapshot of the lobby fields.
func (p *UnoPlugin) buildLobbyDisplay(lobby *unoMultiLobby) string {
var sb strings.Builder
modeTag := ""
if lobby.noMercy {
modeTag = " 🔥 NO MERCY"
if lobby.sevenZeroRule {
modeTag += " (7-0)"
}
}
slots := lobby.lobbySlots()
sb.WriteString(fmt.Sprintf("🃏 **UNO Lobby**%s — Ante: €%d\nPlayers (%d/4):\n", modeTag, int(lobby.ante), slots))
idx := 1
for _, uid := range lobby.players {
name := p.DisplayName(uid)
label := ""
if uid == lobby.creator {
label = " (host)"
}
sb.WriteString(fmt.Sprintf(" %d. %s%s\n", idx, name, label))
idx++
}
for _, bn := range lobby.botNames {
sb.WriteString(fmt.Sprintf(" %d. 🤖 %s\n", idx, bn))
idx++
}
sb.WriteString("\nType `!uno join` to join, `!uno addbot` for a bot, or `!uno go` to start!")
return sb.String()
}
// ---------------------------------------------------------------------------
// Game helpers
// ---------------------------------------------------------------------------
// multiName returns the display name for a multiplayer UNO player.
// Bots use their assigned displayName; humans use the Matrix display name.
func (p *UnoPlugin) multiName(pl *unoMultiPlayer) string {
if pl.isBot {
return pl.displayName
}
return p.DisplayName(pl.userID)
}
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
msg := p.buildLobbyDisplay(lobby)
p.mu.Unlock()
return p.SendMessage(ctx.RoomID, msg)
}
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 lobby.lobbySlots() >= 4 {
p.mu.Unlock()
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Lobby is full! (%d/4)", lobby.lobbySlots()))
}
// 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)
msg := p.buildLobbyDisplay(lobby)
p.mu.Unlock()
return p.SendMessage(ctx.RoomID, msg)
}
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)", name, lobby.lobbySlots()))
}
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) handleMultiAddBot(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`.")
}
if lobby.lobbySlots() >= 4 {
p.mu.Unlock()
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Lobby is full! (%d/4)", lobby.lobbySlots()))
}
botIdx := len(lobby.botNames)
if botIdx >= len(unoExtraBotNames) {
p.mu.Unlock()
return p.SendReply(ctx.RoomID, ctx.EventID,
fmt.Sprintf("Maximum %d extra bots already added.", len(unoExtraBotNames)))
}
lobby.botNames = append(lobby.botNames, unoExtraBotNames[botIdx])
msg := p.buildLobbyDisplay(lobby)
p.mu.Unlock()
return p.SendMessage(ctx.RoomID, msg)
}
func (p *UnoPlugin) handleMultiRemoveBot(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.")
}
if len(lobby.botNames) == 0 {
p.mu.Unlock()
return p.SendReply(ctx.RoomID, ctx.EventID, "No bots to remove.")
}
removed := lobby.botNames[len(lobby.botNames)-1]
lobby.botNames = lobby.botNames[:len(lobby.botNames)-1]
msg := p.buildLobbyDisplay(lobby)
p.mu.Unlock()
p.SendMessage(ctx.RoomID, fmt.Sprintf("🤖 %s removed from the lobby.", removed))
return p.SendMessage(ctx.RoomID, msg)
}
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.")
}
// Need at least 2 total participants (humans + lobby bots + default bot)
totalParticipants := len(lobby.players) + len(lobby.botNames) + 1 // +1 for default bot
if totalParticipants < 2 {
p.mu.Unlock()
return p.SendReply(ctx.RoomID, ctx.EventID, "Need at least 2 players to start.")
}
lobby.timer.Stop()
players := lobby.players
lobbyBots := lobby.botNames
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})
}
// Debit bot antes from community pot
for range lobbyBots {
p.claimFromPot(ante)
}
// Build game
game := p.initMultiGame(resolved, lobbyBots, roomID, ante, noMercy, sevenZeroRule)
game.botAntes = len(lobbyBots)
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
modeTag := ""
if noMercy {
modeTag = " 🔥 NO MERCY"
if sevenZeroRule {
modeTag += " (7-0)"
}
}
totalPot := int(ante) * (len(players) + len(lobbyBots))
sb.WriteString(fmt.Sprintf("🃏 **Multiplayer UNO!**%s Pot: €%d\n\nPlayers:\n", modeTag, totalPot))
for i, pl := range game.players {
name := p.multiName(pl)
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, lobbyBots []string, 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,
displayName: p.DisplayName(pd.userID),
dmRoomID: pd.dmRoomID,
hand: hand,
active: true,
})
}
// Default bot (always present)
addBot := func(name string) {
botHand := make([]unoCard, cardsPerPlayer)
copy(botHand, deck[cardIdx:cardIdx+cardsPerPlayer])
cardIdx += cardsPerPlayer
unshuffled = append(unshuffled, &unoMultiPlayer{
userID: id.UserID(name),
displayName: name,
hand: botHand,
isBot: true,
active: true,
})
}
addBot(unoBotName())
// Additional bots from lobby
for _, bn := range lobbyBots {
addBot(bn)
}
// 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
}
if p.checkMultiSuddenDeath(game) {
if roomBuf.Len() > 0 {
p.SendMessage(game.roomID, roomBuf.String())
}
return
}
continue
}
// Human's turn — flush bot play buffer first so the turn announcement arrives
// before any auto-action happens.
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++
if p.checkMultiSuddenDeath(game) {
return
}
p.executeMultiTurn(game)
}
// nextTurnLabel returns "It's X's turn." for the current player.
func (p *UnoPlugin) nextTurnLabel(game *unoMultiGame) string {
return fmt.Sprintf("It's %s's turn.", p.multiName(game.currentPlayer()))
}
// ---------------------------------------------------------------------------
// 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))
if p.checkMultiSuddenDeath(game) {
return nil
}
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.multiName(pl)
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.multiName(pl)
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.multiName(target)
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++
if p.checkMultiSuddenDeath(game) {
return nil
}
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.multiName(nextPlayer)
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.multiName(pl)
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.multiName(nextPlayer)
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
nextUpName := p.multiName(game.currentPlayer())
roomMsg.WriteString(fmt.Sprintf("\n It's %s's turn.", nextUpName))
p.SendMessage(game.roomID, roomMsg.String())
if p.checkMultiSuddenDeath(game) {
return
}
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.multiName(wd4Player)
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.multiName(wd4Player)
victimName := p.multiName(victim)
// 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++
if p.checkMultiSuddenDeath(game) {
return
}
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++
if p.checkMultiSuddenDeath(game) {
return
}
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++
if p.checkMultiSuddenDeath(game) {
return
}
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 := game.currentPlayer().displayName
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 := bot.displayName
// 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, bot)
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.multiName(target)
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!")
}
}
// Next turn label (applyCardEffects already advanced currentIdx)
nextUpName := p.multiName(game.currentPlayer())
roomBuf.WriteString(fmt.Sprintf("\n It's %s's turn.", nextUpName))
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.multiName(target)
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++
if p.checkMultiSuddenDeath(game) {
return
}
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.multiName(target)
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
}
if p.checkMultiSuddenDeath(game) {
return
}
p.executeMultiTurn(game)
}
// ---------------------------------------------------------------------------
// Sudden Death (multiplayer — only when 2 active players remain)
// ---------------------------------------------------------------------------
// checkMultiSuddenDeath checks whether sudden death should be announced or resolved.
// Only applies when exactly 2 active players remain. Returns true if the game ended.
// Caller must hold game.mu.
func (p *UnoPlugin) checkMultiSuddenDeath(game *unoMultiGame) bool {
if game.done {
return true
}
active := game.activePlayers()
if len(active) != 2 {
return false
}
// Resolve
if game.suddenDeath && game.turns >= game.suddenDeathTurn {
p.multiSuddenDeathWinner(game, active)
return true
}
// Announce
if !game.suddenDeath && game.turns >= suddenDeathAnnounce {
game.suddenDeath = true
game.suddenDeathTurn = game.turns + suddenDeathCountdown
remaining := game.suddenDeathTurn - game.turns
p.SendMessage(game.roomID, fmt.Sprintf(
"⏰ **SUDDEN DEATH!** Match ends in %d turns — lowest hand value wins!\n\n%s",
remaining, pickCommentary("sudden_death")))
for _, pl := range active {
if !pl.isBot {
p.SendMessage(pl.dmRoomID, fmt.Sprintf(
"⏰ **Sudden Death!** %d turns remaining — dump your high-value cards!", remaining))
}
}
return false
}
// Countdown reminders
if game.suddenDeath {
remaining := game.suddenDeathTurn - game.turns
switch remaining {
case 10:
p.SendMessage(game.roomID, "⏰ **10 turns remaining!**")
case 5:
p.SendMessage(game.roomID, "⏰ **5 turns remaining!**")
}
}
return false
}
func (p *UnoPlugin) multiSuddenDeathWinner(game *unoMultiGame, active []*unoMultiPlayer) {
if game.done {
return
}
game.done = true
if game.timer != nil {
game.timer.Stop()
}
if game.inactiveTimer != nil {
game.inactiveTimer.Stop()
}
a, b := active[0], active[1]
aScore := scoreHand(a.hand)
bScore := scoreHand(b.hand)
breakdown := fmt.Sprintf("**Final Scores:**\n%s: %s\n%s: %s",
p.multiName(a), formatHandScore(a.hand),
p.multiName(b), formatHandScore(b.hand))
var winner, loser *unoMultiPlayer
switch {
case aScore < bScore:
winner, loser = a, b
case bScore < aScore:
winner, loser = b, a
case len(a.hand) < len(b.hand):
winner, loser = a, b
breakdown += "\n*Tiebreaker: fewer cards!*"
case len(b.hand) < len(a.hand):
winner, loser = b, a
breakdown += "\n*Tiebreaker: fewer cards!*"
default:
// Tiebreaker: player whose turn it is NOT wins
current := game.currentPlayer()
if current == a {
winner, loser = b, a
} else {
winner, loser = a, b
}
breakdown += "\n*Tiebreaker: dead even — advantage to the opponent!*"
}
_ = loser // used implicitly via winner != loser
p.SendMessage(game.roomID, fmt.Sprintf(
"⏰ **SUDDEN DEATH!** %s wins on points!\n%s\n\n%s",
p.multiName(winner), breakdown, pickCommentary("sudden_death_win")))
if winner.isBot {
p.multiBotWins2(game)
} else {
p.multiPlayerWins2(game, winner)
}
}
// multiPlayerWins2 / multiBotWins2 handle payout without duplicate done/timer logic
// (already handled by multiSuddenDeathWinner).
func (p *UnoPlugin) multiPlayerWins2(game *unoMultiGame, winner *unoMultiPlayer) {
humanCount := 0
for _, pl := range game.players {
if !pl.isBot {
humanCount++
}
}
totalPot := game.ante * float64(humanCount+game.botAntes)
net, _ := communityTax(winner.userID, totalPot, 0.05)
p.euro.Credit(winner.userID, net, "uno_multi_win")
p.recordMultiGame(game, winner.userID, "sudden_death_win")
p.cleanupMultiGame(game)
}
func (p *UnoPlugin) multiBotWins2(game *unoMultiGame) {
humanCount := 0
for _, pl := range game.players {
if !pl.isBot {
humanCount++
}
}
totalPot := game.ante * float64(humanCount+game.botAntes)
p.addToPot(totalPot)
for _, pl := range game.players {
if !pl.isBot {
recordBotDefeat(pl.userID, "uno_multi")
}
}
p.recordMultiGame(game, id.UserID("bot"), "sudden_death_bot")
p.cleanupMultiGame(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+game.botAntes)
net, _ := communityTax(winner.userID, totalPot, 0.05)
name := p.DisplayName(winner.userID)
p.euro.Credit(winner.userID, net, "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, winner *unoMultiPlayer) {
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+game.botAntes)
// Pot goes to community
p.addToPot(totalPot)
bn := winner.displayName
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, winner)
} 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+game.botAntes)
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
currentName := p.multiName(game.currentPlayer())
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
}
counts = append(counts, fmt.Sprintf("%s (%d)", p.multiName(pl), 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)
for _, pl := range game.players {
if pl == player || !pl.active {
continue
}
counts = append(counts, fmt.Sprintf("%s (%d)", p.multiName(pl), 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)
for _, pl := range game.players {
if pl == player || !pl.active {
continue
}
counts = append(counts, fmt.Sprintf("%s (%d)", p.multiName(pl), 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.multiName(target)
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
}