From 944603864644cc84d12541a484e2138fb552b95c Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Sat, 21 Mar 2026 19:37:41 -0700 Subject: [PATCH] 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 --- internal/plugin/blackjack.go | 92 +++- internal/plugin/uno.go | 619 +++++++++++++++++++-- internal/plugin/uno_multi.go | 949 ++++++++++++++++++++++++++++++--- internal/plugin/uno_nomercy.go | 614 +++++++++++++++++++++ 4 files changed, 2118 insertions(+), 156 deletions(-) create mode 100644 internal/plugin/uno_nomercy.go diff --git a/internal/plugin/blackjack.go b/internal/plugin/blackjack.go index 4bd13f7..1ec1d28 100644 --- a/internal/plugin/blackjack.go +++ b/internal/plugin/blackjack.go @@ -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) - _ = 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() + 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)) } - p.startRound(ctx.RoomID, table) 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()} } // --------------------------------------------------------------------------- diff --git a/internal/plugin/uno.go b/internal/plugin/uno.go index 07c417b..a8bcb24 100644 --- a/internal/plugin/uno.go +++ b/internal/plugin/uno.go @@ -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 - deck = newUnoDeck() + if noMercy { + deck = newNoMercyDeck() + } else { + deck = newUnoDeck() + } copy(playerHand, deck[:7]) copy(botHand, deck[7:14]) startCard = deck[14] @@ -593,17 +655,19 @@ func (p *UnoPlugin) initGame(playerID id.UserID, roomID, dmRoom id.RoomID, wager } 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(), + 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, } } @@ -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) diff --git a/internal/plugin/uno_multi.go b/internal/plugin/uno_multi.go index e0df5fb..ab3ff56 100644 --- a/internal/plugin/uno_multi.go +++ b/internal/plugin/uno_multi.go @@ -20,10 +20,11 @@ import ( type unoMultiPhase int const ( - unoMultiPhasePlay unoMultiPhase = iota - unoMultiPhaseChooseColor // active player must pick a color - unoMultiPhaseDrawnPlayable // active player drew a playable card, yes/no - unoMultiPhaseChallenge // next player may challenge a Wild Draw Four + unoMultiPhasePlay unoMultiPhase = iota + unoMultiPhaseChooseColor // active player must pick a color + unoMultiPhaseDrawnPlayable // active player drew a playable card, yes/no + unoMultiPhaseChallenge // next player may challenge a Wild Draw Four + unoMultiPhaseChooseSwapTarget // No Mercy: player played a 7, must choose swap target ) type unoMultiPlayer struct { @@ -60,18 +61,26 @@ type unoMultiGame struct { done bool bookDown bool + // No Mercy mode + noMercy bool + sevenZeroRule bool + stackTotal int // cumulative draw penalty during stacking + stackMinValue int // minimum draw value to stack (0 = not stacking) + timer *time.Timer inactiveTimer *time.Timer // 10-minute game timeout mu sync.Mutex // per-game lock } type unoMultiLobby struct { - roomID id.RoomID - creator id.UserID - ante float64 - players []id.UserID - createdAt time.Time - timer *time.Timer + roomID id.RoomID + creator id.UserID + ante float64 + players []id.UserID + createdAt time.Time + timer *time.Timer + noMercy bool + sevenZeroRule bool } // --------------------------------------------------------------------------- @@ -149,7 +158,12 @@ func (g *unoMultiGame) draw(n int) []unoCard { } func (g *unoMultiGame) reshuffleDiscard() { - fresh := newUnoDeck() + var fresh []unoCard + if g.noMercy { + fresh = newNoMercyDeck() + } else { + fresh = newUnoDeck() + } inPlay := make(map[unoCard]int) for _, p := range g.players { for _, c := range p.hand { @@ -199,7 +213,7 @@ func (g *unoMultiGame) updateBookState() bool { // Lobby commands // --------------------------------------------------------------------------- -func (p *UnoPlugin) handleMultiStart(ctx MessageContext, amountStr string) error { +func (p *UnoPlugin) handleMultiStart(ctx MessageContext, amountStr string, noMercy, sevenZeroRule bool) error { if !isGamesRoom(ctx.RoomID) { return p.SendReply(ctx.RoomID, ctx.EventID, "Multiplayer Uno can only be started in the games channel!") } @@ -249,11 +263,13 @@ func (p *UnoPlugin) handleMultiStart(ctx MessageContext, amountStr string) error timeout := envInt("UNO_MULTI_LOBBY_TIMEOUT", 300) lobby := &unoMultiLobby{ - roomID: ctx.RoomID, - creator: ctx.Sender, - ante: amount, - players: []id.UserID{ctx.Sender}, - createdAt: time.Now(), + roomID: ctx.RoomID, + creator: ctx.Sender, + ante: amount, + players: []id.UserID{ctx.Sender}, + createdAt: time.Now(), + noMercy: noMercy, + sevenZeroRule: sevenZeroRule, } lobby.timer = time.AfterFunc(time.Duration(timeout)*time.Second, func() { @@ -264,9 +280,16 @@ func (p *UnoPlugin) handleMultiStart(ctx MessageContext, amountStr string) error p.mu.Unlock() creatorName := p.unoDisplayName(ctx.Sender) + modeTag := "" + if noMercy { + modeTag = " ๐Ÿ”ฅ NO MERCY" + if sevenZeroRule { + modeTag += " (7-0)" + } + } return p.SendMessage(ctx.RoomID, fmt.Sprintf( - "๐Ÿƒ **UNO Lobby** โ€” Ante: โ‚ฌ%d\nPlayers (1/4):\n 1. %s (host)\n\nType `!uno join` to join or `!uno go` to start!", - int(amount), creatorName)) + "๐Ÿƒ **UNO Lobby**%s โ€” Ante: โ‚ฌ%d\nPlayers (1/4):\n 1. %s (host)\n\nType `!uno join` to join or `!uno go` to start!", + modeTag, int(amount), creatorName)) } func (p *UnoPlugin) handleMultiJoin(ctx MessageContext) error { @@ -322,7 +345,14 @@ func (p *UnoPlugin) handleMultiJoin(ctx MessageContext) error { // Build player list var sb strings.Builder - sb.WriteString(fmt.Sprintf("๐Ÿƒ **UNO Lobby** โ€” Ante: โ‚ฌ%d\nPlayers (%d/4):\n", int(lobby.ante), count)) + lobbyModeTag := "" + if lobby.noMercy { + lobbyModeTag = " ๐Ÿ”ฅ NO MERCY" + if lobby.sevenZeroRule { + lobbyModeTag += " (7-0)" + } + } + sb.WriteString(fmt.Sprintf("๐Ÿƒ **UNO Lobby**%s โ€” Ante: โ‚ฌ%d\nPlayers (%d/4):\n", lobbyModeTag, int(lobby.ante), count)) for i, uid := range lobby.players { name := p.unoDisplayName(uid) label := "" @@ -444,6 +474,8 @@ func (p *UnoPlugin) handleMultiGo(ctx MessageContext) error { players := lobby.players ante := lobby.ante roomID := lobby.roomID + noMercy := lobby.noMercy + sevenZeroRule := lobby.sevenZeroRule delete(p.lobbies, ctx.RoomID) p.mu.Unlock() @@ -463,7 +495,7 @@ func (p *UnoPlugin) handleMultiGo(ctx MessageContext) error { } // Build game - game := p.initMultiGame(resolved, roomID, ante) + game := p.initMultiGame(resolved, roomID, ante, noMercy, sevenZeroRule) p.mu.Lock() p.multiGames[game.id] = game @@ -477,7 +509,14 @@ func (p *UnoPlugin) handleMultiGo(ctx MessageContext) error { // Announce var sb strings.Builder bn := unoBotName() - sb.WriteString(fmt.Sprintf("๐Ÿƒ **Multiplayer UNO!** Pot: โ‚ฌ%d\n\nPlayers:\n", int(ante)*len(players))) + modeTag := "" + if noMercy { + modeTag = " ๐Ÿ”ฅ NO MERCY" + if sevenZeroRule { + modeTag += " (7-0)" + } + } + sb.WriteString(fmt.Sprintf("๐Ÿƒ **Multiplayer UNO!**%s Pot: โ‚ฌ%d\n\nPlayers:\n", modeTag, int(ante)*len(players))) for i, pl := range game.players { name := p.unoDisplayName(pl.userID) if pl.isBot { @@ -489,8 +528,12 @@ func (p *UnoPlugin) handleMultiGo(ctx MessageContext) error { } sb.WriteString(fmt.Sprintf(" %d. %s%s\n", i+1, name, marker)) } + startComment := pickCommentary("start") + if noMercy { + startComment = pickNoMercyCommentary("nomercy_start") + } sb.WriteString(fmt.Sprintf("\nStarting card: %s\n%s\n\n[Check your DMs!]", - game.discardTop.DisplayWithColor(game.topColor), pickCommentary("start"))) + game.discardTop.DisplayWithColor(game.topColor), startComment)) p.SendMessage(roomID, sb.String()) // Start first turn @@ -511,8 +554,13 @@ type playerDMPair struct { dmRoomID id.RoomID } -func (p *UnoPlugin) initMultiGame(players []playerDMPair, roomID id.RoomID, ante float64) *unoMultiGame { - deck := newUnoDeck() +func (p *UnoPlugin) initMultiGame(players []playerDMPair, roomID id.RoomID, ante float64, noMercy, sevenZeroRule bool) *unoMultiGame { + var deck []unoCard + if noMercy { + deck = newNoMercyDeck() + } else { + deck = newUnoDeck() + } cardsPerPlayer := 7 cardIdx := 0 @@ -548,11 +596,11 @@ func (p *UnoPlugin) initMultiGame(players []playerDMPair, roomID id.RoomID, ante remaining := make([]unoCard, len(deck)-cardIdx) copy(remaining, deck[cardIdx:]) - // Starting card โ€” must not be Wild + // Starting card โ€” must be a number card 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 @@ -568,19 +616,21 @@ func (p *UnoPlugin) initMultiGame(players []playerDMPair, roomID id.RoomID, ante gameID := fmt.Sprintf("multi_%d", time.Now().UnixNano()) return &unoMultiGame{ - id: gameID, - roomID: roomID, - ante: ante, - players: unshuffled, - currentIdx: 0, - direction: 1, - drawPile: remaining, - discardTop: startCard, - topColor: startCard.Color, - phase: unoMultiPhasePlay, - turns: 0, - turnID: 0, - startedAt: time.Now(), + id: gameID, + roomID: roomID, + ante: ante, + players: unshuffled, + currentIdx: 0, + direction: 1, + drawPile: remaining, + discardTop: startCard, + topColor: startCard.Color, + phase: unoMultiPhasePlay, + turns: 0, + turnID: 0, + startedAt: time.Now(), + noMercy: noMercy, + sevenZeroRule: sevenZeroRule, } } @@ -623,11 +673,97 @@ func (p *UnoPlugin) executeMultiTurn(game *unoMultiGame) { roomBuf.Reset() } + // No Mercy stacking: check if player must absorb + if game.noMercy && game.stackMinValue > 0 { + if !hasStackableCard(player.hand, game.topColor, game.stackMinValue) { + name := p.unoDisplayName(player.userID) + drawn := game.draw(game.stackTotal) + player.hand = append(player.hand, drawn...) + p.SendMessage(player.dmRoomID, fmt.Sprintf("๐Ÿ’ฅ No stackable card! You draw %d cards.\n%s", + game.stackTotal, pickNoMercyCommentary("stack_absorbed"))) + p.SendMessage(game.roomID, fmt.Sprintf("๐Ÿ’ฅ %s absorbs the stack! Draws %d cards. (%d cards now)", + name, game.stackTotal, len(player.hand))) + game.stackTotal = 0 + game.stackMinValue = 0 + if p.checkMultiMercyElimination(game, player) { + if game.done { + return + } + game.currentIdx = game.nextActiveIdx() + game.turnID++ + continue + } + // Skip this player's turn + game.currentIdx = game.nextActiveIdx() + game.turnID++ + continue + } + // Player has stackable cards โ€” show stacking prompt + game.phase = unoMultiPhasePlay + p.sendMultiHandDisplayStacking(game, player) + p.startMultiAutoPlayTimer(game) + return + } + // Auto-draw if no playable cards if !game.hasPlayable(player.hand) { + if game.noMercy { + // Draw until playable + var allDrawn []unoCard + var playableCard *unoCard + for { + cards := game.draw(1) + if len(cards) == 0 { + break + } + card := cards[0] + player.hand = append(player.hand, card) + allDrawn = append(allDrawn, card) + if p.checkMultiMercyElimination(game, player) { + if game.done { + return + } + game.currentIdx = game.nextActiveIdx() + game.turnID++ + break + } + if card.canPlayOn(game.discardTop, game.topColor) { + playableCard = &card + break + } + } + if !player.active { + continue // mercy-killed + } + name := p.unoDisplayName(player.userID) + if len(allDrawn) == 0 { + p.SendMessage(game.roomID, fmt.Sprintf("๐Ÿƒ %s has no playable cards and deck is empty. Turn passes. (%d cards)", name, len(player.hand))) + p.SendMessage(player.dmRoomID, "No playable cards and deck is empty. Turn passes.") + game.currentIdx = game.nextActiveIdx() + game.turnID++ + continue + } + if playableCard != nil { + game.drawnCard = playableCard + game.phase = unoMultiPhaseDrawnPlayable + p.SendMessage(player.dmRoomID, + fmt.Sprintf("No playable cards โ€” drew %d card(s): %s\nLast card is playable! Play it? (**yes** / **no**)", + len(allDrawn), formatDrawnCards(allDrawn))) + p.SendMessage(game.roomID, fmt.Sprintf("๐Ÿƒ %s draws %d card(s). (%d cards)", name, len(allDrawn), len(player.hand))) + p.startMultiAutoPlayTimer(game) + return + } + p.SendMessage(player.dmRoomID, + fmt.Sprintf("No playable cards โ€” drew %d card(s). None playable. Turn passes.", len(allDrawn))) + p.SendMessage(game.roomID, fmt.Sprintf("๐Ÿƒ %s draws %d card(s). Turn passes. (%d cards)", name, len(allDrawn), len(player.hand))) + game.currentIdx = game.nextActiveIdx() + game.turnID++ + continue + } + + // Classic: draw 1 drawn := game.draw(1) if len(drawn) == 0 { - // Empty deck, no playable cards โ€” pass name := p.unoDisplayName(player.userID) p.SendMessage(game.roomID, fmt.Sprintf("๐Ÿƒ %s has no playable cards and deck is empty. Turn passes. (%d cards)", name, len(player.hand))) p.SendMessage(player.dmRoomID, "No playable cards and deck is empty. Turn passes.") @@ -732,19 +868,52 @@ func (p *UnoPlugin) handleMultiDMInput(ctx MessageContext, game *unoMultiGame) e case unoMultiPhaseDrawnPlayable: return p.handleMultiDrawnPlayable(game, player, input) + case unoMultiPhaseChooseSwapTarget: + return p.handleMultiSwapChoice(game, player, input) + case unoMultiPhasePlay: if input == "uno" { player.calledUno = true p.SendMessage(player.dmRoomID, "โœ… UNO called!") return nil } + // No Mercy stacking: accept + if game.noMercy && game.stackMinValue > 0 && (input == "accept" || input == "a") { + name := p.unoDisplayName(player.userID) + drawn := game.draw(game.stackTotal) + player.hand = append(player.hand, drawn...) + p.SendMessage(player.dmRoomID, fmt.Sprintf("๐Ÿ’ฅ You accept the stack and draw %d cards.\n%s", + game.stackTotal, pickNoMercyCommentary("stack_absorbed"))) + p.SendMessage(game.roomID, fmt.Sprintf("๐Ÿ’ฅ %s absorbs the stack! Draws %d cards. (%d cards now)", + name, game.stackTotal, len(player.hand))) + game.stackTotal = 0 + game.stackMinValue = 0 + if p.checkMultiMercyElimination(game, player) { + if game.done { + return nil + } + p.advanceAndExecute(game) + return nil + } + p.advanceAndExecute(game) + return nil + } if input == "draw" { + if game.noMercy && game.stackMinValue > 0 { + p.SendMessage(player.dmRoomID, "You must play a draw card to stack, or type **accept** to draw the stack.") + return nil + } return p.handleMultiPlayerDraw(game, player) } var cardIdx int if _, err := fmt.Sscanf(input, "%d", &cardIdx); err != nil || cardIdx < 1 || cardIdx > len(player.hand) { - p.SendMessage(player.dmRoomID, - fmt.Sprintf("Reply with a number (1-%d) to play, or **draw** to draw.", len(player.hand))) + if game.noMercy && game.stackMinValue > 0 { + p.SendMessage(player.dmRoomID, + fmt.Sprintf("Reply with a card number (1-%d) to stack, or **accept** to draw %d cards.", len(player.hand), game.stackTotal)) + } else { + p.SendMessage(player.dmRoomID, + fmt.Sprintf("Reply with a number (1-%d) to play, or **draw** to draw.", len(player.hand))) + } return nil } return p.handleMultiPlayerPlay(game, player, cardIdx-1) @@ -774,21 +943,37 @@ func (p *UnoPlugin) handleMultiChallengeInput(game *unoMultiGame, player *unoMul func (p *UnoPlugin) handleMultiPlayerPlay(game *unoMultiGame, player *unoMultiPlayer, idx int) error { card := player.hand[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) { + p.SendMessage(player.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)) + return nil + } + } else if !card.canPlayOn(game.discardTop, game.topColor) { p.SendMessage(player.dmRoomID, fmt.Sprintf("You can't play %s on %s.", card.Display(), game.discardTop.DisplayWithColor(game.topColor))) return nil } - // UNO penalty check โ€” had 2 cards, didn't call UNO - if len(player.hand) == 2 && !player.calledUno { + // UNO penalty check โ€” had 2 cards, didn't call UNO (skip during stacking) + if game.stackMinValue == 0 && len(player.hand) == 2 && !player.calledUno { drawn := game.draw(2) player.hand = append(player.hand, drawn...) player.calledUno = false p.SendMessage(player.dmRoomID, "โš ๏ธ You forgot to call UNO! Draw 2 as penalty.") name := p.unoDisplayName(player.userID) p.SendMessage(game.roomID, fmt.Sprintf("๐Ÿƒ %s forgot to call UNO! 2 card penalty.", name)) - // Re-show hand + if game.noMercy { + if p.checkMultiMercyElimination(game, player) { + if game.done { + return nil + } + p.advanceAndExecute(game) + return nil + } + } p.sendMultiHandDisplay(game, player) p.startMultiAutoPlayTimer(game) return nil @@ -804,8 +989,16 @@ func (p *UnoPlugin) handleMultiPlayerPlay(game *unoMultiGame, player *unoMultiPl player.calledUno = false } + // No Mercy: Discard All โ€” remove remaining cards of same color + if game.noMercy && card.Value == unoDiscardAll { + discarded := discardAllOfColor(&player.hand, card.Color) + if discarded > 0 { + p.SendMessage(player.dmRoomID, fmt.Sprintf("Discarded %d additional %s cards!", discarded, card.Color)) + } + } + // Wild โ€” need color choice - if card.Value == unoWildCard || card.Value == unoWildDrawFour { + if card.isWild() { game.pendingCard = &card if card.Value == unoWildDrawFour { game.wd4PrevColor = game.topColor @@ -819,7 +1012,61 @@ func (p *UnoPlugin) handleMultiPlayerPlay(game *unoMultiGame, player *unoMultiPl game.topColor = card.Color - // Check win + // No Mercy: 7-0 rule + if game.noMercy && game.sevenZeroRule { + if card.Value == unoSeven && len(game.activePlayers()) > 2 { + // Choose swap target + game.phase = unoMultiPhaseChooseSwapTarget + var sb strings.Builder + sb.WriteString("You played a **7**! Choose a player to swap hands with:\n") + i := 1 + for _, pl := range game.players { + if pl == player || !pl.active { + continue + } + name := p.unoDisplayName(pl.userID) + if pl.isBot { + name = unoBotName() + } + sb.WriteString(fmt.Sprintf("%d. %s (%d cards)\n", i, name, len(pl.hand))) + i++ + } + p.SendMessage(player.dmRoomID, sb.String()) + p.startMultiAutoPlayTimer(game) + return nil + } + if card.Value == unoSeven { + // 2-player: swap with the other player + for _, pl := range game.players { + if pl != player && pl.active { + swapHandsMulti(player, pl) + name := p.unoDisplayName(player.userID) + otherName := p.unoDisplayName(pl.userID) + if pl.isBot { + otherName = unoBotName() + } + p.SendMessage(game.roomID, fmt.Sprintf("๐Ÿ”„ %s swaps hands with %s! %s", + name, otherName, pickNoMercyCommentary("hand_swap"))) + if !pl.isBot { + p.SendMessage(pl.dmRoomID, fmt.Sprintf("๐Ÿ”„ %s swapped hands with you! You now have %d cards.", name, len(pl.hand))) + } + break + } + } + } + if card.Value == unoZero { + rotateHandsMulti(game) + p.SendMessage(game.roomID, fmt.Sprintf("๐Ÿ”„ Hands rotated! %s", pickNoMercyCommentary("hand_rotate"))) + // Notify all active human players about their new hand size + for _, pl := range game.players { + if pl.active && !pl.isBot { + p.SendMessage(pl.dmRoomID, fmt.Sprintf("๐Ÿ”„ Hands rotated! You now have %d cards.", len(pl.hand))) + } + } + } + } + + // Check win (after discard all, after swap) if len(player.hand) == 0 { name := p.unoDisplayName(player.userID) p.SendMessage(game.roomID, fmt.Sprintf("๐Ÿƒ %s plays: %s", name, card.Display())) @@ -864,12 +1111,49 @@ func (p *UnoPlugin) handleMultiColorChoice(game *unoMultiGame, player *unoMultiP return nil } + // No Mercy: Color Roulette โ€” next player flips until chosen color + if game.noMercy && pendingCard != nil && pendingCard.Value == unoWildColorRoulette { + name := p.unoDisplayName(player.userID) + nextIdx := game.nextActiveIdx() + target := game.players[nextIdx] + targetName := p.unoDisplayName(target.userID) + if target.isBot { + targetName = unoBotName() + } + + flipped := p.executeColorRouletteMulti(game, target, color) + p.SendMessage(game.roomID, fmt.Sprintf("๐Ÿƒ %s plays: %s (chose %s %s)\n๐ŸŽฐ Color Roulette! %s flips %d cards until finding %s.\n%s", + name, pendingCard.Display(), color.Emoji(), color, + targetName, len(flipped), color, pickNoMercyCommentary("color_roulette"))) + if !target.isBot { + p.SendMessage(target.dmRoomID, fmt.Sprintf("๐ŸŽฐ Color Roulette! You drew %d cards. You now have %d cards.", len(flipped), len(target.hand))) + } + + if p.checkMultiMercyElimination(game, target) { + if game.done { + return nil + } + } + + // Skip the target player, advance to next + game.currentIdx = nextIdx + game.currentIdx = game.nextActiveIdx() + game.turnID++ + p.executeMultiTurn(game) + return nil + } + // Apply effects p.applyAndAnnounce(game, player, *pendingCard) return nil } func (p *UnoPlugin) handleMultiPlayerDraw(game *unoMultiGame, player *unoMultiPlayer) error { + // No Mercy: draw until playable + if game.noMercy { + return p.handleMultiPlayerDrawNoMercy(game, player) + } + drawn := game.draw(1) if len(drawn) == 0 { p.SendMessage(player.dmRoomID, "No cards left to draw! Turn passes.") @@ -898,6 +1182,58 @@ func (p *UnoPlugin) handleMultiPlayerDraw(game *unoMultiGame, player *unoMultiPl return nil } +func (p *UnoPlugin) handleMultiPlayerDrawNoMercy(game *unoMultiGame, player *unoMultiPlayer) error { + var allDrawn []unoCard + var playableCard *unoCard + + for { + drawn := game.draw(1) + if len(drawn) == 0 { + break + } + card := drawn[0] + player.hand = append(player.hand, card) + allDrawn = append(allDrawn, card) + if p.checkMultiMercyElimination(game, player) { + if game.done { + return nil + } + p.advanceAndExecute(game) + return nil + } + if card.canPlayOn(game.discardTop, game.topColor) { + playableCard = &card + break + } + } + + name := p.unoDisplayName(player.userID) + + if len(allDrawn) == 0 { + p.SendMessage(player.dmRoomID, "No cards left to draw! Turn passes.") + p.SendMessage(game.roomID, fmt.Sprintf("๐Ÿƒ %s can't draw โ€” deck is empty. Turn passes.", name)) + p.advanceAndExecute(game) + return nil + } + + if playableCard != nil { + game.drawnCard = playableCard + game.phase = unoMultiPhaseDrawnPlayable + p.SendMessage(player.dmRoomID, + fmt.Sprintf("Drew %d card(s): %s\nLast card is playable! Play it? (**yes** / **no**)", + len(allDrawn), formatDrawnCards(allDrawn))) + p.SendMessage(game.roomID, fmt.Sprintf("๐Ÿƒ %s draws %d card(s). (%d cards)", name, len(allDrawn), len(player.hand))) + p.startMultiAutoPlayTimer(game) + return nil + } + + p.SendMessage(player.dmRoomID, + fmt.Sprintf("Drew %d card(s). None playable. Turn passes.", len(allDrawn))) + p.SendMessage(game.roomID, fmt.Sprintf("๐Ÿƒ %s draws %d card(s). Turn passes. (%d cards)", name, len(allDrawn), len(player.hand))) + p.advanceAndExecute(game) + return nil +} + func (p *UnoPlugin) handleMultiDrawnPlayable(game *unoMultiGame, player *unoMultiPlayer, input string) error { if input != "yes" && input != "y" && input != "no" && input != "n" { p.SendMessage(player.dmRoomID, "Play the drawn card? (**yes** / **no**)") @@ -930,7 +1266,7 @@ func (p *UnoPlugin) handleMultiDrawnPlayable(game *unoMultiGame, player *unoMult player.calledUno = false } - if drawnCard.Value == unoWildCard || drawnCard.Value == unoWildDrawFour { + if drawnCard.isWild() { game.pendingCard = &drawnCard if drawnCard.Value == unoWildDrawFour { game.wd4PrevColor = game.topColor @@ -965,6 +1301,7 @@ type cardEffectResult struct { reversed bool // true if direction was reversed drawnCount int // cards drawn by the victim (2 or 4) needsChallenge bool // true if WD4 challenge phase should start + stackPending bool // true if draw stacking is in progress (No Mercy) } // applyCardEffects applies skip/reverse/draw effects and advances the turn. @@ -986,6 +1323,30 @@ func (p *UnoPlugin) applyCardEffects(game *unoMultiGame, card unoCard) cardEffec game.currentIdx = game.nextActiveIdx() game.turnID++ + case unoSkipEveryone: + if game.noMercy { + // Skip all others โ€” current player goes again + var skippedNames []string + for _, pl := range game.players { + if pl != game.currentPlayer() && pl.active { + name := p.unoDisplayName(pl.userID) + if pl.isBot { + name = unoBotName() + } + skippedNames = append(skippedNames, name) + } + } + result.skippedName = strings.Join(skippedNames, ", ") + // Don't advance โ€” current player keeps their turn + game.turnID++ + } else { + // Treat as skip in classic + result.skippedName = nextName + game.currentIdx = nextIdx + game.currentIdx = game.nextActiveIdx() + game.turnID++ + } + case unoReverse: game.direction *= -1 result.reversed = true @@ -999,18 +1360,84 @@ func (p *UnoPlugin) applyCardEffects(game *unoMultiGame, card unoCard) cardEffec game.turnID++ } - case unoDrawTwo: - drawn := game.draw(2) - nextPlayer.hand = append(nextPlayer.hand, drawn...) - result.skippedName = nextName - result.drawnCount = 2 - game.currentIdx = nextIdx + case unoDrawTwo, unoDrawFour: + if game.noMercy { + // No Mercy: stacking + dv := cardDrawValue(card.Value) + game.stackTotal += dv + game.stackMinValue = dv + result.stackPending = true + result.skippedName = nextName + result.drawnCount = game.stackTotal + game.currentIdx = game.nextActiveIdx() + game.turnID++ + } else if card.Value == unoDrawTwo { + // Classic Draw Two + drawn := game.draw(2) + nextPlayer.hand = append(nextPlayer.hand, drawn...) + result.skippedName = nextName + result.drawnCount = 2 + game.currentIdx = nextIdx + game.currentIdx = game.nextActiveIdx() + game.turnID++ + } else { + // DrawFour shouldn't appear in classic, but handle gracefully + game.currentIdx = game.nextActiveIdx() + game.turnID++ + } + + case unoWildDrawFour: + if game.noMercy { + // Should not appear in No Mercy deck, but handle gracefully + game.currentIdx = game.nextActiveIdx() + game.turnID++ + } else { + result.needsChallenge = true + result.skippedName = nextName + } + + case unoWildReverseDraw4: + if game.noMercy { + game.direction *= -1 + result.reversed = true + dv := cardDrawValue(card.Value) + game.stackTotal += dv + game.stackMinValue = dv + result.stackPending = true + // After reverse, get next player in new direction + nextIdx = game.nextActiveIdx() + nextPlayer = game.players[nextIdx] + nextName = p.unoDisplayName(nextPlayer.userID) + if nextPlayer.isBot { + nextName = unoBotName() + } + result.skippedName = nextName + result.drawnCount = game.stackTotal + game.currentIdx = game.nextActiveIdx() + game.turnID++ + } + + case unoWildDrawSix, unoWildDrawTen: + if game.noMercy { + dv := cardDrawValue(card.Value) + game.stackTotal += dv + game.stackMinValue = dv + result.stackPending = true + result.skippedName = nextName + result.drawnCount = game.stackTotal + game.currentIdx = game.nextActiveIdx() + game.turnID++ + } + + case unoWildColorRoulette: + // Color roulette effect is handled in color choice handler, not here game.currentIdx = game.nextActiveIdx() game.turnID++ - case unoWildDrawFour: - result.needsChallenge = true - result.skippedName = nextName + case unoDiscardAll: + // Discard effect already applied before calling this function + game.currentIdx = game.nextActiveIdx() + game.turnID++ default: game.currentIdx = game.nextActiveIdx() @@ -1024,6 +1451,11 @@ func (p *UnoPlugin) applyCardEffects(game *unoMultiGame, card unoCard) cardEffec func writeEffectLines(sb *strings.Builder, eff cardEffectResult) { if eff.needsChallenge { sb.WriteString(fmt.Sprintf("\n %s may challenge! โšก", eff.skippedName)) + } else if eff.stackPending { + sb.WriteString(fmt.Sprintf("\n ๐Ÿ”ฅ Stack incoming! (+%d) โ€” %s must stack or absorb!", eff.drawnCount, eff.skippedName)) + if eff.reversed { + sb.WriteString("\n Direction reversed!") + } } else if eff.drawnCount > 0 { sb.WriteString(fmt.Sprintf("\n %s draws %d and is skipped!", eff.skippedName, eff.drawnCount)) } else if eff.skippedName != "" && eff.reversed { @@ -1208,46 +1640,160 @@ func (p *UnoPlugin) botMultiTurn(game *unoMultiGame, roomBuf *strings.Builder) { bot := game.currentPlayer() bn := unoBotName() - card, idx := botPickCard(bot.hand, game.discardTop, game.topColor, game.bookDown, game.minOpponentCards(game.currentIdx)) - - if idx < 0 { - // Bot draws - drawn := game.draw(1) - if len(drawn) == 0 { + // No Mercy stacking: bot must stack or absorb + if game.noMercy && game.stackMinValue > 0 { + stackCard, sIdx := botPickStackCard(bot.hand, game.topColor, game.stackMinValue) + if sIdx < 0 { + // Bot absorbs the stack + drawn := game.draw(game.stackTotal) + bot.hand = append(bot.hand, drawn...) if roomBuf.Len() > 0 { roomBuf.WriteString("\n") } - roomBuf.WriteString(fmt.Sprintf("๐Ÿƒ %s can't draw โ€” deck empty. Turn passes.", bn)) + roomBuf.WriteString(fmt.Sprintf("๐Ÿ’ฅ %s absorbs the stack! Draws %d cards. (%d cards now)", + bn, game.stackTotal, len(bot.hand))) + game.stackTotal = 0 + game.stackMinValue = 0 + if p.checkMultiMercyElimination(game, bot) { + if roomBuf.Len() > 0 { + p.SendMessage(game.roomID, roomBuf.String()) + roomBuf.Reset() + } + return + } game.currentIdx = game.nextActiveIdx() game.turnID++ return } - - bot.hand = append(bot.hand, drawn[0]) - - if drawn[0].canPlayOn(game.discardTop, game.topColor) { - bot.hand = bot.hand[:len(bot.hand)-1] - card = drawn[0] + // Bot stacks + bot.hand = append(bot.hand[:sIdx], bot.hand[sIdx+1:]...) + game.discardTop = stackCard + dv := cardDrawValue(stackCard.Value) + game.stackTotal += dv + game.stackMinValue = dv + if stackCard.isWild() { + game.topColor = botPickColor(bot.hand) } else { - // Bot drew, not playable - if game.turns%3 == 0 { - commentKey := "bot_draw_normal" - if game.bookDown { - commentKey = "bot_draw_bookdown" + game.topColor = stackCard.Color + } + if roomBuf.Len() > 0 { + roomBuf.WriteString("\n") + } + roomBuf.WriteString(fmt.Sprintf("๐Ÿ”ฅ %s stacks: %s! Total penalty: +%d", + bn, stackCard.DisplayWithColor(game.topColor), game.stackTotal)) + if stackCard.Value == unoWildReverseDraw4 { + game.direction *= -1 + roomBuf.WriteString(" (direction reversed!)") + } + game.currentIdx = game.nextActiveIdx() + game.turnID++ + game.turns++ + return + } + + var card unoCard + var idx int + if game.noMercy { + card, idx = botPickCardNoMercy(bot.hand, game.discardTop, game.topColor, game.bookDown, game.minOpponentCards(game.currentIdx), 0) + } else { + card, idx = botPickCard(bot.hand, game.discardTop, game.topColor, game.bookDown, game.minOpponentCards(game.currentIdx)) + } + + if idx < 0 { + // Bot draws + if game.noMercy { + // Draw until playable + var allDrawn []unoCard + var playableCard *unoCard + for { + cards := game.draw(1) + if len(cards) == 0 { + break + } + c := cards[0] + bot.hand = append(bot.hand, c) + allDrawn = append(allDrawn, c) + if p.checkMultiMercyElimination(game, bot) { + if roomBuf.Len() > 0 { + p.SendMessage(game.roomID, roomBuf.String()) + roomBuf.Reset() + } + return + } + if c.canPlayOn(game.discardTop, game.topColor) { + playableCard = &c + break + } + } + if len(allDrawn) == 0 { + if roomBuf.Len() > 0 { + roomBuf.WriteString("\n") + } + roomBuf.WriteString(fmt.Sprintf("๐Ÿƒ %s can't draw โ€” deck empty. Turn passes.", bn)) + game.currentIdx = game.nextActiveIdx() + game.turnID++ + return + } + if playableCard != nil { + // Remove from hand and play + for i := len(bot.hand) - 1; i >= 0; i-- { + if bot.hand[i] == *playableCard { + bot.hand = append(bot.hand[:i], bot.hand[i+1:]...) + break + } } if roomBuf.Len() > 0 { roomBuf.WriteString("\n") } - roomBuf.WriteString(pickCommentary(commentKey)) + roomBuf.WriteString(fmt.Sprintf("๐Ÿƒ %s draws %d card(s).", bn, len(allDrawn))) + card = *playableCard + // Fall through to play the card below } else { if roomBuf.Len() > 0 { roomBuf.WriteString("\n") } - roomBuf.WriteString(fmt.Sprintf("๐Ÿƒ %s draws a card.", bn)) + roomBuf.WriteString(fmt.Sprintf("๐Ÿƒ %s draws %d card(s). No playable card.", bn, len(allDrawn))) + game.currentIdx = game.nextActiveIdx() + game.turnID++ + return + } + } else { + drawn := game.draw(1) + if len(drawn) == 0 { + if roomBuf.Len() > 0 { + roomBuf.WriteString("\n") + } + roomBuf.WriteString(fmt.Sprintf("๐Ÿƒ %s can't draw โ€” deck empty. Turn passes.", bn)) + game.currentIdx = game.nextActiveIdx() + game.turnID++ + return + } + + bot.hand = append(bot.hand, drawn[0]) + + if drawn[0].canPlayOn(game.discardTop, game.topColor) { + bot.hand = bot.hand[:len(bot.hand)-1] + card = drawn[0] + } else { + if game.turns%3 == 0 { + commentKey := "bot_draw_normal" + if game.bookDown { + commentKey = "bot_draw_bookdown" + } + if roomBuf.Len() > 0 { + roomBuf.WriteString("\n") + } + roomBuf.WriteString(pickCommentary(commentKey)) + } else { + if roomBuf.Len() > 0 { + roomBuf.WriteString("\n") + } + roomBuf.WriteString(fmt.Sprintf("๐Ÿƒ %s draws a card.", bn)) + } + game.currentIdx = game.nextActiveIdx() + game.turnID++ + return } - game.currentIdx = game.nextActiveIdx() - game.turnID++ - return } } @@ -1257,16 +1803,46 @@ func (p *UnoPlugin) botMultiTurn(game *unoMultiGame, roomBuf *strings.Builder) { } game.discardTop = card + // No Mercy: Discard All โ€” bot discards remaining cards of same color + if game.noMercy && card.Value == unoDiscardAll { + discardAllOfColor(&bot.hand, card.Color) + } + // Wild color choice - if card.Value == unoWildCard || card.Value == unoWildDrawFour { + if card.isWild() { if card.Value == unoWildDrawFour { game.wd4PrevColor = game.topColor } - game.topColor = botPickColor(bot.hand) + if game.noMercy && card.Value == unoWildColorRoulette { + game.topColor = botRouletteColor(bot.hand) + } else { + game.topColor = botPickColor(bot.hand) + } } else { game.topColor = card.Color } + // No Mercy: 7-0 rule + if game.noMercy && game.sevenZeroRule { + if card.Value == unoSeven { + target := botChooseSwapTarget(game, bot) + if target != nil { + swapHandsMulti(bot, target) + if !target.isBot { + p.SendMessage(target.dmRoomID, fmt.Sprintf("๐Ÿ”„ %s swapped hands with you! You now have %d cards.", bn, len(target.hand))) + } + } + } + if card.Value == unoZero { + rotateHandsMulti(game) + for _, pl := range game.players { + if pl.active && !pl.isBot { + p.SendMessage(pl.dmRoomID, fmt.Sprintf("๐Ÿ”„ Hands rotated! You now have %d cards.", len(pl.hand))) + } + } + } + } + // Check bot win if len(bot.hand) == 0 { if roomBuf.Len() > 0 { @@ -1318,6 +1894,46 @@ func (p *UnoPlugin) botMultiTurn(game *unoMultiGame, roomBuf *strings.Builder) { return } + // No Mercy: Color Roulette โ€” next player flips cards + // applyCardEffects already advanced currentIdx to the victim + if game.noMercy && card.Value == unoWildColorRoulette { + target := game.players[game.currentIdx] + targetName := p.unoDisplayName(target.userID) + if target.isBot { + targetName = bn + } + flipped := p.executeColorRouletteMulti(game, target, game.topColor) + roomBuf.WriteString(fmt.Sprintf("\n ๐ŸŽฐ Color Roulette! %s flips %d cards until finding %s %s.", + targetName, len(flipped), game.topColor.Emoji(), game.topColor)) + if !target.isBot { + p.SendMessage(target.dmRoomID, fmt.Sprintf("๐ŸŽฐ Color Roulette! You drew %d cards. You now have %d cards.", len(flipped), len(target.hand))) + } + + if p.checkMultiMercyElimination(game, target) { + if roomBuf.Len() > 0 { + p.SendMessage(game.roomID, roomBuf.String()) + roomBuf.Reset() + } + if game.done { + return + } + } + + // Skip past the roulette victim + game.currentIdx = game.nextActiveIdx() + game.turnID++ + } + + // No Mercy: 7-0 room announcements (swap already happened above) + if game.noMercy && game.sevenZeroRule { + if card.Value == unoSeven { + roomBuf.WriteString("\n ๐Ÿ”„ Hands swapped!") + } + if card.Value == unoZero { + roomBuf.WriteString("\n ๐Ÿ”„ Hands rotated!") + } + } + game.turns++ } @@ -1444,6 +2060,34 @@ func (p *UnoPlugin) autoPlayMultiTurn(game *unoMultiGame, player *unoMultiPlayer p.SendMessage(game.roomID, fmt.Sprintf("๐Ÿƒ *%s was auto-played.* Plays: %s (chose %s %s)", name, pendingCard.Display(), color.Emoji(), color)) + + // No Mercy: Color Roulette โ€” apply roulette effect before generic effects + if game.noMercy && pendingCard != nil && pendingCard.Value == unoWildColorRoulette { + nextIdx := game.nextActiveIdx() + target := game.players[nextIdx] + targetName := p.unoDisplayName(target.userID) + if target.isBot { + targetName = unoBotName() + } + flipped := p.executeColorRouletteMulti(game, target, color) + p.SendMessage(game.roomID, fmt.Sprintf("๐ŸŽฐ Color Roulette! %s flips %d cards until finding %s.\n%s", + targetName, len(flipped), color, pickNoMercyCommentary("color_roulette"))) + if !target.isBot { + p.SendMessage(target.dmRoomID, fmt.Sprintf("๐ŸŽฐ Color Roulette! You drew %d cards. You now have %d cards.", len(flipped), len(target.hand))) + } + if p.checkMultiMercyElimination(game, target) { + if game.done { + return + } + } + // Skip past the roulette victim + game.currentIdx = nextIdx + game.currentIdx = game.nextActiveIdx() + game.turnID++ + p.executeMultiTurn(game) + return + } + p.applyAutoEffects(game, player, *pendingCard) return @@ -1462,7 +2106,7 @@ func (p *UnoPlugin) autoPlayMultiTurn(game *unoMultiGame, player *unoMultiPlayer game.discardTop = drawnCard game.turns++ - if drawnCard.Value == unoWildCard || drawnCard.Value == unoWildDrawFour { + if drawnCard.isWild() { if drawnCard.Value == unoWildDrawFour { game.wd4PrevColor = game.topColor } @@ -1496,7 +2140,45 @@ func (p *UnoPlugin) autoPlayMultiTurn(game *unoMultiGame, player *unoMultiPlayer p.applyAutoEffects(game, player, drawnCard) return + case unoMultiPhaseChooseSwapTarget: + // Auto-play: swap with player who has fewest cards + target := botChooseSwapTarget(game, player) + if target != nil { + swapHandsMulti(player, target) + targetName := p.unoDisplayName(target.userID) + if target.isBot { + targetName = unoBotName() + } + p.SendMessage(player.dmRoomID, fmt.Sprintf("*Auto-played:* Swapped hands with %s.", targetName)) + p.SendMessage(game.roomID, fmt.Sprintf("๐Ÿ”„ *%s was auto-played.* Swaps hands with %s!", name, targetName)) + } + game.phase = unoMultiPhasePlay + if len(player.hand) == 0 { + p.multiPlayerWins(game, player) + return + } + p.advanceAndExecute(game) + return + case unoMultiPhasePlay: + // No Mercy stacking: auto-accept + if game.noMercy && game.stackMinValue > 0 { + drawn := game.draw(game.stackTotal) + player.hand = append(player.hand, drawn...) + p.SendMessage(player.dmRoomID, fmt.Sprintf("*Auto-played:* Accepted stack. Drew %d cards.", game.stackTotal)) + p.SendMessage(game.roomID, fmt.Sprintf("๐Ÿ’ฅ *%s was auto-played.* Absorbs the stack! Draws %d cards. (%d cards now)", + name, game.stackTotal, len(player.hand))) + game.stackTotal = 0 + game.stackMinValue = 0 + if p.checkMultiMercyElimination(game, player) { + if game.done { + return + } + } + p.advanceAndExecute(game) + return + } + // Find first playable non-action card playIdx := -1 for i, c := range player.hand { @@ -1521,7 +2203,7 @@ func (p *UnoPlugin) autoPlayMultiTurn(game *unoMultiGame, player *unoMultiPlayer game.discardTop = card game.turns++ - if card.Value == unoWildCard || card.Value == unoWildDrawFour { + if card.isWild() { if card.Value == unoWildDrawFour { game.wd4PrevColor = game.topColor } @@ -1579,7 +2261,7 @@ func (p *UnoPlugin) autoPlayMultiTurn(game *unoMultiGame, player *unoMultiPlayer game.discardTop = card game.turns++ - if card.Value == unoWildCard || card.Value == unoWildDrawFour { + if card.isWild() { if card.Value == unoWildDrawFour { game.wd4PrevColor = game.topColor } @@ -1837,7 +2519,100 @@ func (p *UnoPlugin) sendMultiHandDisplay(game *unoMultiGame, player *unoMultiPla } sb.WriteString(strings.Join(counts, " | ")) + if game.noMercy && len(player.hand) >= 20 { + sb.WriteString(fmt.Sprintf("\nโš ๏ธ **You have %d cards! (25 = eliminated)**", len(player.hand))) + } + sb.WriteString("\n\nReply with a card number to play, or **draw** to draw.") p.SendMessage(player.dmRoomID, sb.String()) } + +// sendMultiHandDisplayStacking shows hand during stacking โ€” only stackable cards are playable. +func (p *UnoPlugin) sendMultiHandDisplayStacking(game *unoMultiGame, player *unoMultiPlayer) { + var sb strings.Builder + sb.WriteString(fmt.Sprintf("โš ๏ธ **Stack incoming: +%d!**\nDiscard pile: %s\n\n**Your hand:**\n", + game.stackTotal, game.discardTop.DisplayWithColor(game.topColor))) + + for i, c := range player.hand { + marker := "" + if c.canPlayOnStacking(game.topColor, game.stackMinValue) { + marker = " โœ…" + } + sb.WriteString(fmt.Sprintf("%d. %s%s\n", i+1, c.Display(), marker)) + } + + sb.WriteString("\nCard counts: ") + counts := make([]string, 0) + bn := unoBotName() + for _, pl := range game.players { + if pl == player || !pl.active { + continue + } + name := p.unoDisplayName(pl.userID) + if pl.isBot { + name = bn + } + counts = append(counts, fmt.Sprintf("%s (%d)", name, len(pl.hand))) + } + sb.WriteString(strings.Join(counts, " | ")) + + sb.WriteString(fmt.Sprintf("\n\nPlay a draw card to stack, or type **accept** to draw %d cards.", game.stackTotal)) + + p.SendMessage(player.dmRoomID, sb.String()) +} + +// handleMultiSwapChoice handles the player choosing a swap target for the 7-0 rule. +func (p *UnoPlugin) handleMultiSwapChoice(game *unoMultiGame, player *unoMultiPlayer, input string) error { + // Parse target number + var targetIdx int + if _, err := fmt.Sscanf(input, "%d", &targetIdx); err != nil || targetIdx < 1 { + p.SendMessage(player.dmRoomID, "Choose a player number to swap hands with.") + return nil + } + + // Map number to active player + i := 1 + var target *unoMultiPlayer + for _, pl := range game.players { + if pl == player || !pl.active { + continue + } + if i == targetIdx { + target = pl + break + } + i++ + } + + if target == nil { + p.SendMessage(player.dmRoomID, "Invalid player number. Try again.") + return nil + } + + swapHandsMulti(player, target) + game.phase = unoMultiPhasePlay + + name := p.unoDisplayName(player.userID) + targetName := p.unoDisplayName(target.userID) + if target.isBot { + targetName = unoBotName() + } + + p.SendMessage(player.dmRoomID, fmt.Sprintf("Swapped hands with %s! You now have %d cards.", targetName, len(player.hand))) + if !target.isBot { + p.SendMessage(target.dmRoomID, fmt.Sprintf("๐Ÿ”„ %s swapped hands with you! You now have %d cards.", name, len(target.hand))) + } + p.SendMessage(game.roomID, fmt.Sprintf("๐Ÿ”„ %s swaps hands with %s! %s", + name, targetName, pickNoMercyCommentary("hand_swap"))) + + // Check win (if player got an empty hand from swap) + if len(player.hand) == 0 { + p.multiPlayerWins(game, player) + return nil + } + + // Continue to next turn + p.advanceAndExecute(game) + return nil +} diff --git a/internal/plugin/uno_nomercy.go b/internal/plugin/uno_nomercy.go new file mode 100644 index 0000000..7c58f59 --- /dev/null +++ b/internal/plugin/uno_nomercy.go @@ -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 +} +