mirror of
https://github.com/prosolis/gogobee.git
synced 2026-09-14 19:01:09 +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"),
|
|
)
|
|
}
|