Add UNO Show 'Em No Mercy game mode

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

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

View File

@@ -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
}