mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
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:
@@ -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 {
|
||||
@@ -135,7 +132,7 @@ type bjTable struct {
|
||||
deck *deck
|
||||
joinTimer *time.Timer
|
||||
turnTimer *time.Timer
|
||||
currentTurn int
|
||||
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 {
|
||||
|
||||
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(maxAvailable), int(balance)))
|
||||
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)
|
||||
pl.Done = true
|
||||
} else {
|
||||
// Keep hitting until they stand or bust
|
||||
for v < p.cfg.AutoplayThreshold {
|
||||
_ = p.SendMessage(roomID,
|
||||
fmt.Sprintf("⏱️ **%s** timed out — auto-playing (hit)", name))
|
||||
player.Hand = append(player.Hand, t.deck.draw())
|
||||
v = player.value()
|
||||
pl.Hand = append(pl.Hand, t.deck.draw())
|
||||
v = pl.value()
|
||||
if v > 21 {
|
||||
player.Bust = true
|
||||
player.Done = true
|
||||
pl.Bust = true
|
||||
pl.Done = true
|
||||
_ = 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)
|
||||
} else {
|
||||
// Still below threshold, restart timer
|
||||
p.startTurnTimer(roomID, t)
|
||||
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 {
|
||||
|
||||
@@ -55,6 +55,7 @@ type unoMultiGame struct {
|
||||
bookDown bool
|
||||
|
||||
timer *time.Timer
|
||||
inactiveTimer *time.Timer // 10-minute game timeout
|
||||
mu sync.Mutex // per-game lock
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user