diff --git a/internal/plugin/uno.go b/internal/plugin/uno.go index 7234c13..b3111d2 100644 --- a/internal/plugin/uno.go +++ b/internal/plugin/uno.go @@ -225,6 +225,10 @@ type unoGame struct { stackTotal int // cumulative draw penalty during stacking stackMinValue int // minimum draw value to stack (0 = not stacking) + // Sudden death (long-game point scoring) + suddenDeath bool + suddenDeathTurn int // turn at which game ends + idleTimer *time.Timer warningTimer *time.Timer } @@ -346,6 +350,16 @@ var unoCommentary = map[string][]string{ "long_game": { "\"Still going, are we? šŸ’›\" *glances at bookmark*", }, + "sudden_death": { + "*GogoBee puts the book down.* \"This has gone on long enough. šŸ’›\"", + "*GogoBee glances at the clock.* \"Time to settle this. šŸ’›\"", + }, + "sudden_death_win": { + "\"Well played. The numbers don't lie. šŸ’›\" *resumes reading*", + }, + "sudden_death_lose": { + "\"The math was on my side. šŸ’›\" *turns a page*", + }, } func unoBotName() string { @@ -1154,6 +1168,10 @@ func (p *UnoPlugin) soloBotHandleStack(game *unoGame) error { // --------------------------------------------------------------------------- func (p *UnoPlugin) botTurn(game *unoGame) error { + if p.checkSuddenDeath(game) { + return nil + } + // No Mercy stacking: if a stack is pending, bot must handle it if game.noMercy && game.stackMinValue > 0 { return p.soloBotHandleStack(game) @@ -1572,9 +1590,132 @@ func (p *UnoPlugin) botChooseColor(game *unoGame) unoColor { return botPickColor(game.botHand) } +// --------------------------------------------------------------------------- +// Sudden Death (long-game point scoring for 2-player matches) +// --------------------------------------------------------------------------- + +const ( + suddenDeathAnnounce = 80 // turn to announce sudden death + suddenDeathCountdown = 20 // turns after announcement +) + +// checkSuddenDeath checks whether sudden death should be announced or resolved. +// Returns true if the game ended. +func (p *UnoPlugin) checkSuddenDeath(game *unoGame) bool { + if game.done { + return true + } + + // Resolve: game ends at the deadline + if game.suddenDeath && game.turns >= game.suddenDeathTurn { + p.suddenDeathWinner(game) + return true + } + + // Announce + if !game.suddenDeath && game.turns >= suddenDeathAnnounce { + game.suddenDeath = true + game.suddenDeathTurn = game.turns + suddenDeathCountdown + remaining := game.suddenDeathTurn - game.turns + + ann := fmt.Sprintf("ā° **SUDDEN DEATH!** This match ends in %d turns — lowest hand value wins!\n\n%s", + remaining, pickCommentary("sudden_death")) + p.SendMessage(game.dmRoomID, ann) + p.SendMessage(game.roomID, fmt.Sprintf("ā° **Sudden Death activated!** %s's match ends in %d turns.", + p.DisplayName(game.playerID), remaining)) + return false + } + + // Countdown reminders (DM only) + if game.suddenDeath { + remaining := game.suddenDeathTurn - game.turns + switch remaining { + case 10: + p.SendMessage(game.dmRoomID, "ā° **10 turns remaining!**") + case 5: + p.SendMessage(game.dmRoomID, "ā° **5 turns remaining!**") + } + } + + return false +} + +func (p *UnoPlugin) suddenDeathWinner(game *unoGame) { + p.mu.Lock() + if game.done { + p.mu.Unlock() + return + } + game.done = true + p.mu.Unlock() + + playerScore := scoreHand(game.playerHand) + botScore := scoreHand(game.botHand) + bn := unoBotName() + playerName := p.DisplayName(game.playerID) + + // Build score breakdown + breakdown := fmt.Sprintf("**Final Scores:**\n%s: %s\n%s: %s", + playerName, formatHandScore(game.playerHand), + bn, formatHandScore(game.botHand)) + + var playerWins bool + switch { + case playerScore < botScore: + playerWins = true + case botScore < playerScore: + playerWins = false + case len(game.playerHand) < len(game.botHand): + // Tiebreaker: fewer cards + playerWins = true + breakdown += "\n*Tiebreaker: fewer cards!*" + case len(game.botHand) < len(game.playerHand): + playerWins = false + breakdown += "\n*Tiebreaker: fewer cards!*" + default: + // Final tiebreaker: player wins (bot had the advantage of going second in the countdown) + playerWins = true + breakdown += "\n*Tiebreaker: dead even — player takes it!*" + } + + p.SendMessage(game.dmRoomID, fmt.Sprintf("ā° **TIME'S UP — SUDDEN DEATH!**\n\n%s", breakdown)) + + potBefore := p.getPot() + + if playerWins { + payout := p.claimFromPot(game.wager) + totalPayout := game.wager + payout + p.euro.Credit(game.playerID, totalPayout, "uno_win") + + newPot := p.getPot() + p.SendMessage(game.dmRoomID, "šŸŽ‰ **You win on points!**") + p.SendMessage(game.roomID, fmt.Sprintf( + "ā° **Sudden Death!** %s wins on points!\n%s\n€%d claimed from the community pot. (Pot: €%d)\n\n%s", + playerName, breakdown, int(payout), int(newPot), pickCommentary("sudden_death_win"))) + + p.recordGame(game, "sudden_death_player", potBefore) + } else { + p.addToPot(game.wager) + newPot := p.getPot() + + p.SendMessage(game.dmRoomID, fmt.Sprintf("šŸ’€ **%s wins on points.** Better luck next time.", bn)) + p.SendMessage(game.roomID, fmt.Sprintf( + "ā° **Sudden Death!** %s wins on points!\n%s\n%s's €%d added to the community pot. (Pot: €%d)\n\n%s", + bn, breakdown, playerName, int(game.wager), int(newPot), pickCommentary("sudden_death_lose"))) + + recordBotDefeat(game.playerID, "uno") + p.recordGame(game, "sudden_death_bot", potBefore) + } + + p.cleanupGame(game) +} + // afterBotTurn is called when the bot's turn was skipped (player played skip/reverse/draw two). // Shows the hand display for the player's next turn. func (p *UnoPlugin) afterBotTurn(game *unoGame) error { + if p.checkSuddenDeath(game) { + return nil + } return p.playerTurnOrAutoDraw(game) } @@ -1630,6 +1771,10 @@ func (p *UnoPlugin) sendHandDisplayStacking(game *unoGame) { // 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 { + if p.checkSuddenDeath(game) { + return nil + } + // 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) { diff --git a/internal/plugin/uno_multi.go b/internal/plugin/uno_multi.go index a3577e7..1e163d1 100644 --- a/internal/plugin/uno_multi.go +++ b/internal/plugin/uno_multi.go @@ -67,6 +67,10 @@ type unoMultiGame struct { stackTotal int // cumulative draw penalty during stacking stackMinValue int // minimum draw value to stack (0 = not stacking) + // Sudden death (long-game point scoring) + suddenDeath bool + suddenDeathTurn int + timer *time.Timer inactiveTimer *time.Timer // 10-minute game timeout mu sync.Mutex // per-game lock @@ -686,10 +690,17 @@ func (p *UnoPlugin) executeMultiTurn(game *unoMultiGame) { } return } + if p.checkMultiSuddenDeath(game) { + if roomBuf.Len() > 0 { + p.SendMessage(game.roomID, roomBuf.String()) + } + return + } continue } - // Human's turn — flush room buffer + // Human's turn — flush bot play buffer first so the turn announcement arrives + // before any auto-action happens. if roomBuf.Len() > 0 { p.SendMessage(game.roomID, roomBuf.String()) roomBuf.Reset() @@ -837,6 +848,9 @@ func (p *UnoPlugin) advanceAndExecute(game *unoMultiGame) { game.currentIdx = game.nextActiveIdx() game.turnID++ game.turns++ + if p.checkMultiSuddenDeath(game) { + return + } p.executeMultiTurn(game) } @@ -940,6 +954,9 @@ func (p *UnoPlugin) handleMultiDMInput(ctx MessageContext, game *unoMultiGame) e game.turnID++ game.turns++ p.SendMessage(game.roomID, absorbMsg+"\n"+p.nextTurnLabel(game)) + if p.checkMultiSuddenDeath(game) { + return nil + } p.executeMultiTurn(game) return nil } @@ -1185,6 +1202,9 @@ func (p *UnoPlugin) handleMultiColorChoice(game *unoMultiGame, player *unoMultiP game.currentIdx = nextIdx game.currentIdx = game.nextActiveIdx() game.turnID++ + if p.checkMultiSuddenDeath(game) { + return nil + } p.executeMultiTurn(game) return nil } @@ -1554,6 +1574,9 @@ func (p *UnoPlugin) applyAndAnnounce(game *unoMultiGame, player *unoMultiPlayer, roomMsg.WriteString(fmt.Sprintf("\n It's %s's turn.", nextUpName)) p.SendMessage(game.roomID, roomMsg.String()) + if p.checkMultiSuddenDeath(game) { + return + } p.executeMultiTurn(game) } @@ -1616,6 +1639,9 @@ func (p *UnoPlugin) resolveWD4Challenge(game *unoMultiGame, challenged bool) { fmt.Sprintf("šŸƒ %s accepts the Wild Draw Four. Draws 4 and is skipped!", victimName)) game.currentIdx = game.nextActiveIdx() game.turnID++ + if p.checkMultiSuddenDeath(game) { + return + } p.executeMultiTurn(game) return } @@ -1638,6 +1664,9 @@ func (p *UnoPlugin) resolveWD4Challenge(game *unoMultiGame, challenged bool) { wd4Name, game.wd4PrevColor.Emoji(), game.wd4PrevColor, wd4Name)) // Victim is NOT skipped — turn continues from victim game.turnID++ + if p.checkMultiSuddenDeath(game) { + return + } p.executeMultiTurn(game) } else { // Challenge fails — victim draws 6 (4 + 2 penalty) @@ -1649,6 +1678,9 @@ func (p *UnoPlugin) resolveWD4Challenge(game *unoMultiGame, challenged bool) { // Victim is skipped game.currentIdx = game.nextActiveIdx() game.turnID++ + if p.checkMultiSuddenDeath(game) { + return + } p.executeMultiTurn(game) } } @@ -1980,6 +2012,14 @@ func (p *UnoPlugin) botMultiTurn(game *unoMultiGame, roomBuf *strings.Builder) { } } + // Next turn label (applyCardEffects already advanced currentIdx) + nextUp := game.currentPlayer() + nextUpName := p.DisplayName(nextUp.userID) + if nextUp.isBot { + nextUpName = unoBotName() + } + roomBuf.WriteString(fmt.Sprintf("\n It's %s's turn.", nextUpName)) + game.turns++ } @@ -2130,6 +2170,9 @@ func (p *UnoPlugin) autoPlayMultiTurn(game *unoMultiGame, player *unoMultiPlayer game.currentIdx = nextIdx game.currentIdx = game.nextActiveIdx() game.turnID++ + if p.checkMultiSuddenDeath(game) { + return + } p.executeMultiTurn(game) return } @@ -2344,9 +2387,166 @@ func (p *UnoPlugin) applyAutoEffects(game *unoMultiGame, player *unoMultiPlayer, p.startWD4Challenge(game, player) return } + if p.checkMultiSuddenDeath(game) { + return + } p.executeMultiTurn(game) } +// --------------------------------------------------------------------------- +// Sudden Death (multiplayer — only when 2 active players remain) +// --------------------------------------------------------------------------- + +// checkMultiSuddenDeath checks whether sudden death should be announced or resolved. +// Only applies when exactly 2 active players remain. Returns true if the game ended. +// Caller must hold game.mu. +func (p *UnoPlugin) checkMultiSuddenDeath(game *unoMultiGame) bool { + if game.done { + return true + } + + active := game.activePlayers() + if len(active) != 2 { + return false + } + + // Resolve + if game.suddenDeath && game.turns >= game.suddenDeathTurn { + p.multiSuddenDeathWinner(game, active) + return true + } + + // Announce + if !game.suddenDeath && game.turns >= suddenDeathAnnounce { + game.suddenDeath = true + game.suddenDeathTurn = game.turns + suddenDeathCountdown + remaining := game.suddenDeathTurn - game.turns + + p.SendMessage(game.roomID, fmt.Sprintf( + "ā° **SUDDEN DEATH!** Match ends in %d turns — lowest hand value wins!\n\n%s", + remaining, pickCommentary("sudden_death"))) + + for _, pl := range active { + if !pl.isBot { + p.SendMessage(pl.dmRoomID, fmt.Sprintf( + "ā° **Sudden Death!** %d turns remaining — dump your high-value cards!", remaining)) + } + } + return false + } + + // Countdown reminders + if game.suddenDeath { + remaining := game.suddenDeathTurn - game.turns + switch remaining { + case 10: + p.SendMessage(game.roomID, "ā° **10 turns remaining!**") + case 5: + p.SendMessage(game.roomID, "ā° **5 turns remaining!**") + } + } + + return false +} + +func (p *UnoPlugin) multiSuddenDeathWinner(game *unoMultiGame, active []*unoMultiPlayer) { + if game.done { + return + } + game.done = true + + if game.timer != nil { + game.timer.Stop() + } + if game.inactiveTimer != nil { + game.inactiveTimer.Stop() + } + + a, b := active[0], active[1] + aScore := scoreHand(a.hand) + bScore := scoreHand(b.hand) + + nameOf := func(pl *unoMultiPlayer) string { + if pl.isBot { + return unoBotName() + } + return p.DisplayName(pl.userID) + } + + breakdown := fmt.Sprintf("**Final Scores:**\n%s: %s\n%s: %s", + nameOf(a), formatHandScore(a.hand), + nameOf(b), formatHandScore(b.hand)) + + var winner, loser *unoMultiPlayer + switch { + case aScore < bScore: + winner, loser = a, b + case bScore < aScore: + winner, loser = b, a + case len(a.hand) < len(b.hand): + winner, loser = a, b + breakdown += "\n*Tiebreaker: fewer cards!*" + case len(b.hand) < len(a.hand): + winner, loser = b, a + breakdown += "\n*Tiebreaker: fewer cards!*" + default: + // Tiebreaker: player whose turn it is NOT wins + current := game.currentPlayer() + if current == a { + winner, loser = b, a + } else { + winner, loser = a, b + } + breakdown += "\n*Tiebreaker: dead even — advantage to the opponent!*" + } + _ = loser // used implicitly via winner != loser + + p.SendMessage(game.roomID, fmt.Sprintf( + "ā° **SUDDEN DEATH!** %s wins on points!\n%s\n\n%s", + nameOf(winner), breakdown, pickCommentary("sudden_death_win"))) + + if winner.isBot { + p.multiBotWins2(game) + } else { + p.multiPlayerWins2(game, winner) + } +} + +// multiPlayerWins2 / multiBotWins2 handle payout without duplicate done/timer logic +// (already handled by multiSuddenDeathWinner). +func (p *UnoPlugin) multiPlayerWins2(game *unoMultiGame, winner *unoMultiPlayer) { + humanCount := 0 + for _, pl := range game.players { + if !pl.isBot { + humanCount++ + } + } + totalPot := game.ante * float64(humanCount) + p.euro.Credit(winner.userID, totalPot, "uno_multi_win") + + p.recordMultiGame(game, winner.userID, "sudden_death_win") + p.cleanupMultiGame(game) +} + +func (p *UnoPlugin) multiBotWins2(game *unoMultiGame) { + humanCount := 0 + for _, pl := range game.players { + if !pl.isBot { + humanCount++ + } + } + totalPot := game.ante * float64(humanCount) + p.addToPot(totalPot) + + for _, pl := range game.players { + if !pl.isBot { + recordBotDefeat(pl.userID, "uno_multi") + } + } + p.recordMultiGame(game, id.UserID("bot"), "sudden_death_bot") + p.cleanupMultiGame(game) +} + // --------------------------------------------------------------------------- // Win / Forfeit / Cleanup // --------------------------------------------------------------------------- diff --git a/internal/plugin/uno_nomercy.go b/internal/plugin/uno_nomercy.go index 1318a33..53ddf95 100644 --- a/internal/plugin/uno_nomercy.go +++ b/internal/plugin/uno_nomercy.go @@ -26,6 +26,57 @@ func cardDrawValue(v unoValue) int { } } +// cardPointValue returns the point value of a card for sudden-death scoring. +func cardPointValue(c unoCard) int { + switch c.Value { + case unoZero: + return 0 + case unoOne: + return 1 + case unoTwo: + return 2 + case unoThree: + return 3 + case unoFour: + return 4 + case unoFive: + return 5 + case unoSix: + return 6 + case unoSeven: + return 7 + case unoEight: + return 8 + case unoNine: + return 9 + case unoSkip, unoReverse, unoDrawTwo: + return 20 + case unoSkipEveryone, unoDrawFour, unoDiscardAll: + return 30 + case unoWildCard, unoWildDrawFour: + return 50 + case unoWildReverseDraw4, unoWildDrawSix, unoWildColorRoulette: + return 60 + case unoWildDrawTen: + return 75 + default: + return 0 + } +} + +func scoreHand(hand []unoCard) int { + total := 0 + for _, c := range hand { + total += cardPointValue(c) + } + return total +} + +func formatHandScore(hand []unoCard) string { + score := scoreHand(hand) + return fmt.Sprintf("%d cards, %d points", len(hand), score) +} + func isDrawCard(v unoValue) bool { return cardDrawValue(v) > 0 } diff --git a/internal/plugin/uno_test.go b/internal/plugin/uno_test.go index b6068a0..f073b9a 100644 --- a/internal/plugin/uno_test.go +++ b/internal/plugin/uno_test.go @@ -288,3 +288,58 @@ func TestUnoColor_Emoji(t *testing.T) { t.Errorf("blue emoji: got %q", unoBlue.Emoji()) } } + +// --------------------------------------------------------------------------- +// Card point values (sudden death scoring) +// --------------------------------------------------------------------------- + +func TestCardPointValue(t *testing.T) { + tests := []struct { + name string + card unoCard + want int + }{ + {"zero", unoCard{unoRed, unoZero}, 0}, + {"five", unoCard{unoBlue, unoFive}, 5}, + {"nine", unoCard{unoGreen, unoNine}, 9}, + {"skip", unoCard{unoRed, unoSkip}, 20}, + {"reverse", unoCard{unoBlue, unoReverse}, 20}, + {"draw two", unoCard{unoYellow, unoDrawTwo}, 20}, + {"skip everyone", unoCard{unoRed, unoSkipEveryone}, 30}, + {"draw four colored", unoCard{unoGreen, unoDrawFour}, 30}, + {"discard all", unoCard{unoBlue, unoDiscardAll}, 30}, + {"wild", unoCard{unoWild, unoWildCard}, 50}, + {"wild draw four", unoCard{unoWild, unoWildDrawFour}, 50}, + {"wild reverse draw 4", unoCard{unoWild, unoWildReverseDraw4}, 60}, + {"wild draw six", unoCard{unoWild, unoWildDrawSix}, 60}, + {"wild color roulette", unoCard{unoWild, unoWildColorRoulette}, 60}, + {"wild draw ten", unoCard{unoWild, unoWildDrawTen}, 75}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := cardPointValue(tt.card); got != tt.want { + t.Errorf("cardPointValue(%v) = %d, want %d", tt.card, got, tt.want) + } + }) + } +} + +func TestScoreHand(t *testing.T) { + hand := []unoCard{ + {unoRed, unoFive}, // 5 + {unoBlue, unoNine}, // 9 + {unoGreen, unoSkip}, // 20 + {unoWild, unoWildCard}, // 50 + } + got := scoreHand(hand) + want := 84 + if got != want { + t.Errorf("scoreHand() = %d, want %d", got, want) + } +} + +func TestScoreHandEmpty(t *testing.T) { + if got := scoreHand(nil); got != 0 { + t.Errorf("scoreHand(nil) = %d, want 0", got) + } +}