Fix Blackjack bugs and UNO multiplayer issues, add simultaneous play

Blackjack:
- Fix handValue soft flag for multi-ace hands (A+A+5 returned soft=false,
  causing dealer to incorrectly stand on soft 17)
- Fix loss display showing €-50 instead of -€50
- Round down Blackjack 1.5x payout per requirements (math.Floor)
- Restrict !bjboard to games room like all other game commands
- Fix leaderboard showing misleading W/L (pushes counted as losses)
- Notify user when bet is capped to max instead of silently capping
- Fix bet max message to use min(MaxBet, maxAvailable)
- Rework to simultaneous play: all players hit/stand independently
  with one shared round timer, reminders at 30s and 10s remaining
- Add !blackjack deal to skip the 60s join wait
- Add mid-round forfeit via !blackjack leave during play
- Set minimum 5s delay for reminder timers to avoid spam on low timeouts

Multiplayer UNO:
- Fix lobby race conditions: move euro debit inside mutex lock to
  prevent lobby overwrites and missed refunds
- Extract shared applyCardEffects to deduplicate card effect logic
  from applyAndAnnounce, botMultiTurn, and applyAutoEffects
- Add 10-minute game inactivity timeout with refunds (was in
  requirements but not implemented)
- Add card count to all "draws a card, turn passes" room messages
  so spectators can track hand sizes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
prosolis
2026-03-19 21:59:57 -07:00
parent 265a9976cf
commit b9237181b1
2 changed files with 325 additions and 215 deletions

View File

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

View File

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