Add UNO Show 'Em No Mercy game mode

168-card deck with new card types (Skip Everyone, Discard All,
Wild Reverse Draw Four, Wild Draw Six/Ten, Wild Color Roulette),
draw stacking, draw-until-playable, mercy elimination at 25 cards,
and optional 7-0 hand swap/rotation rule. Works in both solo and
multiplayer. Includes blackjack max player increase to 4.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
prosolis
2026-03-21 19:37:41 -07:00
parent 20332d69d6
commit 9446038646
4 changed files with 2118 additions and 156 deletions

View File

@@ -279,8 +279,8 @@ func (p *BlackjackPlugin) handleBlackjack(ctx MessageContext) error {
return p.SendReply(ctx.RoomID, ctx.EventID, "You're already at the table!")
}
}
if len(table.players) >= 2 {
return p.SendReply(ctx.RoomID, ctx.EventID, "Table is full (max 2 players).")
if len(table.players) >= 4 {
return p.SendReply(ctx.RoomID, ctx.EventID, "Table is full (max 4 players).")
}
if !p.euro.Debit(ctx.Sender, bet, "blackjack_bet") {
@@ -289,13 +289,20 @@ func (p *BlackjackPlugin) handleBlackjack(ctx MessageContext) error {
table.players = append(table.players, &bjPlayer{UserID: ctx.Sender, Bet: bet})
name := p.bjDisplayName(ctx.Sender)
if len(table.players) >= 4 {
_ = p.SendMessage(ctx.RoomID,
fmt.Sprintf("🃏 **%s** joins the table! Bet: €%d\nTable is full — dealing!", name, int(bet)))
if table.joinTimer != nil {
table.joinTimer.Stop()
}
p.startRound(ctx.RoomID, table)
} else {
remaining := len(table.players)
_ = p.SendMessage(ctx.RoomID,
fmt.Sprintf("🃏 **%s** joins the table! Bet: €%d (%d/4 players — `!blackjack deal` to start now)",
name, int(bet), remaining))
}
return nil
}
@@ -458,15 +465,16 @@ func (p *BlackjackPlugin) findPlayer(table *bjTable, userID id.UserID) *bjPlayer
func (p *BlackjackPlugin) handleHit(ctx MessageContext) error {
p.mu.Lock()
defer p.mu.Unlock()
table, exists := p.tables[ctx.RoomID]
if !exists || table.phase != "playing" {
p.mu.Unlock()
return nil
}
player := p.findPlayer(table, ctx.Sender)
if player == nil || player.Done {
p.mu.Unlock()
return nil
}
@@ -477,48 +485,70 @@ func (p *BlackjackPlugin) handleHit(ctx MessageContext) error {
player.Bust = true
player.Done = true
name := p.bjDisplayName(player.UserID)
_ = p.SendMessage(ctx.RoomID,
fmt.Sprintf("💥 **%s** busts with %s (%d)!", name, handStr(player.Hand), v))
p.checkAllDone(ctx.RoomID, table)
msgs := []string{fmt.Sprintf("💥 **%s** busts with %s (%d)!", name, handStr(player.Hand), v)}
allDoneMsgs := p.collectAllDone(ctx.RoomID, table)
p.mu.Unlock()
for _, m := range append(msgs, allDoneMsgs...) {
_ = p.SendMessage(ctx.RoomID, m)
}
return nil
}
if v == 21 {
player.Done = true
name := p.bjDisplayName(player.UserID)
_ = p.SendMessage(ctx.RoomID,
fmt.Sprintf("**%s** has 21! %s", name, handStr(player.Hand)))
p.checkAllDone(ctx.RoomID, table)
msgs := []string{fmt.Sprintf("**%s** has 21! %s", name, handStr(player.Hand))}
allDoneMsgs := p.collectAllDone(ctx.RoomID, table)
p.mu.Unlock()
for _, m := range append(msgs, allDoneMsgs...) {
_ = p.SendMessage(ctx.RoomID, m)
}
return nil
}
_ = p.SendMessage(ctx.RoomID, p.renderTable(table, false))
msg := p.renderTable(table, false)
p.mu.Unlock()
_ = p.SendMessage(ctx.RoomID, msg)
return nil
}
func (p *BlackjackPlugin) handleStand(ctx MessageContext) error {
p.mu.Lock()
defer p.mu.Unlock()
table, exists := p.tables[ctx.RoomID]
if !exists || table.phase != "playing" {
p.mu.Unlock()
return nil
}
player := p.findPlayer(table, ctx.Sender)
if player == nil || player.Done {
p.mu.Unlock()
return nil
}
player.Done = true
name := p.bjDisplayName(player.UserID)
_ = p.SendMessage(ctx.RoomID, fmt.Sprintf("**%s** stands at %d.", name, player.value()))
p.checkAllDone(ctx.RoomID, table)
msgs := []string{fmt.Sprintf("**%s** stands at %d.", name, player.value())}
allDoneMsgs := p.collectAllDone(ctx.RoomID, table)
p.mu.Unlock()
for _, m := range append(msgs, allDoneMsgs...) {
_ = p.SendMessage(ctx.RoomID, m)
}
return nil
}
// checkAllDone checks if all players are done and triggers the dealer. Must be called with p.mu held.
func (p *BlackjackPlugin) checkAllDone(roomID id.RoomID, table *bjTable) {
msgs := p.collectAllDone(roomID, table)
for _, m := range msgs {
_ = p.SendMessage(roomID, m)
}
}
// collectAllDone is like checkAllDone but returns messages instead of sending them.
// This allows callers to release the mutex before sending. Must be called with p.mu held.
func (p *BlackjackPlugin) collectAllDone(roomID id.RoomID, table *bjTable) []string {
var waiting []string
for _, pl := range table.players {
if !pl.Done {
@@ -527,14 +557,12 @@ func (p *BlackjackPlugin) checkAllDone(roomID id.RoomID, table *bjTable) {
}
if len(waiting) > 0 {
_ = p.SendMessage(roomID,
fmt.Sprintf("⏳ Waiting on: **%s**", strings.Join(waiting, "**, **")))
return
return []string{fmt.Sprintf("⏳ Waiting on: **%s**", strings.Join(waiting, "**, **"))}
}
// All players done — stop timers and go to dealer
p.stopRoundTimers(table)
p.playDealer(roomID, table)
return p.collectDealer(roomID, table)
}
// startRoundTimer starts a shared timeout for the round plus reminder nudges. Must be called with p.mu held.
@@ -631,6 +659,14 @@ func (p *BlackjackPlugin) stopRoundTimers(table *bjTable) {
// playDealer plays the dealer hand. Must be called with p.mu held.
func (p *BlackjackPlugin) playDealer(roomID id.RoomID, table *bjTable) {
msgs := p.collectDealer(roomID, table)
for _, m := range msgs {
_ = p.SendMessage(roomID, m)
}
}
// collectDealer is like playDealer but returns messages. Must be called with p.mu held.
func (p *BlackjackPlugin) collectDealer(roomID id.RoomID, table *bjTable) []string {
// Check if all players busted
allBust := true
for _, pl := range table.players {
@@ -652,10 +688,18 @@ func (p *BlackjackPlugin) playDealer(roomID id.RoomID, table *bjTable) {
}
}
p.resolveRound(roomID, table)
return p.collectResolveRound(roomID, table)
}
func (p *BlackjackPlugin) resolveRound(roomID id.RoomID, table *bjTable) {
msgs := p.collectResolveRound(roomID, table)
for _, m := range msgs {
_ = p.SendMessage(roomID, m)
}
}
// collectResolveRound resolves the round and returns messages. Must be called with p.mu held.
func (p *BlackjackPlugin) collectResolveRound(roomID id.RoomID, table *bjTable) []string {
table.phase = "done"
dealerValue, _ := handValue(table.dealer)
dealerBust := dealerValue > 21
@@ -738,7 +782,7 @@ func (p *BlackjackPlugin) resolveRound(roomID id.RoomID, table *bjTable) {
table.joinTimer.Stop()
}
delete(p.tables, roomID)
_ = p.SendMessage(roomID, sb.String())
return []string{sb.String()}
}
// ---------------------------------------------------------------------------

View File

@@ -78,6 +78,14 @@ const (
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 {
@@ -94,6 +102,20 @@ func (v unoValue) String() string {
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 "?"
}
@@ -101,7 +123,10 @@ func (v unoValue) String() string {
func (v unoValue) isAction() bool {
return v == unoSkip || v == unoReverse || v == unoDrawTwo ||
v == unoWildCard || v == unoWildDrawFour
v == unoWildCard || v == unoWildDrawFour ||
v == unoSkipEveryone || v == unoDrawFour || v == unoDiscardAll ||
v == unoWildReverseDraw4 || v == unoWildDrawSix ||
v == unoWildDrawTen || v == unoWildColorRoulette
}
type unoCard struct {
@@ -109,8 +134,14 @@ type unoCard struct {
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.Value == unoWildCard || c.Value == unoWildDrawFour {
if c.isWild() {
return fmt.Sprintf("%s %s", unoWild.Emoji(), c.Value)
}
return fmt.Sprintf("%s %s", c.Color.Emoji(), c.Value)
@@ -118,14 +149,14 @@ func (c unoCard) Display() string {
// DisplayWithColor shows a wild card with its chosen color.
func (c unoCard) DisplayWithColor(chosenColor unoColor) string {
if c.Value == unoWildCard || c.Value == unoWildDrawFour {
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.Value == unoWildCard || c.Value == unoWildDrawFour {
if c.isWild() {
return true
}
return c.Color == topColor || c.Value == top.Value
@@ -190,6 +221,12 @@ type unoGame struct {
startedAt time.Time
done bool // set true when game ends — prevents double-completion
// No Mercy mode
noMercy bool
sevenZeroRule bool
stackTotal int // cumulative draw penalty during stacking
stackMinValue int // minimum draw value to stack (0 = not stacking)
idleTimer *time.Timer
warningTimer *time.Timer
}
@@ -211,7 +248,12 @@ func (g *unoGame) draw(n int) []unoCard {
func (g *unoGame) reshuffleDiscard() {
// Rebuild deck from cards not in play
fresh := newUnoDeck()
var fresh []unoCard
if g.noMercy {
fresh = newNoMercyDeck()
} else {
fresh = newUnoDeck()
}
inPlay := make(map[unoCard]int)
for _, c := range g.playerHand {
@@ -361,7 +403,7 @@ func (p *UnoPlugin) Name() string { return "uno" }
func (p *UnoPlugin) Commands() []CommandDef {
return []CommandDef{
{Name: "uno", Description: "Solo or multiplayer Uno", Usage: "!uno €amount | !uno start €amount | !uno join | !uno go", Category: "Games"},
{Name: "uno", Description: "Solo or multiplayer Uno", Usage: "!uno [nomercy [7-0]] €amount | !uno start [nomercy [7-0]] €amount | !uno join | !uno go", Category: "Games"},
{Name: "uno_pot", Description: "Show the community pot balance", Usage: "!uno_pot", Category: "Games"},
}
}
@@ -382,7 +424,9 @@ func (p *UnoPlugin) OnMessage(ctx MessageContext) error {
// Multiplayer subcommands
switch {
case strings.HasPrefix(lower, "start "):
return p.handleMultiStart(ctx, strings.TrimSpace(args[6:]))
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":
@@ -393,16 +437,17 @@ func (p *UnoPlugin) OnMessage(ctx MessageContext) error {
return p.handleMultiCancel(ctx)
}
// Solo challenge: !uno €amount
// Solo challenge: !uno [nomercy [7-0]] €amount
noMercy, sevenZero, amountStr := parseNoMercyFlags(args)
if isGamesRoom(ctx.RoomID) {
return p.handleChallenge(ctx, 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))
return p.handleChallenge(ctx, id.RoomID(gr), noMercy, sevenZero, amountStr)
}
// DM gameplay — check solo games first, then multiplayer
@@ -487,13 +532,12 @@ func (p *UnoPlugin) handlePotCheck(ctx MessageContext) error {
// Challenge
// ---------------------------------------------------------------------------
func (p *UnoPlugin) handleChallenge(ctx MessageContext, announceRoom id.RoomID) error {
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)
}
args := p.GetArgs(ctx.Body, "uno")
amountStr := strings.TrimPrefix(strings.TrimSpace(args), "€")
amountStr := strings.TrimPrefix(strings.TrimSpace(rawAmount), "")
var amount float64
fmt.Sscanf(amountStr, "%f", &amount)
@@ -531,7 +575,7 @@ func (p *UnoPlugin) handleChallenge(ctx MessageContext, announceRoom id.RoomID)
}
// Initialize game
game := p.initGame(ctx.Sender, announceRoom, dmRoom, amount)
game := p.initGame(ctx.Sender, announceRoom, dmRoom, amount, noMercy, sevenZeroRule)
p.mu.Lock()
p.games[ctx.Sender] = game
@@ -541,9 +585,18 @@ func (p *UnoPlugin) handleChallenge(ctx MessageContext, announceRoom id.RoomID)
// Room announcement
playerName := p.unoDisplayName(ctx.Sender)
botName := unoBotName()
modeTag := ""
startComment := pickCommentary("start")
if noMercy {
modeTag = " 🔥 **NO MERCY**"
startComment = pickNoMercyCommentary("nomercy_start")
if sevenZeroRule {
modeTag += " (7-0)"
}
}
p.SendMessage(announceRoom, fmt.Sprintf(
"🃏 **%s** has challenged %s to Uno! Stakes: €%d\n\n%s\n\n[Check your DMs to play.]",
playerName, botName, int(amount), pickCommentary("start"),
"🃏 **%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)
@@ -555,8 +608,13 @@ func (p *UnoPlugin) handleChallenge(ctx MessageContext, announceRoom id.RoomID)
return nil
}
func (p *UnoPlugin) initGame(playerID id.UserID, roomID, dmRoom id.RoomID, wager float64) *unoGame {
deck := newUnoDeck()
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)
@@ -566,11 +624,11 @@ func (p *UnoPlugin) initGame(playerID id.UserID, roomID, dmRoom id.RoomID, wager
remaining := make([]unoCard, len(deck)-14)
copy(remaining, deck[14:])
// Flip starting card — must not be Wild or Wild Draw Four
// Flip starting card — must be a number card (not action/wild)
var startCard unoCard
startIdx := -1
for i, c := range remaining {
if c.Value != unoWildCard && c.Value != unoWildDrawFour {
if !c.Value.isAction() && !c.isWild() {
startCard = c
startIdx = i
break
@@ -584,7 +642,11 @@ func (p *UnoPlugin) initGame(playerID id.UserID, roomID, dmRoom id.RoomID, wager
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]
@@ -604,6 +666,8 @@ func (p *UnoPlugin) initGame(playerID id.UserID, roomID, dmRoom id.RoomID, wager
topColor: startCard.Color,
phase: unoPhasePlay,
startedAt: time.Now(),
noMercy: noMercy,
sevenZeroRule: sevenZeroRule,
}
}
@@ -639,12 +703,33 @@ func (p *UnoPlugin) handleDMInput(ctx MessageContext, game *unoGame) error {
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)))
}
@@ -657,7 +742,14 @@ func (p *UnoPlugin) handleDMInput(ctx MessageContext, game *unoGame) error {
func (p *UnoPlugin) handlePlayerPlay(game *unoGame, idx int) error {
card := game.playerHand[idx]
if !card.canPlayOn(game.discardTop, game.topColor) {
// No Mercy stacking validation
if game.noMercy && game.stackMinValue > 0 {
if !card.canPlayOnStacking(game.topColor, game.stackMinValue) {
return p.SendMessage(game.dmRoomID,
fmt.Sprintf("You can't stack %s — need a draw card worth +%d or more. Type **accept** to draw %d.",
card.Display(), game.stackMinValue, game.stackTotal))
}
} else if !card.canPlayOn(game.discardTop, game.topColor) {
return p.SendMessage(game.dmRoomID, fmt.Sprintf("You can't play %s on %s. Choose another card or **draw**.",
card.Display(), game.discardTop.DisplayWithColor(game.topColor)))
}
@@ -678,7 +770,7 @@ func (p *UnoPlugin) handlePlayerPlay(game *unoGame, idx int) error {
game.discardTop = card
game.turns++
if card.Value == unoWildCard || card.Value == unoWildDrawFour {
if card.isWild() {
game.pendingCard = &card
game.phase = unoPhaseChooseColor
return p.SendMessage(game.dmRoomID,
@@ -709,7 +801,35 @@ func (p *UnoPlugin) handlePlayerPlay(game *unoGame, idx int) error {
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...)
@@ -722,6 +842,12 @@ func (p *UnoPlugin) handlePlayerPlay(game *unoGame, idx int) error {
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)
}
@@ -765,7 +891,30 @@ func (p *UnoPlugin) handleColorChoice(game *unoGame, input string) error {
p.SendMessage(game.dmRoomID, fmt.Sprintf("Color set to %s %s.%s", color.Emoji(), color, bookMsg))
// Wild Draw Four — bot draws 4 and loses turn
// 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...)
@@ -784,6 +933,11 @@ func (p *UnoPlugin) handleColorChoice(game *unoGame, input string) error {
}
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()+".")
@@ -804,6 +958,48 @@ func (p *UnoPlugin) handlePlayerDraw(game *unoGame) error {
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**)")
@@ -828,7 +1024,7 @@ func (p *UnoPlugin) handleDrawnPlayable(game *unoGame, input string) error {
game.discardTop = drawnCard
game.turns++
if drawnCard.Value == unoWildCard || drawnCard.Value == unoWildDrawFour {
if drawnCard.isWild() {
game.pendingCard = &drawnCard
game.phase = unoPhaseChooseColor
return p.SendMessage(game.dmRoomID,
@@ -851,13 +1047,41 @@ func (p *UnoPlugin) handleDrawnPlayable(game *unoGame, input string) error {
}
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 {
if drawnCard.Value == unoSkip || drawnCard.Value == unoReverse || drawnCard.Value == unoSkipEveryone {
p.SendMessage(game.dmRoomID, unoBotName()+"'s turn is skipped!")
return p.afterBotTurn(game)
}
@@ -865,11 +1089,78 @@ func (p *UnoPlugin) handleDrawnPlayable(game *unoGame, input string) error {
return p.botTurn(game)
}
// ---------------------------------------------------------------------------
// Solo stacking (No Mercy)
// ---------------------------------------------------------------------------
// soloPlayerPlaysDrawCard handles when the player plays a draw card in No Mercy.
// The bot gets a chance to stack back.
func (p *UnoPlugin) soloPlayerPlaysDrawCard(game *unoGame, card unoCard) error {
dv := cardDrawValue(card.Value)
game.stackTotal += dv
game.stackMinValue = dv
// Reverse effect for Wild Reverse Draw Four
if card.Value == unoWildReverseDraw4 {
// In 2-player, reverse = skip, so effectively bot is the target
// Direction doesn't matter in solo but we note the reverse
}
// Bot tries to stack
return p.soloBotHandleStack(game)
}
// soloBotHandleStack has the bot try to stack or absorb.
func (p *UnoPlugin) soloBotHandleStack(game *unoGame) error {
bn := unoBotName()
// Bot tries to find a stackable card
stackCard, idx := botPickStackCard(game.botHand, game.topColor, game.stackMinValue)
if idx < 0 {
// Bot absorbs
drawn := game.draw(game.stackTotal)
game.botHand = append(game.botHand, drawn...)
p.SendMessage(game.dmRoomID, fmt.Sprintf("💥 %s can't stack! Draws %d cards. (%d cards now)\n%s",
bn, game.stackTotal, len(game.botHand), pickNoMercyCommentary("stack_absorbed")))
game.stackTotal = 0
game.stackMinValue = 0
if p.checkSoloMercyElimination(game) {
return nil
}
return p.afterBotTurn(game)
}
// Bot stacks
game.botHand = append(game.botHand[:idx], game.botHand[idx+1:]...)
game.discardTop = stackCard
dv := cardDrawValue(stackCard.Value)
game.stackTotal += dv
game.stackMinValue = dv
game.turns++
if stackCard.isWild() {
game.topColor = botPickColor(game.botHand)
} else {
game.topColor = stackCard.Color
}
p.SendMessage(game.dmRoomID, fmt.Sprintf("🔥 %s stacks: %s! Total penalty: +%d",
bn, stackCard.DisplayWithColor(game.topColor), game.stackTotal))
// Player must now respond to the stack
return p.playerTurnOrAutoDraw(game)
}
// ---------------------------------------------------------------------------
// Bot turn
// ---------------------------------------------------------------------------
func (p *UnoPlugin) botTurn(game *unoGame) error {
// No Mercy stacking: if a stack is pending, bot must handle it
if game.noMercy && game.stackMinValue > 0 {
return p.soloBotHandleStack(game)
}
// Long game commentary (DM only)
if game.turns > 0 && game.turns%30 == 0 {
p.SendMessage(game.dmRoomID, pickCommentary("long_game"))
@@ -878,9 +1169,12 @@ func (p *UnoPlugin) botTurn(game *unoGame) error {
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 {
// Can't draw — turn passes to player (show hand, don't auto-draw to avoid infinite loop)
p.SendMessage(game.dmRoomID, unoBotName()+" can't draw — no cards left. Turn passes.")
game.phase = unoPhasePlay
p.sendHandDisplay(game)
@@ -888,9 +1182,8 @@ func (p *UnoPlugin) botTurn(game *unoGame) error {
}
game.botHand = append(game.botHand, drawn[0])
// Check if drawn card is playable
if drawn[0].canPlayOn(game.discardTop, game.topColor) {
game.botHand = game.botHand[:len(game.botHand)-1] // remove last (drawn card)
game.botHand = game.botHand[:len(game.botHand)-1]
return p.botPlaysCard(game, drawn[0])
}
@@ -907,12 +1200,68 @@ func (p *UnoPlugin) botTurn(game *unoGame) error {
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.Value == unoWildCard || card.Value == unoWildDrawFour {
if card.isWild() {
game.topColor = p.botChooseColor(game)
} else {
game.topColor = card.Color
@@ -926,6 +1275,15 @@ func (p *UnoPlugin) botPlaysCard(game *unoGame, card unoCard) error {
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"
@@ -935,6 +1293,15 @@ func (p *UnoPlugin) botPlaysCard(game *unoGame, card unoCard) error {
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 {
@@ -945,6 +1312,42 @@ func (p *UnoPlugin) botPlaysCard(game *unoGame, card unoCard) error {
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 {
@@ -953,6 +1356,25 @@ func (p *UnoPlugin) botPlaysCard(game *unoGame, card unoCard) error {
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 {
@@ -961,7 +1383,20 @@ func (p *UnoPlugin) botPlaysCard(game *unoGame, card unoCard) error {
dm.WriteString(fmt.Sprintf(pickCommentary(commentKey), displayCard))
}
// Check bot win
// 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)
@@ -982,12 +1417,21 @@ func (p *UnoPlugin) botPlaysCard(game *unoGame, card unoCard) error {
}
// Skip/Reverse — bot goes again in 2-player
if card.Value == unoSkip || card.Value == unoReverse {
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)
}
@@ -1120,6 +1564,9 @@ func botPickColor(hand []unoCard) unoColor {
// 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))
}
@@ -1150,6 +1597,9 @@ func (p *UnoPlugin) appendHandDisplay(game *unoGame, sb *strings.Builder) {
}
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.")
}
@@ -1159,19 +1609,98 @@ func (p *UnoPlugin) sendHandDisplay(game *unoGame) {
p.SendMessage(game.dmRoomID, sb.String())
}
// sendHandDisplayStacking shows hand during stacking — only stackable cards are playable.
func (p *UnoPlugin) sendHandDisplayStacking(game *unoGame) {
var sb strings.Builder
sb.WriteString(fmt.Sprintf("⚠️ **Stack incoming: +%d!**\nDiscard pile: %s\n\n**Your hand:**\n",
game.stackTotal, game.discardTop.DisplayWithColor(game.topColor)))
for i, c := range game.playerHand {
marker := ""
if c.canPlayOnStacking(game.topColor, game.stackMinValue) {
marker = " ✅"
}
sb.WriteString(fmt.Sprintf("%d. %s%s\n", i+1, c.Display(), marker))
}
sb.WriteString(fmt.Sprintf("\n%s has %d cards.", unoBotName(), len(game.botHand)))
sb.WriteString("\n\nPlay a draw card to stack, or type **accept** to draw the stack.")
p.SendMessage(game.dmRoomID, sb.String())
}
// playerTurnOrAutoDraw shows the hand if the player has playable cards,
// otherwise auto-draws and passes to the bot.
func (p *UnoPlugin) playerTurnOrAutoDraw(game *unoGame) error {
// No Mercy stacking: if a stack is pending, show stack prompt
if game.noMercy && game.stackMinValue > 0 {
if hasStackableCard(game.playerHand, game.topColor, game.stackMinValue) {
game.phase = unoPhasePlay
p.sendHandDisplayStacking(game)
return nil
}
// Player must absorb the stack
drawn := game.draw(game.stackTotal)
game.playerHand = append(game.playerHand, drawn...)
p.SendMessage(game.dmRoomID, fmt.Sprintf("💥 No stackable card! You draw %d cards.\n%s",
game.stackTotal, pickNoMercyCommentary("stack_absorbed")))
game.stackTotal = 0
game.stackMinValue = 0
if p.checkSoloMercyElimination(game) {
return nil
}
// After absorbing, bot's turn (player was skipped)
return p.botTurn(game)
}
if game.hasPlayable() {
game.phase = unoPhasePlay
p.sendHandDisplay(game)
return nil
}
// No playable cards — try to draw
// 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 {
// Deck empty + no playable cards — show hand, let player try manually
p.SendMessage(game.dmRoomID, "No playable cards and deck is empty.")
game.phase = unoPhasePlay
p.sendHandDisplay(game)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,614 @@
package plugin
import (
"fmt"
"math/rand/v2"
"strings"
)
// ---------------------------------------------------------------------------
// No Mercy helpers
// ---------------------------------------------------------------------------
// cardDrawValue returns the draw penalty for a card (0 if not a draw card).
func cardDrawValue(v unoValue) int {
switch v {
case unoDrawTwo:
return 2
case unoDrawFour, unoWildReverseDraw4:
return 4
case unoWildDrawSix:
return 6
case unoWildDrawTen:
return 10
default:
return 0
}
}
func isDrawCard(v unoValue) bool {
return cardDrawValue(v) > 0
}
// canPlayOnStacking checks if a card can be played during a stacking phase.
// Only draw cards with value >= stackMinValue are allowed.
// Wild draws always match; colored draws must match topColor.
func (c unoCard) canPlayOnStacking(topColor unoColor, stackMinValue int) bool {
dv := cardDrawValue(c.Value)
if dv < stackMinValue {
return false
}
if c.isWild() {
return true
}
return c.Color == topColor
}
// hasStackableCard returns true if the hand contains a card that can be stacked.
func hasStackableCard(hand []unoCard, topColor unoColor, stackMinValue int) bool {
for _, c := range hand {
if c.canPlayOnStacking(topColor, stackMinValue) {
return true
}
}
return false
}
// ---------------------------------------------------------------------------
// No Mercy deck (168 cards)
// ---------------------------------------------------------------------------
func newNoMercyDeck() []unoCard {
var cards []unoCard
colors := []unoColor{unoRed, unoBlue, unoYellow, unoGreen}
for _, color := range colors {
// Numbers 0-9: ×2 each
for v := unoZero; v <= unoNine; v++ {
cards = append(cards, unoCard{color, v})
cards = append(cards, unoCard{color, v})
}
// Skip ×3
for i := 0; i < 3; i++ {
cards = append(cards, unoCard{color, unoSkip})
}
// Skip Everyone ×2
for i := 0; i < 2; i++ {
cards = append(cards, unoCard{color, unoSkipEveryone})
}
// Reverse ×4
for i := 0; i < 4; i++ {
cards = append(cards, unoCard{color, unoReverse})
}
// Draw Two ×2
for i := 0; i < 2; i++ {
cards = append(cards, unoCard{color, unoDrawTwo})
}
// Draw Four (colored) ×2
for i := 0; i < 2; i++ {
cards = append(cards, unoCard{color, unoDrawFour})
}
// Discard All ×3
for i := 0; i < 3; i++ {
cards = append(cards, unoCard{color, unoDiscardAll})
}
}
// Wild Reverse Draw Four ×8
for i := 0; i < 8; i++ {
cards = append(cards, unoCard{unoWild, unoWildReverseDraw4})
}
// Wild Draw Six ×4
for i := 0; i < 4; i++ {
cards = append(cards, unoCard{unoWild, unoWildDrawSix})
}
// Wild Draw Ten ×4
for i := 0; i < 4; i++ {
cards = append(cards, unoCard{unoWild, unoWildDrawTen})
}
// Wild Color Roulette ×8
for i := 0; i < 8; i++ {
cards = append(cards, unoCard{unoWild, unoWildColorRoulette})
}
rand.Shuffle(len(cards), func(i, j int) { cards[i], cards[j] = cards[j], cards[i] })
return cards
}
// ---------------------------------------------------------------------------
// Mercy Rule
// ---------------------------------------------------------------------------
const mercyLimit = 25
// checkSoloMercyElimination checks if the player or bot has 25+ cards.
// Returns true if someone was eliminated (game ends).
func (p *UnoPlugin) checkSoloMercyElimination(game *unoGame) bool {
if !game.noMercy {
return false
}
if len(game.playerHand) >= mercyLimit {
p.SendMessage(game.dmRoomID,
fmt.Sprintf("💀 **MERCY KILL!** You have %d cards — eliminated!", len(game.playerHand)))
p.botWins(game)
return true
}
if len(game.botHand) >= mercyLimit {
bn := unoBotName()
p.SendMessage(game.dmRoomID,
fmt.Sprintf("💀 **MERCY KILL!** %s has %d cards — eliminated!", bn, len(game.botHand)))
p.playerWins(game)
return true
}
return false
}
// checkMultiMercyElimination checks if a multiplayer player has 25+ cards.
// Returns true if the player was eliminated.
func (p *UnoPlugin) checkMultiMercyElimination(game *unoMultiGame, player *unoMultiPlayer) bool {
if !game.noMercy || len(player.hand) < mercyLimit {
return false
}
player.active = false
name := p.unoDisplayName(player.userID)
if player.isBot {
name = unoBotName()
}
// Shuffle cards back into draw pile
game.drawPile = append(game.drawPile, player.hand...)
rand.Shuffle(len(game.drawPile), func(i, j int) { game.drawPile[i], game.drawPile[j] = game.drawPile[j], game.drawPile[i] })
player.hand = nil
p.SendMessage(game.roomID,
fmt.Sprintf("💀 **MERCY KILL!** %s had %d+ cards — eliminated!\n\n%s",
name, mercyLimit, pickNoMercyCommentary("mercy_kill")))
if !player.isBot {
p.SendMessage(player.dmRoomID,
fmt.Sprintf("💀 You've been mercy-killed! (%d+ cards)", mercyLimit))
}
// Check if game should end
active := game.activePlayers()
if len(active) <= 1 {
if len(active) == 1 {
winner := active[0]
if winner.isBot {
p.multiBotWins(game)
} else {
p.multiPlayerWins(game, winner)
}
} else {
game.done = true
p.SendMessage(game.roomID, "🃏 Game ended — no players remaining.")
p.cleanupMultiGame(game)
}
return true
}
return true
}
// ---------------------------------------------------------------------------
// Draw-until-playable helpers
// ---------------------------------------------------------------------------
// formatDrawnCards formats a list of drawn cards for display.
func formatDrawnCards(cards []unoCard) string {
if len(cards) == 1 {
return cards[0].Display()
}
var parts []string
for _, c := range cards {
parts = append(parts, c.Display())
}
return strings.Join(parts, ", ")
}
// ---------------------------------------------------------------------------
// Color Roulette
// ---------------------------------------------------------------------------
// executeColorRoulette flips cards from draw pile until the chosen color appears.
// All flipped cards are added to the target's hand.
// Returns the flipped cards for display.
func (p *UnoPlugin) executeColorRouletteSolo(game *unoGame, targetIsPlayer bool, chosenColor unoColor) []unoCard {
var flipped []unoCard
for {
if len(game.drawPile) == 0 {
game.reshuffleDiscard()
}
if len(game.drawPile) == 0 {
break
}
card := game.drawPile[0]
game.drawPile = game.drawPile[1:]
flipped = append(flipped, card)
if targetIsPlayer {
game.playerHand = append(game.playerHand, card)
} else {
game.botHand = append(game.botHand, card)
}
if card.Color == chosenColor {
break
}
}
return flipped
}
func (p *UnoPlugin) executeColorRouletteMulti(game *unoMultiGame, target *unoMultiPlayer, chosenColor unoColor) []unoCard {
var flipped []unoCard
for {
if len(game.drawPile) == 0 {
game.reshuffleDiscard()
}
if len(game.drawPile) == 0 {
break
}
card := game.drawPile[0]
game.drawPile = game.drawPile[1:]
flipped = append(flipped, card)
target.hand = append(target.hand, card)
if card.Color == chosenColor {
break
}
}
return flipped
}
// ---------------------------------------------------------------------------
// 7-0 Rule helpers
// ---------------------------------------------------------------------------
// rotateHandsMulti rotates all active players' hands in the play direction.
func rotateHandsMulti(game *unoMultiGame) {
active := game.activePlayers()
if len(active) < 2 {
return
}
// Build ordered list of active players by turn order in current direction
n := len(game.players)
var ordered []*unoMultiPlayer
idx := game.currentIdx
for i := 0; i < n; i++ {
p := game.players[idx]
if p.active {
ordered = append(ordered, p)
}
idx = (idx + game.direction + n) % n
}
if len(ordered) < 2 {
return
}
// Save all hands
hands := make([][]unoCard, len(ordered))
for i, p := range ordered {
hands[i] = p.hand
}
// Rotate: each player gets the hand of the player behind them (opposite of direction)
for i := range ordered {
prev := (i - 1 + len(ordered)) % len(ordered)
ordered[i].hand = hands[prev]
}
}
// swapHandsSolo swaps player and bot hands.
func swapHandsSolo(game *unoGame) {
game.playerHand, game.botHand = game.botHand, game.playerHand
}
// swapHandsMulti swaps two players' hands.
func swapHandsMulti(a, b *unoMultiPlayer) {
a.hand, b.hand = b.hand, a.hand
}
// ---------------------------------------------------------------------------
// Discard All helper
// ---------------------------------------------------------------------------
// discardAllOfColor removes all cards of the given color from the hand.
// Returns the count of additional cards removed (not counting the played card).
func discardAllOfColor(hand *[]unoCard, color unoColor) int {
var kept []unoCard
removed := 0
for _, c := range *hand {
if c.Color == color {
removed++
} else {
kept = append(kept, c)
}
}
*hand = kept
return removed
}
// ---------------------------------------------------------------------------
// No Mercy Bot AI
// ---------------------------------------------------------------------------
// botPickCardNoMercy selects the best card for bot in No Mercy mode.
func botPickCardNoMercy(hand []unoCard, discardTop unoCard, topColor unoColor, bookDown bool, opponentMinCards int, stackMinValue int) (unoCard, int) {
// During stacking, only stackable cards are valid
if stackMinValue > 0 {
return botPickStackCard(hand, topColor, stackMinValue)
}
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 botPickAggressiveNoMercy(hand, topColor, playable)
}
return botPickNormalNoMercy(hand, topColor, playable, opponentMinCards)
}
func botPickStackCard(hand []unoCard, topColor unoColor, stackMinValue int) (unoCard, int) {
var stackable []int
for i, c := range hand {
if c.canPlayOnStacking(topColor, stackMinValue) {
stackable = append(stackable, i)
}
}
if len(stackable) == 0 {
return unoCard{}, -1 // bot must absorb
}
// Play the lowest-value stackable card to preserve bigger weapons
bestIdx := stackable[0]
bestVal := cardDrawValue(hand[bestIdx].Value)
for _, i := range stackable[1:] {
dv := cardDrawValue(hand[i].Value)
if dv < bestVal {
bestIdx = i
bestVal = dv
}
}
return hand[bestIdx], bestIdx
}
func botPickNormalNoMercy(hand []unoCard, topColor unoColor, playable []int, opponentMinCards int) (unoCard, int) {
var draws, actions, numbers, discardAlls, skipEveryones, roulettes []int
for _, i := range playable {
c := hand[i]
switch {
case isDrawCard(c.Value):
draws = append(draws, i)
case c.Value == unoDiscardAll:
discardAlls = append(discardAlls, i)
case c.Value == unoSkipEveryone:
skipEveryones = append(skipEveryones, i)
case c.Value == unoWildColorRoulette:
roulettes = append(roulettes, i)
case c.Value.isAction():
actions = append(actions, i)
default:
numbers = append(numbers, i)
}
}
// Prioritize Discard All if we have 3+ cards of that color
for _, i := range discardAlls {
color := hand[i].Color
count := 0
for _, c := range hand {
if c.Color == color {
count++
}
}
if count >= 3 {
return hand[i], i
}
}
// If opponent close to winning, go aggressive with draw cards
if opponentMinCards <= 3 {
if len(draws) > 0 {
// Play highest draw value
bestIdx := draws[0]
bestVal := cardDrawValue(hand[bestIdx].Value)
for _, i := range draws[1:] {
dv := cardDrawValue(hand[i].Value)
if dv > bestVal {
bestIdx = i
bestVal = dv
}
}
return hand[bestIdx], bestIdx
}
if len(skipEveryones) > 0 {
return hand[skipEveryones[0]], skipEveryones[0]
}
}
// Normal priority: numbers > actions > discard all > skip everyone > draws > roulettes
if len(numbers) > 0 {
// Prefer color match
for _, i := range numbers {
if hand[i].Color == topColor {
return hand[i], i
}
}
return hand[numbers[0]], numbers[0]
}
if len(actions) > 0 {
return hand[actions[0]], actions[0]
}
if len(discardAlls) > 0 {
return hand[discardAlls[0]], discardAlls[0]
}
if len(skipEveryones) > 0 {
return hand[skipEveryones[0]], skipEveryones[0]
}
if len(draws) > 0 {
// Play lowest draw to save bigger ones
bestIdx := draws[0]
bestVal := cardDrawValue(hand[bestIdx].Value)
for _, i := range draws[1:] {
dv := cardDrawValue(hand[i].Value)
if dv < bestVal {
bestIdx = i
bestVal = dv
}
}
return hand[bestIdx], bestIdx
}
if len(roulettes) > 0 {
return hand[roulettes[0]], roulettes[0]
}
return hand[playable[0]], playable[0]
}
func botPickAggressiveNoMercy(hand []unoCard, topColor unoColor, playable []int) (unoCard, int) {
var draws, actions, others []int
for _, i := range playable {
c := hand[i]
switch {
case isDrawCard(c.Value):
draws = append(draws, i)
case c.Value.isAction():
actions = append(actions, i)
default:
others = append(others, i)
}
}
// Aggressive: play highest draw card first
if len(draws) > 0 {
bestIdx := draws[0]
bestVal := cardDrawValue(hand[bestIdx].Value)
for _, i := range draws[1:] {
dv := cardDrawValue(hand[i].Value)
if dv > bestVal {
bestIdx = i
bestVal = dv
}
}
return hand[bestIdx], bestIdx
}
if len(actions) > 0 {
return hand[actions[0]], actions[0]
}
if len(others) > 0 {
return hand[others[0]], others[0]
}
return hand[playable[0]], playable[0]
}
// botChooseSwapTarget picks the player with the fewest cards (to steal a small hand).
func botChooseSwapTarget(game *unoMultiGame, bot *unoMultiPlayer) *unoMultiPlayer {
var best *unoMultiPlayer
bestCards := 999
for _, p := range game.players {
if p == bot || !p.active {
continue
}
if len(p.hand) < bestCards {
bestCards = len(p.hand)
best = p
}
}
return best
}
// botRouletteColor picks a color the bot has least of (to maximize damage).
func botRouletteColor(hand []unoCard) unoColor {
counts := map[unoColor]int{}
for _, c := range hand {
if c.Color != unoWild {
counts[c.Color]++
}
}
// Pick color with fewest cards (opponent likely has fewer too)
best := unoRed
bestCount := 999
for _, color := range []unoColor{unoRed, unoBlue, unoYellow, unoGreen} {
if counts[color] < bestCount {
bestCount = counts[color]
best = color
}
}
return best
}
// ---------------------------------------------------------------------------
// No Mercy commentary
// ---------------------------------------------------------------------------
var noMercyCommentary = map[string][]string{
"nomercy_start": {
"*GogoBee sets the book down. Not gently.*\n\n\"No mercy? Fine. 💛\"\n\n*deals cards with unsettling precision*",
"*GogoBee marks the page, closes the book with a snap.*\n\n\"Oh, you want to play *that* version. Okay. 💛\"\n\n*shuffles 168 cards without breaking eye contact*",
},
"mercy_kill": {
"\"Sometimes the kindest thing is the quickest. 💛\" *doesn't look up*",
"\"That's what mercy looks like. 💛\"",
"\"...and that's why they call it No Mercy. 💛\"",
},
"stack_absorbed": {
"\"That's a lot of cards. 💛\" *turns a page*",
"\"Ouch. 💛\"",
},
"color_roulette": {
"\"Let's see what fate has in store. 💛\"",
"\"Flip, flip, flip... 💛\" *watches with mild interest*",
},
"discard_all": {
"\"Oh, that's efficient. 💛\"",
},
"skip_everyone": {
"\"Nobody gets a turn. How fun. 💛\"",
},
"hand_swap": {
"\"Musical chairs, card edition. 💛\"",
"\"Surprise. 💛\"",
},
"hand_rotate": {
"\"Everyone pass your cards. Yes, all of them. 💛\"",
},
}
func pickNoMercyCommentary(key string) string {
lines := noMercyCommentary[key]
if len(lines) == 0 {
return ""
}
line := lines[rand.IntN(len(lines))]
return strings.ReplaceAll(line, "GogoBee", unoBotName())
}
// ---------------------------------------------------------------------------
// No Mercy mode flags parser
// ---------------------------------------------------------------------------
// parseNoMercyFlags parses "nomercy [7-0] €amount" and returns noMercy, sevenZeroRule, and remaining amount string.
func parseNoMercyFlags(args string) (noMercy bool, sevenZeroRule bool, amountStr string) {
lower := strings.ToLower(strings.TrimSpace(args))
if !strings.HasPrefix(lower, "nomercy") {
return false, false, args
}
rest := strings.TrimSpace(args[7:]) // len("nomercy") == 7
lowerRest := strings.ToLower(rest)
if strings.HasPrefix(lowerRest, "7-0") {
return true, true, strings.TrimSpace(rest[3:])
}
return true, false, rest
}