mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
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>
615 lines
16 KiB
Go
615 lines
16 KiB
Go
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
|
||
}
|
||
|