diff --git a/internal/plugin/blackjack.go b/internal/plugin/blackjack.go index fea94af..4bd13f7 100644 --- a/internal/plugin/blackjack.go +++ b/internal/plugin/blackjack.go @@ -3,6 +3,7 @@ package plugin import ( "context" "fmt" + "math" "math/rand/v2" "strings" "sync" @@ -85,15 +86,11 @@ func handValue(cards []card) (int, bool) { total += c.value() } } - soft := aces > 0 && total <= 21 for total > 21 && aces > 0 { total -= 10 aces-- } - if aces == 0 { - soft = false - } - return total, soft + return total, aces > 0 } func handStr(cards []card) string { @@ -130,14 +127,14 @@ func (p *bjPlayer) value() int { } type bjTable struct { - players []*bjPlayer - dealer []card - deck *deck - joinTimer *time.Timer - turnTimer *time.Timer - currentTurn int - phase string // "joining", "playing", "done" - roomID id.RoomID + players []*bjPlayer + dealer []card + deck *deck + joinTimer *time.Timer + turnTimer *time.Timer + reminderTimers []*time.Timer + phase string // "joining", "playing", "done" + roomID id.RoomID } // --------------------------------------------------------------------------- @@ -187,7 +184,7 @@ func (p *BlackjackPlugin) Name() string { return "blackjack" } func (p *BlackjackPlugin) Commands() []CommandDef { return []CommandDef{ - {Name: "blackjack", Description: "Start or join a Blackjack table", Usage: "!blackjack โ‚ฌamount | !blackjack leave", Category: "Games"}, + {Name: "blackjack", Description: "Start or join a Blackjack table", Usage: "!blackjack โ‚ฌamount | !blackjack deal | !blackjack leave", Category: "Games"}, {Name: "hit", Description: "Take a card in Blackjack", Usage: "!hit", Category: "Games"}, {Name: "stand", Description: "End your turn in Blackjack", Usage: "!stand", Category: "Games"}, {Name: "bjboard", Description: "Blackjack leaderboard", Usage: "!bjboard", Category: "Games"}, @@ -201,6 +198,9 @@ func (p *BlackjackPlugin) OnReaction(_ ReactionContext) error { return nil } func (p *BlackjackPlugin) OnMessage(ctx MessageContext) error { switch { case p.IsCommand(ctx.Body, "bjboard"): + if !isGamesRoom(ctx.RoomID) { + return p.SendReply(ctx.RoomID, ctx.EventID, "Games are only available in the games channel!") + } return p.handleBoard(ctx) case p.IsCommand(ctx.Body, "blackjack"): if !isGamesRoom(ctx.RoomID) { @@ -232,6 +232,10 @@ func (p *BlackjackPlugin) handleBlackjack(ctx MessageContext) error { return p.handleLeave(ctx) } + if strings.EqualFold(args, "deal") { + return p.handleDeal(ctx) + } + // Parse bet amount amountStr := strings.TrimPrefix(args, "โ‚ฌ") var bet float64 @@ -244,10 +248,6 @@ func (p *BlackjackPlugin) handleBlackjack(ctx MessageContext) error { return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Minimum bet is โ‚ฌ%d.", int(p.cfg.MinBet))) } - if bet > p.cfg.MaxBet { - bet = p.cfg.MaxBet - } - // Check balance balance := p.euro.GetBalance(ctx.Sender) maxAvailable := balance + p.cfg.DebtLimit @@ -255,9 +255,16 @@ func (p *BlackjackPlugin) handleBlackjack(ctx MessageContext) error { return p.SendReply(ctx.RoomID, ctx.EventID, "๐Ÿšซ You're at your debt limit. Earn some euros before playing.") } - if bet > maxAvailable { - return p.SendReply(ctx.RoomID, ctx.EventID, - fmt.Sprintf("You can bet up to โ‚ฌ%d (balance: โ‚ฌ%d).", int(maxAvailable), int(balance))) + + maxBet := min(p.cfg.MaxBet, maxAvailable) + if bet > maxBet { + if maxBet < p.cfg.MaxBet { + return p.SendReply(ctx.RoomID, ctx.EventID, + fmt.Sprintf("You can bet up to โ‚ฌ%d (balance: โ‚ฌ%d).", int(maxBet), int(balance))) + } + _ = p.SendReply(ctx.RoomID, ctx.EventID, + fmt.Sprintf("Max bet is โ‚ฌ%d โ€” capping your bet.", int(p.cfg.MaxBet))) + bet = p.cfg.MaxBet } p.mu.Lock() @@ -311,7 +318,7 @@ func (p *BlackjackPlugin) handleBlackjack(ctx MessageContext) error { name := p.bjDisplayName(ctx.Sender) _ = p.SendMessage(ctx.RoomID, - fmt.Sprintf("๐Ÿƒ **%s** opens a Blackjack table! Bet: โ‚ฌ%d\nJoin with `!blackjack โ‚ฌamount` (60 seconds to join)", + fmt.Sprintf("๐Ÿƒ **%s** opens a Blackjack table! Bet: โ‚ฌ%d\nJoin with `!blackjack โ‚ฌamount` or `!blackjack deal` to start now (60s to join)", name, int(bet))) // Start join timer @@ -331,8 +338,27 @@ func (p *BlackjackPlugin) handleLeave(ctx MessageContext) error { defer p.mu.Unlock() table, exists := p.tables[ctx.RoomID] - if !exists || table.phase != "joining" { - return p.SendReply(ctx.RoomID, ctx.EventID, "No table to leave, or the round has started.") + if !exists { + return p.SendReply(ctx.RoomID, ctx.EventID, "No table to leave.") + } + + if table.phase == "playing" { + // Mid-round forfeit โ€” player loses their bet + player := p.findPlayer(table, ctx.Sender) + if player == nil || player.Done { + return p.SendReply(ctx.RoomID, ctx.EventID, "You're not in an active hand.") + } + player.Bust = true + player.Done = true + name := p.bjDisplayName(ctx.Sender) + _ = p.SendMessage(ctx.RoomID, + fmt.Sprintf("๐Ÿณ๏ธ **%s** forfeits! Bet lost.", name)) + p.checkAllDone(ctx.RoomID, table) + return nil + } + + if table.phase != "joining" { + return p.SendReply(ctx.RoomID, ctx.EventID, "No table to leave.") } for i, pl := range table.players { @@ -357,6 +383,28 @@ func (p *BlackjackPlugin) handleLeave(ctx MessageContext) error { return p.SendReply(ctx.RoomID, ctx.EventID, "You're not at the table.") } +func (p *BlackjackPlugin) handleDeal(ctx MessageContext) error { + p.mu.Lock() + defer p.mu.Unlock() + + table, exists := p.tables[ctx.RoomID] + if !exists || table.phase != "joining" { + return p.SendReply(ctx.RoomID, ctx.EventID, "No table waiting to deal.") + } + + // Only a player at the table can force-start + if p.findPlayer(table, ctx.Sender) == nil { + return p.SendReply(ctx.RoomID, ctx.EventID, "You're not at the table.") + } + + if table.joinTimer != nil { + table.joinTimer.Stop() + } + _ = p.SendMessage(ctx.RoomID, "๐Ÿƒ Dealing!") + p.startRound(ctx.RoomID, table) + return nil +} + // startRound must be called with p.mu held. func (p *BlackjackPlugin) startRound(roomID id.RoomID, table *bjTable) { table.phase = "playing" @@ -379,9 +427,33 @@ func (p *BlackjackPlugin) startRound(roomID id.RoomID, table *bjTable) { // Display initial state _ = p.SendMessage(roomID, p.renderTable(table, false)) - // Find first active player - table.currentTurn = -1 - p.advanceTurn(roomID, table) + // Check if all players already have blackjack + allDone := true + var activeNames []string + for _, pl := range table.players { + if !pl.Done { + allDone = false + activeNames = append(activeNames, p.bjDisplayName(pl.UserID)) + } + } + + if allDone { + p.playDealer(roomID, table) + return + } + + _ = p.SendMessage(roomID, + fmt.Sprintf("๐Ÿ‘‰ **%s** โ€” `!hit` or `!stand` (%ds)", strings.Join(activeNames, "**, **"), p.cfg.TimeoutSeconds)) + p.startRoundTimer(roomID, table) +} + +func (p *BlackjackPlugin) findPlayer(table *bjTable, userID id.UserID) *bjPlayer { + for _, pl := range table.players { + if pl.UserID == userID { + return pl + } + } + return nil } func (p *BlackjackPlugin) handleHit(ctx MessageContext) error { @@ -393,19 +465,11 @@ func (p *BlackjackPlugin) handleHit(ctx MessageContext) error { return nil } - if table.currentTurn < 0 || table.currentTurn >= len(table.players) { + player := p.findPlayer(table, ctx.Sender) + if player == nil || player.Done { return nil } - player := table.players[table.currentTurn] - if player.UserID != ctx.Sender { - return nil // Not your turn - } - - if table.turnTimer != nil { - table.turnTimer.Stop() - } - player.Hand = append(player.Hand, table.deck.draw()) v := player.value() @@ -415,19 +479,20 @@ func (p *BlackjackPlugin) handleHit(ctx MessageContext) error { name := p.bjDisplayName(player.UserID) _ = p.SendMessage(ctx.RoomID, fmt.Sprintf("๐Ÿ’ฅ **%s** busts with %s (%d)!", name, handStr(player.Hand), v)) - p.advanceTurn(ctx.RoomID, table) + p.checkAllDone(ctx.RoomID, table) return nil } if v == 21 { player.Done = true - _ = p.SendMessage(ctx.RoomID, p.renderTable(table, false)) - p.advanceTurn(ctx.RoomID, table) + name := p.bjDisplayName(player.UserID) + _ = p.SendMessage(ctx.RoomID, + fmt.Sprintf("**%s** has 21! %s", name, handStr(player.Hand))) + p.checkAllDone(ctx.RoomID, table) return nil } _ = p.SendMessage(ctx.RoomID, p.renderTable(table, false)) - p.startTurnTimer(ctx.RoomID, table) return nil } @@ -440,86 +505,130 @@ func (p *BlackjackPlugin) handleStand(ctx MessageContext) error { return nil } - if table.currentTurn < 0 || table.currentTurn >= len(table.players) { + player := p.findPlayer(table, ctx.Sender) + if player == nil || player.Done { return nil } - player := table.players[table.currentTurn] - if player.UserID != ctx.Sender { - return nil - } - - if table.turnTimer != nil { - table.turnTimer.Stop() - } - player.Done = true - p.advanceTurn(ctx.RoomID, table) + name := p.bjDisplayName(player.UserID) + _ = p.SendMessage(ctx.RoomID, fmt.Sprintf("**%s** stands at %d.", name, player.value())) + p.checkAllDone(ctx.RoomID, table) return nil } -// advanceTurn moves to the next player or dealer. Must be called with p.mu held. -func (p *BlackjackPlugin) advanceTurn(roomID id.RoomID, table *bjTable) { - // Find next active player - for i := table.currentTurn + 1; i < len(table.players); i++ { - if !table.players[i].Done { - table.currentTurn = i - name := p.bjDisplayName(table.players[i].UserID) - _ = p.SendMessage(roomID, - fmt.Sprintf("๐Ÿ‘‰ **%s**'s turn. `!hit` or `!stand` (%ds)", name, p.cfg.TimeoutSeconds)) - p.startTurnTimer(roomID, table) - return +// 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) { + var waiting []string + for _, pl := range table.players { + if !pl.Done { + waiting = append(waiting, p.bjDisplayName(pl.UserID)) } } - // All players done โ€” dealer's turn + if len(waiting) > 0 { + _ = p.SendMessage(roomID, + fmt.Sprintf("โณ Waiting on: **%s**", strings.Join(waiting, "**, **"))) + return + } + + // All players done โ€” stop timers and go to dealer + p.stopRoundTimers(table) p.playDealer(roomID, table) } -func (p *BlackjackPlugin) startTurnTimer(roomID id.RoomID, table *bjTable) { - turnIdx := table.currentTurn - table.turnTimer = time.AfterFunc(time.Duration(p.cfg.TimeoutSeconds)*time.Second, func() { +// startRoundTimer starts a shared timeout for the round plus reminder nudges. Must be called with p.mu held. +func (p *BlackjackPlugin) startRoundTimer(roomID id.RoomID, table *bjTable) { + p.stopRoundTimers(table) + timeout := p.cfg.TimeoutSeconds + + // Schedule reminders at 30s and 10s before timeout + remindAts := []int{timeout - 30, timeout - 10} + for _, delay := range remindAts { + if delay < 5 { + continue + } + remaining := timeout - delay + t := time.AfterFunc(time.Duration(delay)*time.Second, func() { + p.mu.Lock() + defer p.mu.Unlock() + tbl, exists := p.tables[roomID] + if !exists || tbl != table || tbl.phase != "playing" { + return + } + var waiting []string + for _, pl := range tbl.players { + if !pl.Done { + waiting = append(waiting, p.bjDisplayName(pl.UserID)) + } + } + if len(waiting) > 0 { + _ = p.SendMessage(roomID, + fmt.Sprintf("โณ %ds left โ€” still waiting on: **%s**", remaining, strings.Join(waiting, "**, **"))) + } + }) + table.reminderTimers = append(table.reminderTimers, t) + } + + // Main timeout โ€” auto-play all remaining players + table.turnTimer = time.AfterFunc(time.Duration(timeout)*time.Second, func() { p.mu.Lock() defer p.mu.Unlock() - // Verify table still exists and it's the same turn t, exists := p.tables[roomID] - if !exists || t != table || t.currentTurn != turnIdx { + if !exists || t != table || t.phase != "playing" { return } - // Use looked-up t, not captured table pointer - player := t.players[turnIdx] - v := player.value() - name := p.bjDisplayName(player.UserID) + for _, pl := range t.players { + if pl.Done { + continue + } + v := pl.value() + name := p.bjDisplayName(pl.UserID) - if v >= p.cfg.AutoplayThreshold { - _ = p.SendMessage(roomID, - fmt.Sprintf("โฑ๏ธ **%s** timed out โ€” auto-playing (stand)", name)) - player.Done = true - p.advanceTurn(roomID, t) - } else { - _ = p.SendMessage(roomID, - fmt.Sprintf("โฑ๏ธ **%s** timed out โ€” auto-playing (hit)", name)) - player.Hand = append(player.Hand, t.deck.draw()) - v = player.value() - if v > 21 { - player.Bust = true - player.Done = true + if v >= p.cfg.AutoplayThreshold { _ = p.SendMessage(roomID, - fmt.Sprintf("๐Ÿ’ฅ **%s** busts with %s (%d)!", name, handStr(player.Hand), v)) - p.advanceTurn(roomID, t) - } else if v >= p.cfg.AutoplayThreshold { - player.Done = true - p.advanceTurn(roomID, t) + fmt.Sprintf("โฑ๏ธ **%s** timed out โ€” auto-playing (stand)", name)) + pl.Done = true } else { - // Still below threshold, restart timer - p.startTurnTimer(roomID, t) + // Keep hitting until they stand or bust + for v < p.cfg.AutoplayThreshold { + _ = p.SendMessage(roomID, + fmt.Sprintf("โฑ๏ธ **%s** timed out โ€” auto-playing (hit)", name)) + pl.Hand = append(pl.Hand, t.deck.draw()) + v = pl.value() + if v > 21 { + pl.Bust = true + pl.Done = true + _ = p.SendMessage(roomID, + fmt.Sprintf("๐Ÿ’ฅ **%s** busts with %s (%d)!", name, handStr(pl.Hand), v)) + break + } + } + if !pl.Done { + _ = p.SendMessage(roomID, + fmt.Sprintf("โฑ๏ธ **%s** auto-stands at %d.", name, v)) + pl.Done = true + } } } + + p.playDealer(roomID, t) }) } +func (p *BlackjackPlugin) stopRoundTimers(table *bjTable) { + if table.turnTimer != nil { + table.turnTimer.Stop() + table.turnTimer = nil + } + for _, t := range table.reminderTimers { + t.Stop() + } + table.reminderTimers = nil +} + // playDealer plays the dealer hand. Must be called with p.mu held. func (p *BlackjackPlugin) playDealer(roomID id.RoomID, table *bjTable) { // Check if all players busted @@ -575,7 +684,7 @@ func (p *BlackjackPlugin) resolveRound(roomID id.RoomID, table *bjTable) { case playerBJ: result = "Blackjack!" - payout = pl.Bet + pl.Bet*1.5 // Return bet + 1.5x + payout = pl.Bet + math.Floor(pl.Bet*1.5) // Return bet + 1.5x (rounded down) p.euro.Credit(pl.UserID, payout, "blackjack_win") case dealerBJ: @@ -604,9 +713,14 @@ func (p *BlackjackPlugin) resolveRound(roomID id.RoomID, table *bjTable) { net := payout - pl.Bet newBalance := p.euro.GetBalance(pl.UserID) - netStr := fmt.Sprintf("โ‚ฌ%d", int(net)) - if net > 0 { + var netStr string + switch { + case net > 0: netStr = fmt.Sprintf("+โ‚ฌ%d", int(net)) + case net < 0: + netStr = fmt.Sprintf("-โ‚ฌ%d", int(-net)) + default: + netStr = "โ‚ฌ0" } sb.WriteString(fmt.Sprintf("**%s**: %s %s โ€” %s (balance: โ‚ฌ%d)\n", @@ -619,9 +733,7 @@ func (p *BlackjackPlugin) resolveRound(roomID id.RoomID, table *bjTable) { sb.WriteString(fmt.Sprintf("\nDealer: %s (%d)\n", handStr(table.dealer), dealerValue)) // Stop any pending timers before cleanup - if table.turnTimer != nil { - table.turnTimer.Stop() - } + p.stopRoundTimers(table) if table.joinTimer != nil { table.joinTimer.Stop() } @@ -687,7 +799,7 @@ func (p *BlackjackPlugin) handleBoard(ctx MessageContext) error { rows.Scan(&userID, &earned, &played, &won) rank++ name := p.bjDisplayName(id.UserID(userID)) - sb.WriteString(fmt.Sprintf("%d. **%s** โ€” โ‚ฌ%d (%d/%d W/L)\n", rank, name, int(earned), won, played-won)) + sb.WriteString(fmt.Sprintf("%d. **%s** โ€” โ‚ฌ%d (%d W in %d games)\n", rank, name, int(earned), won, played)) } if rank == 0 { diff --git a/internal/plugin/uno_multi.go b/internal/plugin/uno_multi.go index 0d453c9..ce1fe3b 100644 --- a/internal/plugin/uno_multi.go +++ b/internal/plugin/uno_multi.go @@ -54,8 +54,9 @@ type unoMultiGame struct { done bool bookDown bool - timer *time.Timer - mu sync.Mutex // per-game lock + timer *time.Timer + inactiveTimer *time.Timer // 10-minute game timeout + mu sync.Mutex // per-game lock } type unoMultiLobby struct { @@ -233,10 +234,10 @@ func (p *UnoPlugin) handleMultiStart(ctx MessageContext, amountStr string) error } } } - p.mu.Unlock() - // Debit ante + // Debit ante while still holding the lock if !p.euro.Debit(ctx.Sender, amount, "uno_multi_ante") { + p.mu.Unlock() return p.SendReply(ctx.RoomID, ctx.EventID, "Insufficient balance for that ante.") } @@ -253,7 +254,6 @@ func (p *UnoPlugin) handleMultiStart(ctx MessageContext, amountStr string) error p.lobbyExpired(ctx.RoomID) }) - p.mu.Lock() p.lobbies[ctx.RoomID] = lobby p.mu.Unlock() @@ -303,14 +303,13 @@ func (p *UnoPlugin) handleMultiJoin(ctx MessageContext) error { } } } - p.mu.Unlock() - // Debit ante + // Debit ante while still holding the lock if !p.euro.Debit(ctx.Sender, lobby.ante, "uno_multi_ante") { + p.mu.Unlock() return p.SendReply(ctx.RoomID, ctx.EventID, "Insufficient balance for the ante.") } - p.mu.Lock() lobby.players = append(lobby.players, ctx.Sender) count := len(lobby.players) p.mu.Unlock() @@ -491,6 +490,7 @@ func (p *UnoPlugin) handleMultiGo(ctx MessageContext) error { // Start first turn game.mu.Lock() defer game.mu.Unlock() + p.startInactivityTimer(game) p.executeMultiTurn(game) return nil @@ -623,7 +623,7 @@ func (p *UnoPlugin) executeMultiTurn(game *unoMultiGame) { 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.", name)) + 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++ @@ -645,7 +645,7 @@ func (p *UnoPlugin) executeMultiTurn(game *unoMultiGame) { name := p.unoDisplayName(player.userID) p.SendMessage(player.dmRoomID, fmt.Sprintf("No playable cards โ€” drew automatically: %s\nNot playable. Turn passes.", card.Display())) - p.SendMessage(game.roomID, fmt.Sprintf("๐Ÿƒ %s draws a card. Turn passes.", name)) + p.SendMessage(game.roomID, fmt.Sprintf("๐Ÿƒ %s draws a card. Turn passes. (%d cards)", name, len(player.hand))) game.currentIdx = game.nextActiveIdx() game.turnID++ continue @@ -702,10 +702,11 @@ func (p *UnoPlugin) handleMultiDMInput(ctx MessageContext, game *unoMultiGame) e return nil // not this player's turn } - // Reset auto-play counter on manual input + // Reset auto-play counter and inactivity timer on manual input player.autoPlays = 0 + p.startInactivityTimer(game) - // Cancel timer + // Cancel turn timer if game.timer != nil { game.timer.Stop() } @@ -866,7 +867,7 @@ func (p *UnoPlugin) handleMultiPlayerDraw(game *unoMultiGame, player *unoMultiPl name := p.unoDisplayName(player.userID) p.SendMessage(player.dmRoomID, fmt.Sprintf("You drew: %s\nNot playable. Turn passes.", card.Display())) - p.SendMessage(game.roomID, fmt.Sprintf("๐Ÿƒ %s draws a card. Turn passes.", name)) + p.SendMessage(game.roomID, fmt.Sprintf("๐Ÿƒ %s draws a card. Turn passes. (%d cards)", name, len(player.hand))) p.advanceAndExecute(game) return nil } @@ -884,7 +885,7 @@ func (p *UnoPlugin) handleMultiDrawnPlayable(game *unoMultiGame, player *unoMult if input == "no" || input == "n" { name := p.unoDisplayName(player.userID) p.SendMessage(player.dmRoomID, "Card kept. Turn passes.") - p.SendMessage(game.roomID, fmt.Sprintf("๐Ÿƒ %s draws a card. Turn passes.", name)) + p.SendMessage(game.roomID, fmt.Sprintf("๐Ÿƒ %s draws a card. Turn passes. (%d cards)", name, len(player.hand))) p.advanceAndExecute(game) return nil } @@ -929,13 +930,18 @@ func (p *UnoPlugin) handleMultiDrawnPlayable(game *unoMultiGame, player *unoMult // Card effects & turn announcement // --------------------------------------------------------------------------- -func (p *UnoPlugin) applyAndAnnounce(game *unoMultiGame, player *unoMultiPlayer, card unoCard) { - name := p.unoDisplayName(player.userID) - var roomMsg strings.Builder +// cardEffectResult describes what happened when a card's effects were applied. +type cardEffectResult struct { + skippedName string // non-empty if a player was skipped + reversed bool // true if direction was reversed + drawnCount int // cards drawn by the victim (2 or 4) +} - roomMsg.WriteString(fmt.Sprintf("๐Ÿƒ %s plays: %s", name, card.DisplayWithColor(game.topColor))) +// applyCardEffects applies skip/reverse/draw effects and advances the turn. +// Caller must hold game.mu. +func (p *UnoPlugin) applyCardEffects(game *unoMultiGame, card unoCard) cardEffectResult { + var result cardEffectResult - // Determine next player for effects nextIdx := game.nextActiveIdx() nextPlayer := game.players[nextIdx] nextName := p.unoDisplayName(nextPlayer.userID) @@ -945,24 +951,20 @@ func (p *UnoPlugin) applyAndAnnounce(game *unoMultiGame, player *unoMultiPlayer, switch card.Value { case unoSkip: - // In multiplayer, skip always skips the next player - roomMsg.WriteString(fmt.Sprintf("\n %s is skipped!", nextName)) - // Advance past the skipped player + result.skippedName = nextName game.currentIdx = nextIdx game.currentIdx = game.nextActiveIdx() game.turnID++ case unoReverse: game.direction *= -1 - activePlayers := game.activePlayers() - if len(activePlayers) == 2 { - // 2-player: reverse = skip - roomMsg.WriteString(fmt.Sprintf("\n %s is skipped! (reverse)", nextName)) + result.reversed = true + if len(game.activePlayers()) == 2 { + result.skippedName = nextName game.currentIdx = nextIdx game.currentIdx = game.nextActiveIdx() game.turnID++ } else { - roomMsg.WriteString("\n Direction reversed!") game.currentIdx = game.nextActiveIdx() game.turnID++ } @@ -970,8 +972,8 @@ func (p *UnoPlugin) applyAndAnnounce(game *unoMultiGame, player *unoMultiPlayer, case unoDrawTwo: drawn := game.draw(2) nextPlayer.hand = append(nextPlayer.hand, drawn...) - roomMsg.WriteString(fmt.Sprintf("\n %s draws 2 and is skipped!", nextName)) - // Skip past the victim + result.skippedName = nextName + result.drawnCount = 2 game.currentIdx = nextIdx game.currentIdx = game.nextActiveIdx() game.turnID++ @@ -979,17 +981,42 @@ func (p *UnoPlugin) applyAndAnnounce(game *unoMultiGame, player *unoMultiPlayer, case unoWildDrawFour: drawn := game.draw(4) nextPlayer.hand = append(nextPlayer.hand, drawn...) - roomMsg.WriteString(fmt.Sprintf("\n %s draws 4 and is skipped!", nextName)) + result.skippedName = nextName + result.drawnCount = 4 game.currentIdx = nextIdx game.currentIdx = game.nextActiveIdx() game.turnID++ default: - // Normal card game.currentIdx = game.nextActiveIdx() game.turnID++ } + return result +} + +// writeEffectLines appends human-readable effect descriptions to a string builder. +func writeEffectLines(sb *strings.Builder, eff cardEffectResult) { + 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 { + sb.WriteString(fmt.Sprintf("\n %s is skipped! (reverse)", eff.skippedName)) + } else if eff.skippedName != "" { + sb.WriteString(fmt.Sprintf("\n %s is skipped!", eff.skippedName)) + } else if eff.reversed { + sb.WriteString("\n Direction reversed!") + } +} + +func (p *UnoPlugin) applyAndAnnounce(game *unoMultiGame, player *unoMultiPlayer, card unoCard) { + name := p.unoDisplayName(player.userID) + var roomMsg strings.Builder + + roomMsg.WriteString(fmt.Sprintf("๐Ÿƒ %s plays: %s", name, card.DisplayWithColor(game.topColor))) + + eff := p.applyCardEffects(game, card) + writeEffectLines(&roomMsg, eff) + // Book state commentary (occasionally) if game.turns%4 == 0 { if changed := game.updateBookState(); changed { @@ -1119,53 +1146,8 @@ func (p *UnoPlugin) botMultiTurn(game *unoMultiGame, roomBuf *strings.Builder) { } // Apply action effects - nextIdx := game.nextActiveIdx() - nextPlayer := game.players[nextIdx] - nextName := p.unoDisplayName(nextPlayer.userID) - if nextPlayer.isBot { - nextName = bn - } - - switch card.Value { - case unoSkip: - roomBuf.WriteString(fmt.Sprintf("\n %s is skipped!", nextName)) - game.currentIdx = nextIdx - game.currentIdx = game.nextActiveIdx() - game.turnID++ - - case unoReverse: - game.direction *= -1 - if len(game.activePlayers()) == 2 { - roomBuf.WriteString(fmt.Sprintf("\n %s is skipped! (reverse)", nextName)) - game.currentIdx = nextIdx - game.currentIdx = game.nextActiveIdx() - game.turnID++ - } else { - roomBuf.WriteString("\n Direction reversed!") - game.currentIdx = game.nextActiveIdx() - game.turnID++ - } - - case unoDrawTwo: - drawn := game.draw(2) - nextPlayer.hand = append(nextPlayer.hand, drawn...) - roomBuf.WriteString(fmt.Sprintf("\n %s draws 2 and is skipped!", nextName)) - game.currentIdx = nextIdx - game.currentIdx = game.nextActiveIdx() - game.turnID++ - - case unoWildDrawFour: - drawn := game.draw(4) - nextPlayer.hand = append(nextPlayer.hand, drawn...) - roomBuf.WriteString(fmt.Sprintf("\n %s draws 4 and is skipped!", nextName)) - game.currentIdx = nextIdx - game.currentIdx = game.nextActiveIdx() - game.turnID++ - - default: - game.currentIdx = game.nextActiveIdx() - game.turnID++ - } + eff := p.applyCardEffects(game, card) + writeEffectLines(roomBuf, eff) game.turns++ } @@ -1219,6 +1201,50 @@ func (p *UnoPlugin) startMultiAutoPlayTimer(game *unoMultiGame) { }) } +// startInactivityTimer starts (or resets) the 10-minute game timeout. +// If no human input occurs within the window, the game ends and remaining players are refunded. +// Caller must hold game.mu. +func (p *UnoPlugin) startInactivityTimer(game *unoMultiGame) { + if game.inactiveTimer != nil { + game.inactiveTimer.Stop() + } + + timeout := envInt("UNO_MULTI_INACTIVITY_TIMEOUT", 600) + gameID := game.id + + game.inactiveTimer = time.AfterFunc(time.Duration(timeout)*time.Second, func() { + p.mu.Lock() + mg, exists := p.multiGames[gameID] + p.mu.Unlock() + if !exists { + return + } + + mg.mu.Lock() + defer mg.mu.Unlock() + + if mg.done { + return + } + + mg.done = true + if mg.timer != nil { + mg.timer.Stop() + } + + // Refund remaining human players + for _, pl := range mg.players { + if !pl.isBot && pl.active { + p.euro.Credit(pl.userID, mg.ante, "uno_multi_timeout_refund") + } + } + + p.SendMessage(mg.roomID, "๐Ÿƒ **Game timed out** โ€” no human input for 10 minutes. All antes refunded.") + p.recordMultiGame(mg, id.UserID(""), "timeout") + p.cleanupMultiGame(mg) + }) +} + func (p *UnoPlugin) autoPlayMultiTurn(game *unoMultiGame, player *unoMultiPlayer) { name := p.unoDisplayName(player.userID) @@ -1393,48 +1419,14 @@ func (p *UnoPlugin) autoPlayMultiTurn(game *unoMultiGame, player *unoMultiPlayer } p.SendMessage(player.dmRoomID, fmt.Sprintf("*Auto-played:* Drew %s. Not playable. Turn passes.", card.Display())) - p.SendMessage(game.roomID, fmt.Sprintf("๐Ÿƒ *%s was auto-played.* Draws a card. Turn passes.", name)) + p.SendMessage(game.roomID, fmt.Sprintf("๐Ÿƒ *%s was auto-played.* Draws a card. Turn passes. (%d cards)", name, len(player.hand))) p.advanceAndExecute(game) } } // applyAutoEffects applies card effects after an auto-play and advances the turn. -func (p *UnoPlugin) applyAutoEffects(game *unoMultiGame, player *unoMultiPlayer, card unoCard) { - nextIdx := game.nextActiveIdx() - nextPlayer := game.players[nextIdx] - - switch card.Value { - case unoSkip: - game.currentIdx = nextIdx - game.currentIdx = game.nextActiveIdx() - game.turnID++ - case unoReverse: - game.direction *= -1 - if len(game.activePlayers()) == 2 { - game.currentIdx = nextIdx - game.currentIdx = game.nextActiveIdx() - game.turnID++ - } else { - game.currentIdx = game.nextActiveIdx() - game.turnID++ - } - case unoDrawTwo: - drawn := game.draw(2) - nextPlayer.hand = append(nextPlayer.hand, drawn...) - game.currentIdx = nextIdx - game.currentIdx = game.nextActiveIdx() - game.turnID++ - case unoWildDrawFour: - drawn := game.draw(4) - nextPlayer.hand = append(nextPlayer.hand, drawn...) - game.currentIdx = nextIdx - game.currentIdx = game.nextActiveIdx() - game.turnID++ - default: - game.currentIdx = game.nextActiveIdx() - game.turnID++ - } - +func (p *UnoPlugin) applyAutoEffects(game *unoMultiGame, _ *unoMultiPlayer, card unoCard) { + p.applyCardEffects(game, card) p.executeMultiTurn(game) } @@ -1451,6 +1443,9 @@ func (p *UnoPlugin) multiPlayerWins(game *unoMultiGame, winner *unoMultiPlayer) if game.timer != nil { game.timer.Stop() } + if game.inactiveTimer != nil { + game.inactiveTimer.Stop() + } // Calculate pot (all human antes) humanCount := 0 @@ -1481,6 +1476,9 @@ func (p *UnoPlugin) multiBotWins(game *unoMultiGame) { if game.timer != nil { game.timer.Stop() } + if game.inactiveTimer != nil { + game.inactiveTimer.Stop() + } humanCount := 0 for _, pl := range game.players {