mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 16:42:41 +00:00
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.
2042 lines
58 KiB
Go
2042 lines
58 KiB
Go
package plugin
|
||
|
||
import (
|
||
"fmt"
|
||
"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)
|
||
|
||
// Sudden death (long-game point scoring)
|
||
suddenDeath bool
|
||
suddenDeathTurn int // turn at which game ends
|
||
|
||
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*",
|
||
},
|
||
"sudden_death": {
|
||
"*GogoBee puts the book down.* \"This has gone on long enough. 💛\"",
|
||
"*GogoBee glances at the clock.* \"Time to settle this. 💛\"",
|
||
},
|
||
"sudden_death_win": {
|
||
"\"Well played. The numbers don't lie. 💛\" *resumes reading*",
|
||
},
|
||
"sudden_death_lose": {
|
||
"\"The math was on my side. 💛\" *turns a page*",
|
||
},
|
||
}
|
||
|
||
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) Version() string { return "1.2.0" }
|
||
|
||
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 addbot | !uno removebot | !uno go | !uno leave | !uno cancel", 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)
|
||
case lower == "addbot":
|
||
return p.handleMultiAddBot(ctx)
|
||
case lower == "removebot":
|
||
return p.handleMultiRemoveBot(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.DisplayName(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 (matching color or wild). Type **accept** to draw %d.",
|
||
card.Display(), 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 {
|
||
if p.checkSuddenDeath(game) {
|
||
return nil
|
||
}
|
||
|
||
// 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)
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Sudden Death (long-game point scoring for 2-player matches)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const (
|
||
suddenDeathAnnounce = 80 // turn to announce sudden death
|
||
suddenDeathCountdown = 20 // turns after announcement
|
||
)
|
||
|
||
// checkSuddenDeath checks whether sudden death should be announced or resolved.
|
||
// Returns true if the game ended.
|
||
func (p *UnoPlugin) checkSuddenDeath(game *unoGame) bool {
|
||
if game.done {
|
||
return true
|
||
}
|
||
|
||
// Resolve: game ends at the deadline
|
||
if game.suddenDeath && game.turns >= game.suddenDeathTurn {
|
||
p.suddenDeathWinner(game)
|
||
return true
|
||
}
|
||
|
||
// Announce
|
||
if !game.suddenDeath && game.turns >= suddenDeathAnnounce {
|
||
game.suddenDeath = true
|
||
game.suddenDeathTurn = game.turns + suddenDeathCountdown
|
||
remaining := game.suddenDeathTurn - game.turns
|
||
|
||
ann := fmt.Sprintf("⏰ **SUDDEN DEATH!** This match ends in %d turns — lowest hand value wins!\n\n%s",
|
||
remaining, pickCommentary("sudden_death"))
|
||
p.SendMessage(game.dmRoomID, ann)
|
||
p.SendMessage(game.roomID, fmt.Sprintf("⏰ **Sudden Death activated!** %s's match ends in %d turns.",
|
||
p.DisplayName(game.playerID), remaining))
|
||
return false
|
||
}
|
||
|
||
// Countdown reminders (DM only)
|
||
if game.suddenDeath {
|
||
remaining := game.suddenDeathTurn - game.turns
|
||
switch remaining {
|
||
case 10:
|
||
p.SendMessage(game.dmRoomID, "⏰ **10 turns remaining!**")
|
||
case 5:
|
||
p.SendMessage(game.dmRoomID, "⏰ **5 turns remaining!**")
|
||
}
|
||
}
|
||
|
||
return false
|
||
}
|
||
|
||
func (p *UnoPlugin) suddenDeathWinner(game *unoGame) {
|
||
p.mu.Lock()
|
||
if game.done {
|
||
p.mu.Unlock()
|
||
return
|
||
}
|
||
game.done = true
|
||
p.mu.Unlock()
|
||
|
||
playerScore := scoreHand(game.playerHand)
|
||
botScore := scoreHand(game.botHand)
|
||
bn := unoBotName()
|
||
playerName := p.DisplayName(game.playerID)
|
||
|
||
// Build score breakdown
|
||
breakdown := fmt.Sprintf("**Final Scores:**\n%s: %s\n%s: %s",
|
||
playerName, formatHandScore(game.playerHand),
|
||
bn, formatHandScore(game.botHand))
|
||
|
||
var playerWins bool
|
||
switch {
|
||
case playerScore < botScore:
|
||
playerWins = true
|
||
case botScore < playerScore:
|
||
playerWins = false
|
||
case len(game.playerHand) < len(game.botHand):
|
||
// Tiebreaker: fewer cards
|
||
playerWins = true
|
||
breakdown += "\n*Tiebreaker: fewer cards!*"
|
||
case len(game.botHand) < len(game.playerHand):
|
||
playerWins = false
|
||
breakdown += "\n*Tiebreaker: fewer cards!*"
|
||
default:
|
||
// Final tiebreaker: player wins (bot had the advantage of going second in the countdown)
|
||
playerWins = true
|
||
breakdown += "\n*Tiebreaker: dead even — player takes it!*"
|
||
}
|
||
|
||
p.SendMessage(game.dmRoomID, fmt.Sprintf("⏰ **TIME'S UP — SUDDEN DEATH!**\n\n%s", breakdown))
|
||
|
||
potBefore := p.getPot()
|
||
|
||
if playerWins {
|
||
payout := p.claimFromPot(game.wager)
|
||
totalPayout := game.wager + payout
|
||
net, _ := communityTax(game.playerID, totalPayout, 0.05)
|
||
p.euro.Credit(game.playerID, net, "uno_win")
|
||
|
||
newPot := p.getPot()
|
||
p.SendMessage(game.dmRoomID, "🎉 **You win on points!**")
|
||
p.SendMessage(game.roomID, fmt.Sprintf(
|
||
"⏰ **Sudden Death!** %s wins on points!\n%s\n€%d claimed from the community pot. (Pot: €%d)\n\n%s",
|
||
playerName, breakdown, int(payout), int(newPot), pickCommentary("sudden_death_win")))
|
||
|
||
p.recordGame(game, "sudden_death_player", potBefore)
|
||
} else {
|
||
p.addToPot(game.wager)
|
||
newPot := p.getPot()
|
||
|
||
p.SendMessage(game.dmRoomID, fmt.Sprintf("💀 **%s wins on points.** Better luck next time.", bn))
|
||
p.SendMessage(game.roomID, fmt.Sprintf(
|
||
"⏰ **Sudden Death!** %s wins on points!\n%s\n%s's €%d added to the community pot. (Pot: €%d)\n\n%s",
|
||
bn, breakdown, playerName, int(game.wager), int(newPot), pickCommentary("sudden_death_lose")))
|
||
|
||
recordBotDefeat(game.playerID, "uno")
|
||
p.recordGame(game, "sudden_death_bot", potBefore)
|
||
}
|
||
|
||
p.cleanupGame(game)
|
||
}
|
||
|
||
// 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 {
|
||
if p.checkSuddenDeath(game) {
|
||
return nil
|
||
}
|
||
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 {
|
||
if p.checkSuddenDeath(game) {
|
||
return nil
|
||
}
|
||
|
||
// 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
|
||
net, _ := communityTax(game.playerID, totalPayout, 0.05)
|
||
p.euro.Credit(game.playerID, net, "uno_win")
|
||
|
||
newPot := p.getPot()
|
||
playerName := p.DisplayName(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.DisplayName(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.DisplayName(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) {
|
||
db.Exec("uno: record game",
|
||
`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"),
|
||
)
|
||
}
|