Files
gogobee/internal/plugin/uno.go
prosolis e9890fd880 Add bot defeat tracking, audit fixes, and README UNO section
Add !twinbeeboard command (GogoBee's trophy wall) with unified
bot_defeats table tracking losses across Blackjack, UNO solo, and
UNO multiplayer. Fix No Mercy audit bugs: wild card color choice
routing, bot Color Roulette double-advance, missing DM notifications
for roulette victims and 7-0 swap targets, autoplay Color Roulette
effect. Remove dead code. Update README with full UNO documentation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 20:47:46 -07:00

1904 lines
54 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 (
"context"
"fmt"
"log/slog"
"math/rand/v2"
"os"
"strings"
"sync"
"time"
"gogobee/internal/db"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/id"
)
// ---------------------------------------------------------------------------
// Card types
// ---------------------------------------------------------------------------
type unoColor int
const (
unoRed unoColor = iota
unoBlue
unoYellow
unoGreen
unoWild // wilds have no color until played
)
func (c unoColor) String() string {
switch c {
case unoRed:
return "Red"
case unoBlue:
return "Blue"
case unoYellow:
return "Yellow"
case unoGreen:
return "Green"
default:
return "Wild"
}
}
func (c unoColor) Emoji() string {
switch c {
case unoRed:
return "🟥"
case unoBlue:
return "🟦"
case unoYellow:
return "🟨"
case unoGreen:
return "🟩"
default:
return "🃏"
}
}
type unoValue int
const (
unoZero unoValue = iota
unoOne
unoTwo
unoThree
unoFour
unoFive
unoSix
unoSeven
unoEight
unoNine
unoSkip
unoReverse // acts as skip in 2-player
unoDrawTwo
unoWildCard
unoWildDrawFour
// No Mercy card types
unoSkipEveryone // colored — skips all other players
unoDrawFour // colored Draw Four (NOT wild)
unoDiscardAll // colored — discard all of that color from hand
unoWildReverseDraw4 // wild — reverse + draw 4 + color change
unoWildDrawSix // wild — draw 6
unoWildDrawTen // wild — draw 10
unoWildColorRoulette // wild — next player flips until chosen color
)
func (v unoValue) String() string {
switch {
case v <= unoNine:
return fmt.Sprintf("%d", int(v))
case v == unoSkip:
return "Skip"
case v == unoReverse:
return "Reverse"
case v == unoDrawTwo:
return "Draw Two"
case v == unoWildCard:
return "Wild"
case v == unoWildDrawFour:
return "Wild Draw Four"
case v == unoSkipEveryone:
return "Skip Everyone"
case v == unoDrawFour:
return "Draw Four"
case v == unoDiscardAll:
return "Discard All"
case v == unoWildReverseDraw4:
return "Wild Reverse Draw Four"
case v == unoWildDrawSix:
return "Wild Draw Six"
case v == unoWildDrawTen:
return "Wild Draw Ten"
case v == unoWildColorRoulette:
return "Wild Color Roulette"
default:
return "?"
}
}
func (v unoValue) isAction() bool {
return v == unoSkip || v == unoReverse || v == unoDrawTwo ||
v == unoWildCard || v == unoWildDrawFour ||
v == unoSkipEveryone || v == unoDrawFour || v == unoDiscardAll ||
v == unoWildReverseDraw4 || v == unoWildDrawSix ||
v == unoWildDrawTen || v == unoWildColorRoulette
}
type unoCard struct {
Color unoColor
Value unoValue
}
func (c unoCard) isWild() bool {
return c.Value == unoWildCard || c.Value == unoWildDrawFour ||
c.Value == unoWildReverseDraw4 || c.Value == unoWildDrawSix ||
c.Value == unoWildDrawTen || c.Value == unoWildColorRoulette
}
func (c unoCard) Display() string {
if c.isWild() {
return fmt.Sprintf("%s %s", unoWild.Emoji(), c.Value)
}
return fmt.Sprintf("%s %s", c.Color.Emoji(), c.Value)
}
// DisplayWithColor shows a wild card with its chosen color.
func (c unoCard) DisplayWithColor(chosenColor unoColor) string {
if c.isWild() {
return fmt.Sprintf("%s %s", chosenColor.Emoji(), c.Value)
}
return c.Display()
}
func (c unoCard) canPlayOn(top unoCard, topColor unoColor) bool {
if c.isWild() {
return true
}
return c.Color == topColor || c.Value == top.Value
}
// ---------------------------------------------------------------------------
// Deck
// ---------------------------------------------------------------------------
func newUnoDeck() []unoCard {
var cards []unoCard
colors := []unoColor{unoRed, unoBlue, unoYellow, unoGreen}
for _, color := range colors {
// One zero per color
cards = append(cards, unoCard{color, unoZero})
// Two of each 1-9, Skip, Reverse, Draw Two
for v := unoOne; v <= unoDrawTwo; v++ {
cards = append(cards, unoCard{color, v})
cards = append(cards, unoCard{color, v})
}
}
// 4 Wild, 4 Wild Draw Four
for i := 0; i < 4; i++ {
cards = append(cards, unoCard{unoWild, unoWildCard})
cards = append(cards, unoCard{unoWild, unoWildDrawFour})
}
// Shuffle
rand.Shuffle(len(cards), func(i, j int) { cards[i], cards[j] = cards[j], cards[i] })
return cards
}
// ---------------------------------------------------------------------------
// Game state
// ---------------------------------------------------------------------------
type unoPhase int
const (
unoPhasePlay unoPhase = iota
unoPhaseChooseColor
unoPhaseDrawnPlayable // player drew a playable card, yes/no
)
type unoGame struct {
playerID id.UserID
roomID id.RoomID // games room where it started
dmRoomID id.RoomID // DM room for gameplay
wager float64
playerHand []unoCard
botHand []unoCard
drawPile []unoCard
discardTop unoCard
topColor unoColor // effective color (matters for wilds)
phase unoPhase
drawnCard *unoCard // card drawn this turn (for draw-then-play)
pendingCard *unoCard // wild card waiting for color choice
calledUno bool // player called uno this round
bookDown bool // gogobee is paying attention
turns int
startedAt time.Time
done bool // set true when game ends — prevents double-completion
// No Mercy mode
noMercy bool
sevenZeroRule bool
stackTotal int // cumulative draw penalty during stacking
stackMinValue int // minimum draw value to stack (0 = not stacking)
idleTimer *time.Timer
warningTimer *time.Timer
}
func (g *unoGame) 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 // truly empty, shouldn't happen with 108 cards
}
drawn = append(drawn, g.drawPile[0])
g.drawPile = g.drawPile[1:]
}
return drawn
}
func (g *unoGame) reshuffleDiscard() {
// Rebuild deck from cards not in play
var fresh []unoCard
if g.noMercy {
fresh = newNoMercyDeck()
} else {
fresh = newUnoDeck()
}
inPlay := make(map[unoCard]int)
for _, c := range g.playerHand {
inPlay[c]++
}
for _, c := range g.botHand {
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 *unoGame) hasPlayable() bool {
for _, c := range g.playerHand {
if c.canPlayOn(g.discardTop, g.topColor) {
return true
}
}
return false
}
func (g *unoGame) updateBookState() bool {
wasDown := g.bookDown
g.bookDown = len(g.playerHand) == 2
return wasDown != g.bookDown
}
// ---------------------------------------------------------------------------
// GogoBee commentary
// ---------------------------------------------------------------------------
var unoCommentary = map[string][]string{
"start": {
"*GogoBee sets the book down, marks the page carefully, and looks up with an expression of polite interest.*\n\n\"Oh, we're doing this. Alright. 💛\"\n\n*deals cards*",
},
"bot_play_normal": {
"GogoBee plays: %s. 💛 *doesn't look up*",
"GogoBee plays: %s. 💛 *turns a page*",
},
"bot_play_bookdown": {
"GogoBee plays: %s. 💛",
},
"bot_draw_normal": {
"GogoBee draws a card. 💛 *doesn't look up*",
},
"bot_draw_bookdown": {
"GogoBee draws a card. 💛",
},
"bot_draw_two": {
"\"Oh, sorry about that. Draw two. 💛\" *turns a page*",
},
"bot_draw_two_bookdown": {
"\"Oh, sorry about that. Draw two. 💛\"",
},
"bot_wild_draw_four": {
"\"So sorry. Draw four. 💛\" *doesn't look up*",
},
"bot_wild_draw_four_bookdown": {
"\"So sorry. Draw four. 💛\"",
},
"book_down": {
"*GogoBee sets the book down.*\n\"Hm. 💛\"",
},
"book_up": {
"*picks the book back up*\n\"Mm. 💛\"",
},
"bot_uno": {
"\"Uno, by the way. 💛\" *glances up briefly*",
},
"player_forgot_uno": {
"\"Oh, you forgot to say uno. Draw two. 💛\" *turns a page*",
},
"bot_win": {
"\"Oh that's unfortunate. Better luck next time. 💛\"\n*shuffles deck before you've even left the table*",
},
"bot_lose": {
"\"Oh! You got me. Well done. 💛\"\n*resumes reading*",
},
"bot_lose_empty_pot": {
"\"...I see. The pot is yours. 💛\"\n*marks the page and sets the book down properly this time*\n\"Don't get comfortable.\"",
},
"long_game": {
"\"Still going, are we? 💛\" *glances at bookmark*",
},
}
func unoBotName() string {
name := os.Getenv("BOT_DISPLAY_NAME")
if name == "" {
return "GogoBee"
}
return name
}
func pickCommentary(key string) string {
lines := unoCommentary[key]
if len(lines) == 0 {
return ""
}
line := lines[rand.IntN(len(lines))]
return strings.ReplaceAll(line, "GogoBee", unoBotName())
}
// ---------------------------------------------------------------------------
// UNO Plugin
// ---------------------------------------------------------------------------
type UnoPlugin struct {
Base
euro *EuroPlugin
mu sync.Mutex
games map[id.UserID]*unoGame // solo: one game per player
// reverse lookup: DM room -> player (solo)
dmToPlayer map[id.RoomID]id.UserID
// Multiplayer
lobbies map[id.RoomID]*unoMultiLobby // one lobby per room
multiGames map[string]*unoMultiGame // game ID -> active game
dmToMulti map[id.RoomID]string // DM room -> game ID
}
func NewUnoPlugin(client *mautrix.Client, euro *EuroPlugin) *UnoPlugin {
return &UnoPlugin{
Base: NewBase(client),
euro: euro,
games: make(map[id.UserID]*unoGame),
dmToPlayer: make(map[id.RoomID]id.UserID),
lobbies: make(map[id.RoomID]*unoMultiLobby),
multiGames: make(map[string]*unoMultiGame),
dmToMulti: make(map[id.RoomID]string),
}
}
func (p *UnoPlugin) Name() string { return "uno" }
func (p *UnoPlugin) Commands() []CommandDef {
return []CommandDef{
{Name: "uno", Description: "Solo or multiplayer Uno", Usage: "!uno [nomercy [7-0]] €amount | !uno start [nomercy [7-0]] €amount | !uno join | !uno go", Category: "Games"},
{Name: "uno_pot", Description: "Show the community pot balance", Usage: "!uno_pot", Category: "Games"},
}
}
func (p *UnoPlugin) Init() error { return nil }
func (p *UnoPlugin) OnReaction(_ ReactionContext) error { return nil }
func (p *UnoPlugin) OnMessage(ctx MessageContext) error {
// Room commands
if p.IsCommand(ctx.Body, "uno_pot") {
return p.handlePotCheck(ctx)
}
if p.IsCommand(ctx.Body, "uno") {
args := strings.TrimSpace(p.GetArgs(ctx.Body, "uno"))
lower := strings.ToLower(args)
// Multiplayer subcommands
switch {
case strings.HasPrefix(lower, "start "):
rest := strings.TrimSpace(args[6:])
noMercy, sevenZero, amountStr := parseNoMercyFlags(rest)
return p.handleMultiStart(ctx, amountStr, noMercy, sevenZero)
case lower == "join":
return p.handleMultiJoin(ctx)
case lower == "go":
return p.handleMultiGo(ctx)
case lower == "leave":
return p.handleMultiLeave(ctx)
case lower == "cancel":
return p.handleMultiCancel(ctx)
}
// Solo challenge: !uno [nomercy [7-0]] €amount
noMercy, sevenZero, amountStr := parseNoMercyFlags(args)
if isGamesRoom(ctx.RoomID) {
return p.handleChallenge(ctx, ctx.RoomID, noMercy, sevenZero, amountStr)
}
// Allow starting from DM — announce to games room
gr := gamesRoom()
if gr == "" {
return nil
}
return p.handleChallenge(ctx, id.RoomID(gr), noMercy, sevenZero, amountStr)
}
// DM gameplay — check solo games first, then multiplayer
p.mu.Lock()
playerID, isSoloDM := p.dmToPlayer[ctx.RoomID]
if isSoloDM && playerID == ctx.Sender {
game := p.games[playerID]
if game != nil && !game.done {
p.mu.Unlock()
return p.handleDMInput(ctx, game)
}
}
gameID, isMultiDM := p.dmToMulti[ctx.RoomID]
if isMultiDM {
mg := p.multiGames[gameID]
if mg != nil && !mg.done {
p.mu.Unlock()
return p.handleMultiDMInput(ctx, mg)
}
}
p.mu.Unlock()
return nil
}
// ---------------------------------------------------------------------------
// Pot management
// ---------------------------------------------------------------------------
func (p *UnoPlugin) getPot() float64 {
d := db.Get()
var balance float64
err := d.QueryRow("SELECT balance FROM uno_pot WHERE id = 1").Scan(&balance)
if err != nil {
d.Exec("INSERT OR IGNORE INTO uno_pot (id, balance) VALUES (1, 0)")
return 0
}
return balance
}
func (p *UnoPlugin) addToPot(amount float64) {
d := db.Get()
d.Exec("INSERT OR IGNORE INTO uno_pot (id, balance) VALUES (1, 0)")
d.Exec("UPDATE uno_pot SET balance = balance + ?, updated_at = CURRENT_TIMESTAMP WHERE id = 1", amount)
}
// claimFromPot atomically claims up to amount from the pot. Returns actual payout.
func (p *UnoPlugin) claimFromPot(amount float64) float64 {
d := db.Get()
d.Exec("INSERT OR IGNORE INTO uno_pot (id, balance) VALUES (1, 0)")
// Atomic: clamp to available balance, never go negative
var payout float64
err := d.QueryRow(
`UPDATE uno_pot SET balance = MAX(0, balance - ?), updated_at = CURRENT_TIMESTAMP
WHERE id = 1 RETURNING balance + MIN(balance, ?) - balance`,
amount, amount,
).Scan(&payout)
if err != nil {
// Fallback: read-then-write (less atomic but functional)
pot := p.getPot()
payout = amount
if pot < amount {
payout = pot
}
if payout < 0 {
payout = 0
}
d.Exec("UPDATE uno_pot SET balance = MAX(0, balance - ?), updated_at = CURRENT_TIMESTAMP WHERE id = 1", payout)
}
return payout
}
func (p *UnoPlugin) handlePotCheck(ctx MessageContext) error {
pot := p.getPot()
return p.SendReply(ctx.RoomID, ctx.EventID,
fmt.Sprintf("🃏 Community pot: €%d. %s is waiting. 💛", int(pot), unoBotName()))
}
// ---------------------------------------------------------------------------
// Challenge
// ---------------------------------------------------------------------------
func (p *UnoPlugin) handleChallenge(ctx MessageContext, announceRoom id.RoomID, noMercy, sevenZeroRule bool, rawAmount string) error {
reply := func(text string) error {
return p.SendReply(ctx.RoomID, ctx.EventID, text)
}
amountStr := strings.TrimPrefix(strings.TrimSpace(rawAmount), "€")
var amount float64
fmt.Sscanf(amountStr, "%f", &amount)
minBet := envFloat("UNO_MIN_BET", 10)
if amount < minBet {
return reply(fmt.Sprintf("Minimum wager is €%d. Usage: `!uno €amount`", int(minBet)))
}
// Hold lock for check-and-reserve to prevent TOCTOU double-challenge
p.mu.Lock()
if _, active := p.games[ctx.Sender]; active {
p.mu.Unlock()
return reply("You already have an Uno game in progress!")
}
// Reserve the slot with a placeholder so concurrent challenges are blocked
p.games[ctx.Sender] = &unoGame{done: true} // placeholder
p.mu.Unlock()
// Debit wager
if !p.euro.Debit(ctx.Sender, amount, "uno_wager") {
p.mu.Lock()
delete(p.games, ctx.Sender)
p.mu.Unlock()
return reply("Insufficient balance for that wager.")
}
// Get DM room
dmRoom, err := p.GetDMRoom(ctx.Sender)
if err != nil {
p.euro.Credit(ctx.Sender, amount, "uno_wager_refund")
p.mu.Lock()
delete(p.games, ctx.Sender)
p.mu.Unlock()
return reply("Couldn't open a DM with you. Make sure you accept DMs from the bot.")
}
// Initialize game
game := p.initGame(ctx.Sender, announceRoom, dmRoom, amount, noMercy, sevenZeroRule)
p.mu.Lock()
p.games[ctx.Sender] = game
p.dmToPlayer[dmRoom] = ctx.Sender
p.mu.Unlock()
// Room announcement
playerName := p.unoDisplayName(ctx.Sender)
botName := unoBotName()
modeTag := ""
startComment := pickCommentary("start")
if noMercy {
modeTag = " 🔥 **NO MERCY**"
startComment = pickNoMercyCommentary("nomercy_start")
if sevenZeroRule {
modeTag += " (7-0)"
}
}
p.SendMessage(announceRoom, fmt.Sprintf(
"🃏 **%s** has challenged %s to Uno!%s Stakes: €%d\n\n%s\n\n[Check your DMs to play.]",
playerName, botName, modeTag, int(amount), startComment,
))
// Send initial hand to player (auto-draws if no playable cards)
if err := p.playerTurnOrAutoDraw(game); err != nil {
return err
}
p.resetIdleTimer(game)
return nil
}
func (p *UnoPlugin) initGame(playerID id.UserID, roomID, dmRoom id.RoomID, wager float64, noMercy, sevenZeroRule bool) *unoGame {
var deck []unoCard
if noMercy {
deck = newNoMercyDeck()
} else {
deck = newUnoDeck()
}
// Deal 7 cards each — copy into own slices to avoid shared backing array
playerHand := make([]unoCard, 7)
copy(playerHand, deck[:7])
botHand := make([]unoCard, 7)
copy(botHand, deck[7:14])
remaining := make([]unoCard, len(deck)-14)
copy(remaining, deck[14:])
// Flip starting card — must be a number card (not action/wild)
var startCard unoCard
startIdx := -1
for i, c := range remaining {
if !c.Value.isAction() && !c.isWild() {
startCard = c
startIdx = i
break
}
}
var drawPile []unoCard
if startIdx >= 0 {
drawPile = make([]unoCard, 0, len(remaining)-1)
drawPile = append(drawPile, remaining[:startIdx]...)
drawPile = append(drawPile, remaining[startIdx+1:]...)
} else {
// All remaining are wilds (essentially impossible) — reshuffle whole deck
if noMercy {
deck = newNoMercyDeck()
} else {
deck = newUnoDeck()
}
copy(playerHand, deck[:7])
copy(botHand, deck[7:14])
startCard = deck[14]
drawPile = make([]unoCard, len(deck)-15)
copy(drawPile, deck[15:])
}
return &unoGame{
playerID: playerID,
roomID: roomID,
dmRoomID: dmRoom,
wager: wager,
playerHand: playerHand,
botHand: botHand,
drawPile: drawPile,
discardTop: startCard,
topColor: startCard.Color,
phase: unoPhasePlay,
startedAt: time.Now(),
noMercy: noMercy,
sevenZeroRule: sevenZeroRule,
}
}
// ---------------------------------------------------------------------------
// DM input handling
// ---------------------------------------------------------------------------
func (p *UnoPlugin) handleDMInput(ctx MessageContext, game *unoGame) error {
p.mu.Lock()
if game.done {
p.mu.Unlock()
return nil
}
p.mu.Unlock()
p.resetIdleTimer(game)
input := strings.TrimSpace(strings.ToLower(ctx.Body))
// Quit at any time
if input == "quit" || input == "forfeit" {
return p.forfeitGame(game, false)
}
switch game.phase {
case unoPhaseChooseColor:
return p.handleColorChoice(game, input)
case unoPhaseDrawnPlayable:
return p.handleDrawnPlayable(game, input)
case unoPhasePlay:
if input == "uno" {
game.calledUno = true
return p.SendMessage(game.dmRoomID, "✅ UNO called!")
}
// No Mercy stacking: accept the stack
if game.noMercy && game.stackMinValue > 0 && (input == "accept" || input == "a") {
drawn := game.draw(game.stackTotal)
game.playerHand = append(game.playerHand, drawn...)
p.SendMessage(game.dmRoomID, fmt.Sprintf("💥 You accept the stack and draw %d cards.\n%s",
game.stackTotal, pickNoMercyCommentary("stack_absorbed")))
game.stackTotal = 0
game.stackMinValue = 0
if p.checkSoloMercyElimination(game) {
return nil
}
return p.botTurn(game)
}
if input == "draw" {
// No Mercy: during stacking, draw is not allowed — must stack or accept
if game.noMercy && game.stackMinValue > 0 {
return p.SendMessage(game.dmRoomID, "You must play a draw card to stack, or type **accept** to draw the stack.")
}
return p.handlePlayerDraw(game)
}
// Parse card number
var cardIdx int
if _, err := fmt.Sscanf(input, "%d", &cardIdx); err != nil || cardIdx < 1 || cardIdx > len(game.playerHand) {
if game.noMercy && game.stackMinValue > 0 {
return p.SendMessage(game.dmRoomID,
fmt.Sprintf("Reply with a card number (1-%d) to stack, or **accept** to draw %d cards.", len(game.playerHand), game.stackTotal))
}
return p.SendMessage(game.dmRoomID,
fmt.Sprintf("Reply with a number (1-%d) to play a card, or **draw** to draw.", len(game.playerHand)))
}
return p.handlePlayerPlay(game, cardIdx-1)
}
return nil
}
func (p *UnoPlugin) handlePlayerPlay(game *unoGame, idx int) error {
card := game.playerHand[idx]
// No Mercy stacking validation
if game.noMercy && game.stackMinValue > 0 {
if !card.canPlayOnStacking(game.topColor, game.stackMinValue) {
return p.SendMessage(game.dmRoomID,
fmt.Sprintf("You can't stack %s — need a draw card worth +%d or more. Type **accept** to draw %d.",
card.Display(), game.stackMinValue, game.stackTotal))
}
} else if !card.canPlayOn(game.discardTop, game.topColor) {
return p.SendMessage(game.dmRoomID, fmt.Sprintf("You can't play %s on %s. Choose another card or **draw**.",
card.Display(), game.discardTop.DisplayWithColor(game.topColor)))
}
// Check UNO call requirement
if len(game.playerHand) == 2 && !game.calledUno {
// Penalty! Draw 2
drawn := game.draw(2)
game.playerHand = append(game.playerHand, drawn...)
game.calledUno = false
p.SendMessage(game.dmRoomID, "⚠️ You forgot to call UNO! Draw 2 as penalty.\n"+pickCommentary("player_forgot_uno"))
return p.playerTurnOrAutoDraw(game)
}
// Play the card
game.playerHand = append(game.playerHand[:idx], game.playerHand[idx+1:]...)
game.discardTop = card
game.turns++
if card.isWild() {
game.pendingCard = &card
game.phase = unoPhaseChooseColor
return p.SendMessage(game.dmRoomID,
"You played a **"+card.Value.String()+"**! Choose a color:\n1. 🟥 Red\n2. 🟦 Blue\n3. 🟨 Yellow\n4. 🟩 Green")
}
game.topColor = card.Color
// Check player win
if len(game.playerHand) == 0 {
return p.playerWins(game)
}
// Check book state change
bookMsg := ""
if changed := game.updateBookState(); changed {
if game.bookDown {
bookMsg = "\n" + pickCommentary("book_down")
} else {
bookMsg = "\n" + pickCommentary("book_up")
}
}
// Track UNO requirement — player must call "uno" before playing their next card
if len(game.playerHand) == 2 {
game.calledUno = false
}
p.SendMessage(game.dmRoomID, fmt.Sprintf("You played %s.%s", card.Display(), bookMsg))
// No Mercy: Discard All — remove remaining cards of same color
if game.noMercy && card.Value == unoDiscardAll {
discarded := discardAllOfColor(&game.playerHand, card.Color)
if discarded > 0 {
p.SendMessage(game.dmRoomID, fmt.Sprintf("Discarded %d additional %s cards!", discarded, card.Color))
}
if len(game.playerHand) == 0 {
return p.playerWins(game)
}
}
// No Mercy: 7-0 rule
if game.noMercy && game.sevenZeroRule {
if card.Value == unoSeven || card.Value == unoZero {
swapHandsSolo(game)
p.SendMessage(game.dmRoomID, fmt.Sprintf("Hands swapped! You now have %d cards, %s has %d.",
len(game.playerHand), unoBotName(), len(game.botHand)))
if p.checkSoloMercyElimination(game) {
return nil
}
}
}
// Apply action card effects
if game.noMercy && isDrawCard(card.Value) {
// No Mercy stacking: bot gets a chance to stack
return p.soloPlayerPlaysDrawCard(game, card)
}
if card.Value == unoDrawTwo {
drawn := game.draw(2)
game.botHand = append(game.botHand, drawn...)
p.SendMessage(game.dmRoomID, unoBotName()+" draws 2 cards and loses their turn.")
return p.afterBotTurn(game)
}
if card.Value == unoSkip || card.Value == unoReverse {
p.SendMessage(game.dmRoomID, unoBotName()+"'s turn is skipped!")
return p.afterBotTurn(game)
}
// No Mercy: Skip Everyone (same as skip in 2-player)
if game.noMercy && card.Value == unoSkipEveryone {
p.SendMessage(game.dmRoomID, unoBotName()+"'s turn is skipped!")
return p.afterBotTurn(game)
}
// Bot's turn
return p.botTurn(game)
}
func (p *UnoPlugin) handleColorChoice(game *unoGame, 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:
return p.SendMessage(game.dmRoomID, "Choose a color: **1** (Red), **2** (Blue), **3** (Yellow), **4** (Green)")
}
game.topColor = color
game.phase = unoPhasePlay
pendingCard := game.pendingCard
game.pendingCard = nil
// Check player win
if len(game.playerHand) == 0 {
p.SendMessage(game.dmRoomID, fmt.Sprintf("Color set to %s %s.", color.Emoji(), color))
return p.playerWins(game)
}
// Check book state
bookMsg := ""
if changed := game.updateBookState(); changed {
if game.bookDown {
bookMsg = "\n" + pickCommentary("book_down")
} else {
bookMsg = "\n" + pickCommentary("book_up")
}
}
p.SendMessage(game.dmRoomID, fmt.Sprintf("Color set to %s %s.%s", color.Emoji(), color, bookMsg))
// No Mercy wild effects
if game.noMercy && pendingCard != nil {
switch pendingCard.Value {
case unoWildReverseDraw4:
// Reverse (acts as skip in 2-player) + stacking with +4
return p.soloPlayerPlaysDrawCard(game, *pendingCard)
case unoWildDrawSix:
return p.soloPlayerPlaysDrawCard(game, *pendingCard)
case unoWildDrawTen:
return p.soloPlayerPlaysDrawCard(game, *pendingCard)
case unoWildColorRoulette:
// Next player (bot) flips until chosen color
bn := unoBotName()
flipped := p.executeColorRouletteSolo(game, false, color)
p.SendMessage(game.dmRoomID, fmt.Sprintf("🎰 Color Roulette! %s flips %d cards until finding %s %s.\n%s",
bn, len(flipped), color.Emoji(), color, pickNoMercyCommentary("color_roulette")))
if p.checkSoloMercyElimination(game) {
return nil
}
return p.afterBotTurn(game)
}
}
// Wild Draw Four — bot draws 4 and loses turn (classic mode)
if pendingCard != nil && pendingCard.Value == unoWildDrawFour {
drawn := game.draw(4)
game.botHand = append(game.botHand, drawn...)
p.SendMessage(game.dmRoomID, unoBotName()+" draws 4 cards and loses their turn.")
// Bot's turn was skipped, so it's player's turn again
return p.afterBotTurn(game)
}
// Track UNO requirement
if len(game.playerHand) == 2 {
game.calledUno = false
}
// Regular wild — bot's turn
return p.botTurn(game)
}
func (p *UnoPlugin) handlePlayerDraw(game *unoGame) error {
// No Mercy: draw until playable
if game.noMercy {
return p.handlePlayerDrawNoMercy(game)
}
drawn := game.draw(1)
if len(drawn) == 0 {
p.SendMessage(game.dmRoomID, "No cards left to draw! Turn passes to "+unoBotName()+".")
return p.botTurn(game)
}
card := drawn[0]
game.playerHand = append(game.playerHand, card)
if card.canPlayOn(game.discardTop, game.topColor) {
game.drawnCard = &card
game.phase = unoPhaseDrawnPlayable
return p.SendMessage(game.dmRoomID,
fmt.Sprintf("You drew: %s\nIt's playable! Play it? (**yes** / **no**)", card.Display()))
}
p.SendMessage(game.dmRoomID, fmt.Sprintf("You drew: %s\nNot playable. Turn passes to "+unoBotName()+".", card.Display()))
return p.botTurn(game)
}
func (p *UnoPlugin) handlePlayerDrawNoMercy(game *unoGame) error {
var allDrawn []unoCard
var playableCard *unoCard
for {
drawn := game.draw(1)
if len(drawn) == 0 {
break
}
card := drawn[0]
game.playerHand = append(game.playerHand, card)
allDrawn = append(allDrawn, card)
if p.checkSoloMercyElimination(game) {
return nil
}
if card.canPlayOn(game.discardTop, game.topColor) {
playableCard = &card
break
}
}
if len(allDrawn) == 0 {
p.SendMessage(game.dmRoomID, "No cards left to draw! Turn passes to "+unoBotName()+".")
return p.botTurn(game)
}
if playableCard != nil {
game.drawnCard = playableCard
game.phase = unoPhaseDrawnPlayable
return p.SendMessage(game.dmRoomID,
fmt.Sprintf("Drew %d card(s): %s\nLast card is playable! Play it? (**yes** / **no**)",
len(allDrawn), formatDrawnCards(allDrawn)))
}
p.SendMessage(game.dmRoomID,
fmt.Sprintf("Drew %d card(s): %s\nNo playable card found. Turn passes to %s.",
len(allDrawn), formatDrawnCards(allDrawn), unoBotName()))
return p.botTurn(game)
}
func (p *UnoPlugin) handleDrawnPlayable(game *unoGame, input string) error {
if input != "yes" && input != "y" && input != "no" && input != "n" {
return p.SendMessage(game.dmRoomID, "Play the drawn card? (**yes** / **no**)")
}
drawnCard := *game.drawnCard
game.drawnCard = nil
game.phase = unoPhasePlay
if input == "no" || input == "n" {
p.SendMessage(game.dmRoomID, "Card kept. Turn passes to "+unoBotName()+".")
return p.botTurn(game)
}
// Play the drawn card — remove it from hand
for i, c := range game.playerHand {
if c == drawnCard {
game.playerHand = append(game.playerHand[:i], game.playerHand[i+1:]...)
break
}
}
game.discardTop = drawnCard
game.turns++
if drawnCard.isWild() {
game.pendingCard = &drawnCard
game.phase = unoPhaseChooseColor
return p.SendMessage(game.dmRoomID,
"You played a **"+drawnCard.Value.String()+"**! Choose a color:\n1. 🟥 Red\n2. 🟦 Blue\n3. 🟨 Yellow\n4. 🟩 Green")
}
game.topColor = drawnCard.Color
if len(game.playerHand) == 0 {
return p.playerWins(game)
}
bookMsg := ""
if changed := game.updateBookState(); changed {
if game.bookDown {
bookMsg = "\n" + pickCommentary("book_down")
} else {
bookMsg = "\n" + pickCommentary("book_up")
}
}
p.SendMessage(game.dmRoomID, fmt.Sprintf("You played %s.%s", drawnCard.Display(), bookMsg))
// No Mercy: Discard All
if game.noMercy && drawnCard.Value == unoDiscardAll {
discarded := discardAllOfColor(&game.playerHand, drawnCard.Color)
if discarded > 0 {
p.SendMessage(game.dmRoomID, fmt.Sprintf("Discarded %d additional %s cards!", discarded, drawnCard.Color))
}
if len(game.playerHand) == 0 {
return p.playerWins(game)
}
}
// No Mercy: 7-0 rule
if game.noMercy && game.sevenZeroRule {
if drawnCard.Value == unoSeven || drawnCard.Value == unoZero {
swapHandsSolo(game)
p.SendMessage(game.dmRoomID, fmt.Sprintf("Hands swapped! You now have %d cards, %s has %d.",
len(game.playerHand), unoBotName(), len(game.botHand)))
if p.checkSoloMercyElimination(game) {
return nil
}
}
}
// No Mercy stacking for draw cards
if game.noMercy && isDrawCard(drawnCard.Value) {
return p.soloPlayerPlaysDrawCard(game, drawnCard)
}
if drawnCard.Value == unoDrawTwo {
drawn := game.draw(2)
game.botHand = append(game.botHand, drawn...)
p.SendMessage(game.dmRoomID, unoBotName()+" draws 2 cards and loses their turn.")
return p.afterBotTurn(game)
}
if drawnCard.Value == unoSkip || drawnCard.Value == unoReverse || drawnCard.Value == unoSkipEveryone {
p.SendMessage(game.dmRoomID, unoBotName()+"'s turn is skipped!")
return p.afterBotTurn(game)
}
return p.botTurn(game)
}
// ---------------------------------------------------------------------------
// Solo stacking (No Mercy)
// ---------------------------------------------------------------------------
// soloPlayerPlaysDrawCard handles when the player plays a draw card in No Mercy.
// The bot gets a chance to stack back.
func (p *UnoPlugin) soloPlayerPlaysDrawCard(game *unoGame, card unoCard) error {
dv := cardDrawValue(card.Value)
game.stackTotal += dv
game.stackMinValue = dv
// Reverse effect for Wild Reverse Draw Four
if card.Value == unoWildReverseDraw4 {
// In 2-player, reverse = skip, so effectively bot is the target
// Direction doesn't matter in solo but we note the reverse
}
// Bot tries to stack
return p.soloBotHandleStack(game)
}
// soloBotHandleStack has the bot try to stack or absorb.
func (p *UnoPlugin) soloBotHandleStack(game *unoGame) error {
bn := unoBotName()
// Bot tries to find a stackable card
stackCard, idx := botPickStackCard(game.botHand, game.topColor, game.stackMinValue)
if idx < 0 {
// Bot absorbs
drawn := game.draw(game.stackTotal)
game.botHand = append(game.botHand, drawn...)
p.SendMessage(game.dmRoomID, fmt.Sprintf("💥 %s can't stack! Draws %d cards. (%d cards now)\n%s",
bn, game.stackTotal, len(game.botHand), pickNoMercyCommentary("stack_absorbed")))
game.stackTotal = 0
game.stackMinValue = 0
if p.checkSoloMercyElimination(game) {
return nil
}
return p.afterBotTurn(game)
}
// Bot stacks
game.botHand = append(game.botHand[:idx], game.botHand[idx+1:]...)
game.discardTop = stackCard
dv := cardDrawValue(stackCard.Value)
game.stackTotal += dv
game.stackMinValue = dv
game.turns++
if stackCard.isWild() {
game.topColor = botPickColor(game.botHand)
} else {
game.topColor = stackCard.Color
}
p.SendMessage(game.dmRoomID, fmt.Sprintf("🔥 %s stacks: %s! Total penalty: +%d",
bn, stackCard.DisplayWithColor(game.topColor), game.stackTotal))
// Player must now respond to the stack
return p.playerTurnOrAutoDraw(game)
}
// ---------------------------------------------------------------------------
// Bot turn
// ---------------------------------------------------------------------------
func (p *UnoPlugin) botTurn(game *unoGame) error {
// No Mercy stacking: if a stack is pending, bot must handle it
if game.noMercy && game.stackMinValue > 0 {
return p.soloBotHandleStack(game)
}
// Long game commentary (DM only)
if game.turns > 0 && game.turns%30 == 0 {
p.SendMessage(game.dmRoomID, pickCommentary("long_game"))
}
card, idx := p.botChooseCard(game)
if idx < 0 {
// Bot draws
if game.noMercy {
return p.botDrawNoMercy(game)
}
drawn := game.draw(1)
if len(drawn) == 0 {
p.SendMessage(game.dmRoomID, unoBotName()+" can't draw — no cards left. Turn passes.")
game.phase = unoPhasePlay
p.sendHandDisplay(game)
return nil
}
game.botHand = append(game.botHand, drawn[0])
if drawn[0].canPlayOn(game.discardTop, game.topColor) {
game.botHand = game.botHand[:len(game.botHand)-1]
return p.botPlaysCard(game, drawn[0])
}
commentKey := "bot_draw_normal"
if game.bookDown {
commentKey = "bot_draw_bookdown"
}
p.SendMessage(game.dmRoomID, pickCommentary(commentKey))
return p.playerTurnOrAutoDraw(game)
}
// Play the card
game.botHand = append(game.botHand[:idx], game.botHand[idx+1:]...)
return p.botPlaysCard(game, card)
}
// botDrawNoMercy draws until playable for the bot in No Mercy mode.
func (p *UnoPlugin) botDrawNoMercy(game *unoGame) error {
bn := unoBotName()
var allDrawn []unoCard
var playableCard *unoCard
for {
drawn := game.draw(1)
if len(drawn) == 0 {
break
}
card := drawn[0]
game.botHand = append(game.botHand, card)
allDrawn = append(allDrawn, card)
if p.checkSoloMercyElimination(game) {
return nil
}
if card.canPlayOn(game.discardTop, game.topColor) {
playableCard = &card
break
}
}
if len(allDrawn) == 0 {
p.SendMessage(game.dmRoomID, bn+" can't draw — no cards left. Turn passes.")
game.phase = unoPhasePlay
p.sendHandDisplay(game)
return nil
}
if playableCard != nil {
// Remove the playable card from bot's hand
for i := len(game.botHand) - 1; i >= 0; i-- {
if game.botHand[i] == *playableCard {
game.botHand = append(game.botHand[:i], game.botHand[i+1:]...)
break
}
}
commentKey := "bot_draw_normal"
if game.bookDown {
commentKey = "bot_draw_bookdown"
}
p.SendMessage(game.dmRoomID, fmt.Sprintf("%s drew %d card(s). %s",
bn, len(allDrawn), pickCommentary(commentKey)))
return p.botPlaysCard(game, *playableCard)
}
commentKey := "bot_draw_normal"
if game.bookDown {
commentKey = "bot_draw_bookdown"
}
p.SendMessage(game.dmRoomID, fmt.Sprintf("%s drew %d card(s). No playable card. %s",
bn, len(allDrawn), pickCommentary(commentKey)))
return p.playerTurnOrAutoDraw(game)
}
func (p *UnoPlugin) botPlaysCard(game *unoGame, card unoCard) error {
game.discardTop = card
game.turns++
// Choose color for wilds
if card.isWild() {
game.topColor = p.botChooseColor(game)
} else {
game.topColor = card.Color
}
displayCard := card.DisplayWithColor(game.topColor)
bn := unoBotName()
// Build a single DM message (all commentary stays in DM)
var dm strings.Builder
switch card.Value {
case unoDrawTwo:
if game.noMercy {
// No Mercy: stacking
dm.WriteString(fmt.Sprintf("%s plays: %s — stack incoming! (+%d total)",
bn, displayCard, cardDrawValue(card.Value)))
p.SendMessage(game.dmRoomID, dm.String())
game.stackTotal = cardDrawValue(card.Value)
game.stackMinValue = cardDrawValue(card.Value)
return p.playerTurnOrAutoDraw(game)
}
commentKey := "bot_draw_two"
if game.bookDown {
commentKey = "bot_draw_two_bookdown"
}
dm.WriteString(fmt.Sprintf("%s plays: %s\n%s\nYou draw 2 cards and lose your turn.",
bn, displayCard, pickCommentary(commentKey)))
drawn := game.draw(2)
game.playerHand = append(game.playerHand, drawn...)
case unoDrawFour:
// Only exists in No Mercy — colored draw four
dm.WriteString(fmt.Sprintf("%s plays: %s — stack incoming! (+%d total)",
bn, displayCard, cardDrawValue(card.Value)))
p.SendMessage(game.dmRoomID, dm.String())
game.stackTotal = cardDrawValue(card.Value)
game.stackMinValue = cardDrawValue(card.Value)
return p.playerTurnOrAutoDraw(game)
case unoWildDrawFour:
commentKey := "bot_wild_draw_four"
if game.bookDown {
commentKey = "bot_wild_draw_four_bookdown"
}
dm.WriteString(fmt.Sprintf("%s plays: %s (chose %s %s)\n%s\nYou draw 4 cards and lose your turn.",
bn, card.Display(), game.topColor.Emoji(), game.topColor, pickCommentary(commentKey)))
drawn := game.draw(4)
game.playerHand = append(game.playerHand, drawn...)
case unoWildReverseDraw4:
dm.WriteString(fmt.Sprintf("%s plays: %s (chose %s %s) — stack incoming! (+%d total)",
bn, card.Display(), game.topColor.Emoji(), game.topColor, cardDrawValue(card.Value)))
p.SendMessage(game.dmRoomID, dm.String())
game.stackTotal = cardDrawValue(card.Value)
game.stackMinValue = cardDrawValue(card.Value)
return p.playerTurnOrAutoDraw(game)
case unoWildDrawSix:
dm.WriteString(fmt.Sprintf("%s plays: %s (chose %s %s) — stack incoming! (+%d total)",
bn, card.Display(), game.topColor.Emoji(), game.topColor, cardDrawValue(card.Value)))
p.SendMessage(game.dmRoomID, dm.String())
game.stackTotal = cardDrawValue(card.Value)
game.stackMinValue = cardDrawValue(card.Value)
return p.playerTurnOrAutoDraw(game)
case unoWildDrawTen:
dm.WriteString(fmt.Sprintf("%s plays: %s (chose %s %s) — stack incoming! (+%d total)",
bn, card.Display(), game.topColor.Emoji(), game.topColor, cardDrawValue(card.Value)))
p.SendMessage(game.dmRoomID, dm.String())
game.stackTotal = cardDrawValue(card.Value)
game.stackMinValue = cardDrawValue(card.Value)
return p.playerTurnOrAutoDraw(game)
case unoWildColorRoulette:
flipped := p.executeColorRouletteSolo(game, true, game.topColor)
dm.WriteString(fmt.Sprintf("%s plays: %s (chose %s %s)\n🎰 Color Roulette! You flip %d cards.\n%s",
bn, card.Display(), game.topColor.Emoji(), game.topColor,
len(flipped), pickNoMercyCommentary("color_roulette")))
p.SendMessage(game.dmRoomID, dm.String())
if p.checkSoloMercyElimination(game) {
return nil
}
// Player was the victim — bot goes again (skip effect)
return p.botTurn(game)
case unoSkip, unoReverse:
commentKey := "bot_play_normal"
if game.bookDown {
commentKey = "bot_play_bookdown"
}
dm.WriteString(fmt.Sprintf(pickCommentary(commentKey), displayCard))
dm.WriteString("\nYour turn is skipped!")
case unoSkipEveryone:
commentKey := "bot_play_normal"
if game.bookDown {
commentKey = "bot_play_bookdown"
}
dm.WriteString(fmt.Sprintf(pickCommentary(commentKey), displayCard))
dm.WriteString("\nYour turn is skipped!")
case unoDiscardAll:
discarded := discardAllOfColor(&game.botHand, card.Color)
commentKey := "bot_play_normal"
if game.bookDown {
commentKey = "bot_play_bookdown"
}
dm.WriteString(fmt.Sprintf(pickCommentary(commentKey), displayCard))
if discarded > 0 {
dm.WriteString(fmt.Sprintf("\n%s discards %d additional %s cards!", bn, discarded, card.Color))
}
default:
commentKey := "bot_play_normal"
if game.bookDown {
commentKey = "bot_play_bookdown"
}
dm.WriteString(fmt.Sprintf(pickCommentary(commentKey), displayCard))
}
// No Mercy: 7-0 rule effects from bot
if game.noMercy && game.sevenZeroRule {
if card.Value == unoSeven || card.Value == unoZero {
swapHandsSolo(game)
dm.WriteString(fmt.Sprintf("\n%s\nHands swapped! You now have %d cards.",
pickNoMercyCommentary("hand_swap"), len(game.playerHand)))
if p.checkSoloMercyElimination(game) {
p.SendMessage(game.dmRoomID, dm.String())
return nil
}
}
}
// Check bot win (after discard all, etc.)
if len(game.botHand) == 0 {
p.SendMessage(game.dmRoomID, dm.String())
return p.botWins(game)
}
// Bot UNO call
if len(game.botHand) == 1 {
dm.WriteString("\n\n" + pickCommentary("bot_uno"))
}
// Book state
if changed := game.updateBookState(); changed {
if game.bookDown {
dm.WriteString("\n\n" + pickCommentary("book_down"))
} else {
dm.WriteString("\n\n" + pickCommentary("book_up"))
}
}
// Skip/Reverse — bot goes again in 2-player
if card.Value == unoSkip || card.Value == unoReverse ||
card.Value == unoSkipEveryone {
p.SendMessage(game.dmRoomID, dm.String())
return p.botTurn(game)
}
p.SendMessage(game.dmRoomID, dm.String())
// Mercy check after player received cards (color roulette victim, etc.)
if game.noMercy {
if p.checkSoloMercyElimination(game) {
return nil
}
}
return p.playerTurnOrAutoDraw(game)
}
// ---------------------------------------------------------------------------
// Bot AI (shared between solo and multiplayer)
// ---------------------------------------------------------------------------
// botPickCard selects the best card to play from the given hand.
// opponentMinCards is the smallest hand size among opponents.
func botPickCard(hand []unoCard, discardTop unoCard, topColor unoColor, bookDown bool, opponentMinCards int) (unoCard, int) {
var playable []int
for i, c := range hand {
if c.canPlayOn(discardTop, topColor) {
playable = append(playable, i)
}
}
if len(playable) == 0 {
return unoCard{}, -1
}
if bookDown {
return botPickAggressive(hand, playable)
}
return botPickNormal(hand, topColor, playable, opponentMinCards)
}
func botPickNormal(hand []unoCard, topColor unoColor, playable []int, opponentMinCards int) (unoCard, int) {
var actions, numbers, wd4s []int
for _, i := range playable {
c := hand[i]
switch {
case c.Value == unoWildDrawFour:
wd4s = append(wd4s, i)
case c.Value.isAction():
actions = append(actions, i)
default:
numbers = append(numbers, i)
}
}
// Sometimes play WD4 even when we have other options (20% chance)
// This makes the challenge mechanic meaningful against the bot.
if len(wd4s) > 0 && (len(actions) > 0 || len(numbers) > 0) && rand.IntN(5) == 0 {
idx := wd4s[0]
return hand[idx], idx
}
// Save WD4 unless opponent is close to winning
if opponentMinCards > 3 {
if len(actions) > 0 {
for _, i := range actions {
if hand[i].Color == topColor {
return hand[i], i
}
}
idx := actions[0]
return hand[idx], idx
}
if len(numbers) > 0 {
for _, i := range numbers {
if hand[i].Color == topColor {
return hand[i], i
}
}
idx := numbers[0]
return hand[idx], idx
}
}
// Opponent close to winning or no other choice
if len(wd4s) > 0 {
idx := wd4s[0]
return hand[idx], idx
}
if len(actions) > 0 {
idx := actions[0]
return hand[idx], idx
}
idx := numbers[0]
return hand[idx], idx
}
func botPickAggressive(hand []unoCard, playable []int) (unoCard, int) {
var wd4s, actions, numbers []int
for _, i := range playable {
c := hand[i]
switch {
case c.Value == unoWildDrawFour:
wd4s = append(wd4s, i)
case c.Value.isAction():
actions = append(actions, i)
default:
numbers = append(numbers, i)
}
}
if len(wd4s) > 0 {
idx := wd4s[0]
return hand[idx], idx
}
if len(actions) > 0 {
idx := actions[0]
return hand[idx], idx
}
idx := numbers[0]
return hand[idx], idx
}
func botPickColor(hand []unoCard) unoColor {
counts := map[unoColor]int{}
for _, c := range hand {
if c.Color != unoWild {
counts[c.Color]++
}
}
best := unoRed
bestCount := 0
for _, color := range []unoColor{unoRed, unoBlue, unoYellow, unoGreen} {
if counts[color] > bestCount {
bestCount = counts[color]
best = color
}
}
return best
}
// Solo wrappers for backward compatibility
func (p *UnoPlugin) botChooseCard(game *unoGame) (unoCard, int) {
if game.noMercy {
return botPickCardNoMercy(game.botHand, game.discardTop, game.topColor, game.bookDown, len(game.playerHand), game.stackMinValue)
}
return botPickCard(game.botHand, game.discardTop, game.topColor, game.bookDown, len(game.playerHand))
}
func (p *UnoPlugin) botChooseColor(game *unoGame) unoColor {
return botPickColor(game.botHand)
}
// afterBotTurn is called when the bot's turn was skipped (player played skip/reverse/draw two).
// Shows the hand display for the player's next turn.
func (p *UnoPlugin) afterBotTurn(game *unoGame) error {
return p.playerTurnOrAutoDraw(game)
}
// ---------------------------------------------------------------------------
// Display
// ---------------------------------------------------------------------------
func (p *UnoPlugin) appendHandDisplay(game *unoGame, 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 game.playerHand {
playable := ""
if c.canPlayOn(game.discardTop, game.topColor) {
playable = " ✅"
}
sb.WriteString(fmt.Sprintf("%d. %s%s\n", i+1, c.Display(), playable))
}
sb.WriteString(fmt.Sprintf("\n%s has %d cards.", unoBotName(), len(game.botHand)))
if game.noMercy && len(game.playerHand) >= 20 {
sb.WriteString(fmt.Sprintf("\n⚠ **You have %d cards! (25 = eliminated)**", len(game.playerHand)))
}
sb.WriteString("\n\nReply with a card number to play, or **draw** to draw.")
}
func (p *UnoPlugin) sendHandDisplay(game *unoGame) {
var sb strings.Builder
p.appendHandDisplay(game, &sb)
p.SendMessage(game.dmRoomID, sb.String())
}
// sendHandDisplayStacking shows hand during stacking — only stackable cards are playable.
func (p *UnoPlugin) sendHandDisplayStacking(game *unoGame) {
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 game.playerHand {
marker := ""
if c.canPlayOnStacking(game.topColor, game.stackMinValue) {
marker = " ✅"
}
sb.WriteString(fmt.Sprintf("%d. %s%s\n", i+1, c.Display(), marker))
}
sb.WriteString(fmt.Sprintf("\n%s has %d cards.", unoBotName(), len(game.botHand)))
sb.WriteString("\n\nPlay a draw card to stack, or type **accept** to draw the stack.")
p.SendMessage(game.dmRoomID, sb.String())
}
// playerTurnOrAutoDraw shows the hand if the player has playable cards,
// otherwise auto-draws and passes to the bot.
func (p *UnoPlugin) playerTurnOrAutoDraw(game *unoGame) error {
// No Mercy stacking: if a stack is pending, show stack prompt
if game.noMercy && game.stackMinValue > 0 {
if hasStackableCard(game.playerHand, game.topColor, game.stackMinValue) {
game.phase = unoPhasePlay
p.sendHandDisplayStacking(game)
return nil
}
// Player must absorb the stack
drawn := game.draw(game.stackTotal)
game.playerHand = append(game.playerHand, drawn...)
p.SendMessage(game.dmRoomID, fmt.Sprintf("💥 No stackable card! You draw %d cards.\n%s",
game.stackTotal, pickNoMercyCommentary("stack_absorbed")))
game.stackTotal = 0
game.stackMinValue = 0
if p.checkSoloMercyElimination(game) {
return nil
}
// After absorbing, bot's turn (player was skipped)
return p.botTurn(game)
}
if game.hasPlayable() {
game.phase = unoPhasePlay
p.sendHandDisplay(game)
return nil
}
// No Mercy: draw until playable
if game.noMercy {
var allDrawn []unoCard
var playableCard *unoCard
for {
cards := game.draw(1)
if len(cards) == 0 {
break
}
card := cards[0]
game.playerHand = append(game.playerHand, card)
allDrawn = append(allDrawn, card)
if p.checkSoloMercyElimination(game) {
return nil
}
if card.canPlayOn(game.discardTop, game.topColor) {
playableCard = &card
break
}
}
if len(allDrawn) == 0 {
p.SendMessage(game.dmRoomID, "No playable cards and deck is empty.")
game.phase = unoPhasePlay
p.sendHandDisplay(game)
return nil
}
if playableCard != nil {
game.drawnCard = playableCard
game.phase = unoPhaseDrawnPlayable
return p.SendMessage(game.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.dmRoomID,
fmt.Sprintf("No playable cards — drew %d card(s). None playable. Turn passes to %s.",
len(allDrawn), unoBotName()))
return p.botTurn(game)
}
// Classic: draw one
drawn := game.draw(1)
if len(drawn) == 0 {
p.SendMessage(game.dmRoomID, "No playable cards and deck is empty.")
game.phase = unoPhasePlay
p.sendHandDisplay(game)
return nil
}
card := drawn[0]
game.playerHand = append(game.playerHand, card)
if card.canPlayOn(game.discardTop, game.topColor) {
game.drawnCard = &card
game.phase = unoPhaseDrawnPlayable
return p.SendMessage(game.dmRoomID,
fmt.Sprintf("No playable cards — drew automatically: %s\nIt's playable! Play it? (**yes** / **no**)", card.Display()))
}
p.SendMessage(game.dmRoomID,
fmt.Sprintf("No playable cards — drew automatically: %s\nNot playable. Turn passes to %s.", card.Display(), unoBotName()))
return p.botTurn(game)
}
// ---------------------------------------------------------------------------
// Win/Loss
// ---------------------------------------------------------------------------
func (p *UnoPlugin) playerWins(game *unoGame) error {
p.mu.Lock()
if game.done {
p.mu.Unlock()
return nil
}
game.done = true
p.mu.Unlock()
potBefore := p.getPot()
payout := p.claimFromPot(game.wager)
totalPayout := game.wager + payout
p.euro.Credit(game.playerID, totalPayout, "uno_win")
newPot := p.getPot()
playerName := p.unoDisplayName(game.playerID)
p.SendMessage(game.dmRoomID, "🎉 **Uno out! You win!**")
if newPot <= 0 {
p.SendMessage(game.roomID, fmt.Sprintf(
"🎉 **%s** has defeated "+unoBotName()+"! €%d claimed from the community pot.\n(Community pot remaining: €0)\n\n%s\n\n🃏 The community pot has been reset. Current pot: €0. For now.",
playerName, int(payout), pickCommentary("bot_lose_empty_pot")))
} else {
p.SendMessage(game.roomID, fmt.Sprintf(
"🎉 **%s** has defeated "+unoBotName()+"! €%d claimed from the community pot.\n(Community pot remaining: €%d)\n\n%s",
playerName, int(payout), int(newPot), pickCommentary("bot_lose")))
}
p.recordGame(game, "player_win", potBefore)
p.cleanupGame(game)
return nil
}
func (p *UnoPlugin) botWins(game *unoGame) error {
p.mu.Lock()
if game.done {
p.mu.Unlock()
return nil
}
game.done = true
p.mu.Unlock()
potBefore := p.getPot()
p.addToPot(game.wager)
newPot := p.getPot()
playerName := p.unoDisplayName(game.playerID)
p.SendMessage(game.dmRoomID, "💀 **"+unoBotName()+" wins.** Better luck next time.")
p.SendMessage(game.roomID, fmt.Sprintf(
"💀 "+unoBotName()+" wins. **%s**'s €%d has been added to the community pot.\n(Community pot: €%d)\n\n%s",
playerName, int(game.wager), int(newPot), pickCommentary("bot_win")))
recordBotDefeat(game.playerID, "uno")
p.recordGame(game, "gogobee_win", potBefore)
p.cleanupGame(game)
return nil
}
func (p *UnoPlugin) forfeitGame(game *unoGame, timeout bool) error {
p.mu.Lock()
if game.done {
p.mu.Unlock()
return nil
}
game.done = true
p.mu.Unlock()
potBefore := p.getPot()
p.addToPot(game.wager)
playerName := p.unoDisplayName(game.playerID)
if timeout {
p.SendMessage(game.dmRoomID,
fmt.Sprintf("Game forfeited. Your €%d has been added to the community pot.", int(game.wager)))
p.SendMessage(game.roomID,
fmt.Sprintf("🚪 **%s** has forfeited. €%d added to the pot. 💛", playerName, int(game.wager)))
} else {
p.SendMessage(game.dmRoomID,
fmt.Sprintf("You quit. Your €%d has been added to the community pot.", int(game.wager)))
p.SendMessage(game.roomID,
fmt.Sprintf("🚪 **%s** has quit. €%d added to the pot. 💛", playerName, int(game.wager)))
}
recordBotDefeat(game.playerID, "uno")
p.recordGame(game, "gogobee_win", potBefore)
p.cleanupGame(game)
return nil
}
// ---------------------------------------------------------------------------
// Idle timers
// ---------------------------------------------------------------------------
func (p *UnoPlugin) resetIdleTimer(game *unoGame) {
p.mu.Lock()
if game.idleTimer != nil {
game.idleTimer.Stop()
}
if game.warningTimer != nil {
game.warningTimer.Stop()
}
playerID := game.playerID
p.mu.Unlock()
game.idleTimer = time.AfterFunc(5*time.Minute, func() {
p.mu.Lock()
g := p.games[playerID]
if g == nil || g.done {
p.mu.Unlock()
return
}
dmRoom := g.dmRoomID
p.mu.Unlock()
p.SendMessage(dmRoom,
"Still there? Reply with your move or type **quit** to forfeit.\n(You have 2 minutes before the game is forfeited.)")
p.mu.Lock()
if g.done {
p.mu.Unlock()
return
}
g.warningTimer = time.AfterFunc(2*time.Minute, func() {
p.mu.Lock()
g2 := p.games[playerID]
if g2 == nil || g2.done {
p.mu.Unlock()
return
}
p.mu.Unlock()
p.forfeitGame(g2, true)
})
p.mu.Unlock()
})
}
// ---------------------------------------------------------------------------
// Cleanup and persistence
// ---------------------------------------------------------------------------
func (p *UnoPlugin) cleanupGame(game *unoGame) {
p.mu.Lock()
if game.idleTimer != nil {
game.idleTimer.Stop()
}
if game.warningTimer != nil {
game.warningTimer.Stop()
}
delete(p.games, game.playerID)
delete(p.dmToPlayer, game.dmRoomID)
p.mu.Unlock()
}
func (p *UnoPlugin) recordGame(game *unoGame, result string, potBefore float64) {
d := db.Get()
_, err := d.Exec(
`INSERT INTO uno_games (player_id, wager, result, pot_before, pot_after, turns, started_at, ended_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
string(game.playerID), game.wager, result, potBefore, p.getPot(),
game.turns, game.startedAt.UTC().Format("2006-01-02 15:04:05"),
time.Now().UTC().Format("2006-01-02 15:04:05"),
)
if err != nil {
slog.Error("uno: failed to record game", "err", err)
}
}
func (p *UnoPlugin) unoDisplayName(userID id.UserID) string {
resp, err := p.Client.GetDisplayName(context.Background(), userID)
if err != nil || resp.DisplayName == "" {
return string(userID)
}
return resp.DisplayName
}