Files
gogobee/internal/plugin/uno_nomercy.go
prosolis 9446038646 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>
2026-03-21 19:37:41 -07:00

615 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}