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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"math"
|
||||||
"math/rand/v2"
|
"math/rand/v2"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -85,15 +86,11 @@ func handValue(cards []card) (int, bool) {
|
|||||||
total += c.value()
|
total += c.value()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
soft := aces > 0 && total <= 21
|
|
||||||
for total > 21 && aces > 0 {
|
for total > 21 && aces > 0 {
|
||||||
total -= 10
|
total -= 10
|
||||||
aces--
|
aces--
|
||||||
}
|
}
|
||||||
if aces == 0 {
|
return total, aces > 0
|
||||||
soft = false
|
|
||||||
}
|
|
||||||
return total, soft
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func handStr(cards []card) string {
|
func handStr(cards []card) string {
|
||||||
@@ -130,14 +127,14 @@ func (p *bjPlayer) value() int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type bjTable struct {
|
type bjTable struct {
|
||||||
players []*bjPlayer
|
players []*bjPlayer
|
||||||
dealer []card
|
dealer []card
|
||||||
deck *deck
|
deck *deck
|
||||||
joinTimer *time.Timer
|
joinTimer *time.Timer
|
||||||
turnTimer *time.Timer
|
turnTimer *time.Timer
|
||||||
currentTurn int
|
reminderTimers []*time.Timer
|
||||||
phase string // "joining", "playing", "done"
|
phase string // "joining", "playing", "done"
|
||||||
roomID id.RoomID
|
roomID id.RoomID
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -187,7 +184,7 @@ func (p *BlackjackPlugin) Name() string { return "blackjack" }
|
|||||||
|
|
||||||
func (p *BlackjackPlugin) Commands() []CommandDef {
|
func (p *BlackjackPlugin) Commands() []CommandDef {
|
||||||
return []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: "hit", Description: "Take a card in Blackjack", Usage: "!hit", Category: "Games"},
|
||||||
{Name: "stand", Description: "End your turn in Blackjack", Usage: "!stand", Category: "Games"},
|
{Name: "stand", Description: "End your turn in Blackjack", Usage: "!stand", Category: "Games"},
|
||||||
{Name: "bjboard", Description: "Blackjack leaderboard", Usage: "!bjboard", 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 {
|
func (p *BlackjackPlugin) OnMessage(ctx MessageContext) error {
|
||||||
switch {
|
switch {
|
||||||
case p.IsCommand(ctx.Body, "bjboard"):
|
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)
|
return p.handleBoard(ctx)
|
||||||
case p.IsCommand(ctx.Body, "blackjack"):
|
case p.IsCommand(ctx.Body, "blackjack"):
|
||||||
if !isGamesRoom(ctx.RoomID) {
|
if !isGamesRoom(ctx.RoomID) {
|
||||||
@@ -232,6 +232,10 @@ func (p *BlackjackPlugin) handleBlackjack(ctx MessageContext) error {
|
|||||||
return p.handleLeave(ctx)
|
return p.handleLeave(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if strings.EqualFold(args, "deal") {
|
||||||
|
return p.handleDeal(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
// Parse bet amount
|
// Parse bet amount
|
||||||
amountStr := strings.TrimPrefix(args, "€")
|
amountStr := strings.TrimPrefix(args, "€")
|
||||||
var bet float64
|
var bet float64
|
||||||
@@ -244,10 +248,6 @@ func (p *BlackjackPlugin) handleBlackjack(ctx MessageContext) error {
|
|||||||
return p.SendReply(ctx.RoomID, ctx.EventID,
|
return p.SendReply(ctx.RoomID, ctx.EventID,
|
||||||
fmt.Sprintf("Minimum bet is €%d.", int(p.cfg.MinBet)))
|
fmt.Sprintf("Minimum bet is €%d.", int(p.cfg.MinBet)))
|
||||||
}
|
}
|
||||||
if bet > p.cfg.MaxBet {
|
|
||||||
bet = p.cfg.MaxBet
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check balance
|
// Check balance
|
||||||
balance := p.euro.GetBalance(ctx.Sender)
|
balance := p.euro.GetBalance(ctx.Sender)
|
||||||
maxAvailable := balance + p.cfg.DebtLimit
|
maxAvailable := balance + p.cfg.DebtLimit
|
||||||
@@ -255,9 +255,16 @@ func (p *BlackjackPlugin) handleBlackjack(ctx MessageContext) error {
|
|||||||
return p.SendReply(ctx.RoomID, ctx.EventID,
|
return p.SendReply(ctx.RoomID, ctx.EventID,
|
||||||
"🚫 You're at your debt limit. Earn some euros before playing.")
|
"🚫 You're at your debt limit. Earn some euros before playing.")
|
||||||
}
|
}
|
||||||
if bet > maxAvailable {
|
|
||||||
return p.SendReply(ctx.RoomID, ctx.EventID,
|
maxBet := min(p.cfg.MaxBet, maxAvailable)
|
||||||
fmt.Sprintf("You can bet up to €%d (balance: €%d).", int(maxAvailable), int(balance)))
|
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()
|
p.mu.Lock()
|
||||||
@@ -311,7 +318,7 @@ func (p *BlackjackPlugin) handleBlackjack(ctx MessageContext) error {
|
|||||||
|
|
||||||
name := p.bjDisplayName(ctx.Sender)
|
name := p.bjDisplayName(ctx.Sender)
|
||||||
_ = p.SendMessage(ctx.RoomID,
|
_ = 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)))
|
name, int(bet)))
|
||||||
|
|
||||||
// Start join timer
|
// Start join timer
|
||||||
@@ -331,8 +338,27 @@ func (p *BlackjackPlugin) handleLeave(ctx MessageContext) error {
|
|||||||
defer p.mu.Unlock()
|
defer p.mu.Unlock()
|
||||||
|
|
||||||
table, exists := p.tables[ctx.RoomID]
|
table, exists := p.tables[ctx.RoomID]
|
||||||
if !exists || table.phase != "joining" {
|
if !exists {
|
||||||
return p.SendReply(ctx.RoomID, ctx.EventID, "No table to leave, or the round has started.")
|
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 {
|
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.")
|
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.
|
// startRound must be called with p.mu held.
|
||||||
func (p *BlackjackPlugin) startRound(roomID id.RoomID, table *bjTable) {
|
func (p *BlackjackPlugin) startRound(roomID id.RoomID, table *bjTable) {
|
||||||
table.phase = "playing"
|
table.phase = "playing"
|
||||||
@@ -379,9 +427,33 @@ func (p *BlackjackPlugin) startRound(roomID id.RoomID, table *bjTable) {
|
|||||||
// Display initial state
|
// Display initial state
|
||||||
_ = p.SendMessage(roomID, p.renderTable(table, false))
|
_ = p.SendMessage(roomID, p.renderTable(table, false))
|
||||||
|
|
||||||
// Find first active player
|
// Check if all players already have blackjack
|
||||||
table.currentTurn = -1
|
allDone := true
|
||||||
p.advanceTurn(roomID, table)
|
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 {
|
func (p *BlackjackPlugin) handleHit(ctx MessageContext) error {
|
||||||
@@ -393,19 +465,11 @@ func (p *BlackjackPlugin) handleHit(ctx MessageContext) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if table.currentTurn < 0 || table.currentTurn >= len(table.players) {
|
player := p.findPlayer(table, ctx.Sender)
|
||||||
|
if player == nil || player.Done {
|
||||||
return nil
|
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())
|
player.Hand = append(player.Hand, table.deck.draw())
|
||||||
v := player.value()
|
v := player.value()
|
||||||
|
|
||||||
@@ -415,19 +479,20 @@ func (p *BlackjackPlugin) handleHit(ctx MessageContext) error {
|
|||||||
name := p.bjDisplayName(player.UserID)
|
name := p.bjDisplayName(player.UserID)
|
||||||
_ = p.SendMessage(ctx.RoomID,
|
_ = p.SendMessage(ctx.RoomID,
|
||||||
fmt.Sprintf("💥 **%s** busts with %s (%d)!", name, handStr(player.Hand), v))
|
fmt.Sprintf("💥 **%s** busts with %s (%d)!", name, handStr(player.Hand), v))
|
||||||
p.advanceTurn(ctx.RoomID, table)
|
p.checkAllDone(ctx.RoomID, table)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if v == 21 {
|
if v == 21 {
|
||||||
player.Done = true
|
player.Done = true
|
||||||
_ = p.SendMessage(ctx.RoomID, p.renderTable(table, false))
|
name := p.bjDisplayName(player.UserID)
|
||||||
p.advanceTurn(ctx.RoomID, table)
|
_ = p.SendMessage(ctx.RoomID,
|
||||||
|
fmt.Sprintf("**%s** has 21! %s", name, handStr(player.Hand)))
|
||||||
|
p.checkAllDone(ctx.RoomID, table)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
_ = p.SendMessage(ctx.RoomID, p.renderTable(table, false))
|
_ = p.SendMessage(ctx.RoomID, p.renderTable(table, false))
|
||||||
p.startTurnTimer(ctx.RoomID, table)
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -440,86 +505,130 @@ func (p *BlackjackPlugin) handleStand(ctx MessageContext) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if table.currentTurn < 0 || table.currentTurn >= len(table.players) {
|
player := p.findPlayer(table, ctx.Sender)
|
||||||
|
if player == nil || player.Done {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
player := table.players[table.currentTurn]
|
|
||||||
if player.UserID != ctx.Sender {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if table.turnTimer != nil {
|
|
||||||
table.turnTimer.Stop()
|
|
||||||
}
|
|
||||||
|
|
||||||
player.Done = true
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// advanceTurn moves to the next player or dealer. Must be called with p.mu held.
|
// checkAllDone checks if all players are done and triggers the dealer. Must be called with p.mu held.
|
||||||
func (p *BlackjackPlugin) advanceTurn(roomID id.RoomID, table *bjTable) {
|
func (p *BlackjackPlugin) checkAllDone(roomID id.RoomID, table *bjTable) {
|
||||||
// Find next active player
|
var waiting []string
|
||||||
for i := table.currentTurn + 1; i < len(table.players); i++ {
|
for _, pl := range table.players {
|
||||||
if !table.players[i].Done {
|
if !pl.Done {
|
||||||
table.currentTurn = i
|
waiting = append(waiting, p.bjDisplayName(pl.UserID))
|
||||||
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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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)
|
p.playDealer(roomID, table)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *BlackjackPlugin) startTurnTimer(roomID id.RoomID, table *bjTable) {
|
// startRoundTimer starts a shared timeout for the round plus reminder nudges. Must be called with p.mu held.
|
||||||
turnIdx := table.currentTurn
|
func (p *BlackjackPlugin) startRoundTimer(roomID id.RoomID, table *bjTable) {
|
||||||
table.turnTimer = time.AfterFunc(time.Duration(p.cfg.TimeoutSeconds)*time.Second, func() {
|
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()
|
p.mu.Lock()
|
||||||
defer p.mu.Unlock()
|
defer p.mu.Unlock()
|
||||||
|
|
||||||
// Verify table still exists and it's the same turn
|
|
||||||
t, exists := p.tables[roomID]
|
t, exists := p.tables[roomID]
|
||||||
if !exists || t != table || t.currentTurn != turnIdx {
|
if !exists || t != table || t.phase != "playing" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use looked-up t, not captured table pointer
|
for _, pl := range t.players {
|
||||||
player := t.players[turnIdx]
|
if pl.Done {
|
||||||
v := player.value()
|
continue
|
||||||
name := p.bjDisplayName(player.UserID)
|
}
|
||||||
|
v := pl.value()
|
||||||
|
name := p.bjDisplayName(pl.UserID)
|
||||||
|
|
||||||
if v >= p.cfg.AutoplayThreshold {
|
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
|
|
||||||
_ = p.SendMessage(roomID,
|
_ = p.SendMessage(roomID,
|
||||||
fmt.Sprintf("💥 **%s** busts with %s (%d)!", name, handStr(player.Hand), v))
|
fmt.Sprintf("⏱️ **%s** timed out — auto-playing (stand)", name))
|
||||||
p.advanceTurn(roomID, t)
|
pl.Done = true
|
||||||
} else if v >= p.cfg.AutoplayThreshold {
|
|
||||||
player.Done = true
|
|
||||||
p.advanceTurn(roomID, t)
|
|
||||||
} else {
|
} else {
|
||||||
// Still below threshold, restart timer
|
// Keep hitting until they stand or bust
|
||||||
p.startTurnTimer(roomID, t)
|
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.
|
// playDealer plays the dealer hand. Must be called with p.mu held.
|
||||||
func (p *BlackjackPlugin) playDealer(roomID id.RoomID, table *bjTable) {
|
func (p *BlackjackPlugin) playDealer(roomID id.RoomID, table *bjTable) {
|
||||||
// Check if all players busted
|
// Check if all players busted
|
||||||
@@ -575,7 +684,7 @@ func (p *BlackjackPlugin) resolveRound(roomID id.RoomID, table *bjTable) {
|
|||||||
|
|
||||||
case playerBJ:
|
case playerBJ:
|
||||||
result = "Blackjack!"
|
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")
|
p.euro.Credit(pl.UserID, payout, "blackjack_win")
|
||||||
|
|
||||||
case dealerBJ:
|
case dealerBJ:
|
||||||
@@ -604,9 +713,14 @@ func (p *BlackjackPlugin) resolveRound(roomID id.RoomID, table *bjTable) {
|
|||||||
|
|
||||||
net := payout - pl.Bet
|
net := payout - pl.Bet
|
||||||
newBalance := p.euro.GetBalance(pl.UserID)
|
newBalance := p.euro.GetBalance(pl.UserID)
|
||||||
netStr := fmt.Sprintf("€%d", int(net))
|
var netStr string
|
||||||
if net > 0 {
|
switch {
|
||||||
|
case net > 0:
|
||||||
netStr = fmt.Sprintf("+€%d", int(net))
|
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",
|
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))
|
sb.WriteString(fmt.Sprintf("\nDealer: %s (%d)\n", handStr(table.dealer), dealerValue))
|
||||||
|
|
||||||
// Stop any pending timers before cleanup
|
// Stop any pending timers before cleanup
|
||||||
if table.turnTimer != nil {
|
p.stopRoundTimers(table)
|
||||||
table.turnTimer.Stop()
|
|
||||||
}
|
|
||||||
if table.joinTimer != nil {
|
if table.joinTimer != nil {
|
||||||
table.joinTimer.Stop()
|
table.joinTimer.Stop()
|
||||||
}
|
}
|
||||||
@@ -687,7 +799,7 @@ func (p *BlackjackPlugin) handleBoard(ctx MessageContext) error {
|
|||||||
rows.Scan(&userID, &earned, &played, &won)
|
rows.Scan(&userID, &earned, &played, &won)
|
||||||
rank++
|
rank++
|
||||||
name := p.bjDisplayName(id.UserID(userID))
|
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 {
|
if rank == 0 {
|
||||||
|
|||||||
@@ -54,8 +54,9 @@ type unoMultiGame struct {
|
|||||||
done bool
|
done bool
|
||||||
bookDown bool
|
bookDown bool
|
||||||
|
|
||||||
timer *time.Timer
|
timer *time.Timer
|
||||||
mu sync.Mutex // per-game lock
|
inactiveTimer *time.Timer // 10-minute game timeout
|
||||||
|
mu sync.Mutex // per-game lock
|
||||||
}
|
}
|
||||||
|
|
||||||
type unoMultiLobby struct {
|
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") {
|
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.")
|
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.lobbyExpired(ctx.RoomID)
|
||||||
})
|
})
|
||||||
|
|
||||||
p.mu.Lock()
|
|
||||||
p.lobbies[ctx.RoomID] = lobby
|
p.lobbies[ctx.RoomID] = lobby
|
||||||
p.mu.Unlock()
|
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") {
|
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.")
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Insufficient balance for the ante.")
|
||||||
}
|
}
|
||||||
|
|
||||||
p.mu.Lock()
|
|
||||||
lobby.players = append(lobby.players, ctx.Sender)
|
lobby.players = append(lobby.players, ctx.Sender)
|
||||||
count := len(lobby.players)
|
count := len(lobby.players)
|
||||||
p.mu.Unlock()
|
p.mu.Unlock()
|
||||||
@@ -491,6 +490,7 @@ func (p *UnoPlugin) handleMultiGo(ctx MessageContext) error {
|
|||||||
// Start first turn
|
// Start first turn
|
||||||
game.mu.Lock()
|
game.mu.Lock()
|
||||||
defer game.mu.Unlock()
|
defer game.mu.Unlock()
|
||||||
|
p.startInactivityTimer(game)
|
||||||
p.executeMultiTurn(game)
|
p.executeMultiTurn(game)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -623,7 +623,7 @@ func (p *UnoPlugin) executeMultiTurn(game *unoMultiGame) {
|
|||||||
if len(drawn) == 0 {
|
if len(drawn) == 0 {
|
||||||
// Empty deck, no playable cards — pass
|
// Empty deck, no playable cards — pass
|
||||||
name := p.unoDisplayName(player.userID)
|
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.")
|
p.SendMessage(player.dmRoomID, "No playable cards and deck is empty. Turn passes.")
|
||||||
game.currentIdx = game.nextActiveIdx()
|
game.currentIdx = game.nextActiveIdx()
|
||||||
game.turnID++
|
game.turnID++
|
||||||
@@ -645,7 +645,7 @@ func (p *UnoPlugin) executeMultiTurn(game *unoMultiGame) {
|
|||||||
name := p.unoDisplayName(player.userID)
|
name := p.unoDisplayName(player.userID)
|
||||||
p.SendMessage(player.dmRoomID,
|
p.SendMessage(player.dmRoomID,
|
||||||
fmt.Sprintf("No playable cards — drew automatically: %s\nNot playable. Turn passes.", card.Display()))
|
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.currentIdx = game.nextActiveIdx()
|
||||||
game.turnID++
|
game.turnID++
|
||||||
continue
|
continue
|
||||||
@@ -702,10 +702,11 @@ func (p *UnoPlugin) handleMultiDMInput(ctx MessageContext, game *unoMultiGame) e
|
|||||||
return nil // not this player's turn
|
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
|
player.autoPlays = 0
|
||||||
|
p.startInactivityTimer(game)
|
||||||
|
|
||||||
// Cancel timer
|
// Cancel turn timer
|
||||||
if game.timer != nil {
|
if game.timer != nil {
|
||||||
game.timer.Stop()
|
game.timer.Stop()
|
||||||
}
|
}
|
||||||
@@ -866,7 +867,7 @@ func (p *UnoPlugin) handleMultiPlayerDraw(game *unoMultiGame, player *unoMultiPl
|
|||||||
|
|
||||||
name := p.unoDisplayName(player.userID)
|
name := p.unoDisplayName(player.userID)
|
||||||
p.SendMessage(player.dmRoomID, fmt.Sprintf("You drew: %s\nNot playable. Turn passes.", card.Display()))
|
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)
|
p.advanceAndExecute(game)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -884,7 +885,7 @@ func (p *UnoPlugin) handleMultiDrawnPlayable(game *unoMultiGame, player *unoMult
|
|||||||
if input == "no" || input == "n" {
|
if input == "no" || input == "n" {
|
||||||
name := p.unoDisplayName(player.userID)
|
name := p.unoDisplayName(player.userID)
|
||||||
p.SendMessage(player.dmRoomID, "Card kept. Turn passes.")
|
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)
|
p.advanceAndExecute(game)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -929,13 +930,18 @@ func (p *UnoPlugin) handleMultiDrawnPlayable(game *unoMultiGame, player *unoMult
|
|||||||
// Card effects & turn announcement
|
// Card effects & turn announcement
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
func (p *UnoPlugin) applyAndAnnounce(game *unoMultiGame, player *unoMultiPlayer, card unoCard) {
|
// cardEffectResult describes what happened when a card's effects were applied.
|
||||||
name := p.unoDisplayName(player.userID)
|
type cardEffectResult struct {
|
||||||
var roomMsg strings.Builder
|
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()
|
nextIdx := game.nextActiveIdx()
|
||||||
nextPlayer := game.players[nextIdx]
|
nextPlayer := game.players[nextIdx]
|
||||||
nextName := p.unoDisplayName(nextPlayer.userID)
|
nextName := p.unoDisplayName(nextPlayer.userID)
|
||||||
@@ -945,24 +951,20 @@ func (p *UnoPlugin) applyAndAnnounce(game *unoMultiGame, player *unoMultiPlayer,
|
|||||||
|
|
||||||
switch card.Value {
|
switch card.Value {
|
||||||
case unoSkip:
|
case unoSkip:
|
||||||
// In multiplayer, skip always skips the next player
|
result.skippedName = nextName
|
||||||
roomMsg.WriteString(fmt.Sprintf("\n %s is skipped!", nextName))
|
|
||||||
// Advance past the skipped player
|
|
||||||
game.currentIdx = nextIdx
|
game.currentIdx = nextIdx
|
||||||
game.currentIdx = game.nextActiveIdx()
|
game.currentIdx = game.nextActiveIdx()
|
||||||
game.turnID++
|
game.turnID++
|
||||||
|
|
||||||
case unoReverse:
|
case unoReverse:
|
||||||
game.direction *= -1
|
game.direction *= -1
|
||||||
activePlayers := game.activePlayers()
|
result.reversed = true
|
||||||
if len(activePlayers) == 2 {
|
if len(game.activePlayers()) == 2 {
|
||||||
// 2-player: reverse = skip
|
result.skippedName = nextName
|
||||||
roomMsg.WriteString(fmt.Sprintf("\n %s is skipped! (reverse)", nextName))
|
|
||||||
game.currentIdx = nextIdx
|
game.currentIdx = nextIdx
|
||||||
game.currentIdx = game.nextActiveIdx()
|
game.currentIdx = game.nextActiveIdx()
|
||||||
game.turnID++
|
game.turnID++
|
||||||
} else {
|
} else {
|
||||||
roomMsg.WriteString("\n Direction reversed!")
|
|
||||||
game.currentIdx = game.nextActiveIdx()
|
game.currentIdx = game.nextActiveIdx()
|
||||||
game.turnID++
|
game.turnID++
|
||||||
}
|
}
|
||||||
@@ -970,8 +972,8 @@ func (p *UnoPlugin) applyAndAnnounce(game *unoMultiGame, player *unoMultiPlayer,
|
|||||||
case unoDrawTwo:
|
case unoDrawTwo:
|
||||||
drawn := game.draw(2)
|
drawn := game.draw(2)
|
||||||
nextPlayer.hand = append(nextPlayer.hand, drawn...)
|
nextPlayer.hand = append(nextPlayer.hand, drawn...)
|
||||||
roomMsg.WriteString(fmt.Sprintf("\n %s draws 2 and is skipped!", nextName))
|
result.skippedName = nextName
|
||||||
// Skip past the victim
|
result.drawnCount = 2
|
||||||
game.currentIdx = nextIdx
|
game.currentIdx = nextIdx
|
||||||
game.currentIdx = game.nextActiveIdx()
|
game.currentIdx = game.nextActiveIdx()
|
||||||
game.turnID++
|
game.turnID++
|
||||||
@@ -979,17 +981,42 @@ func (p *UnoPlugin) applyAndAnnounce(game *unoMultiGame, player *unoMultiPlayer,
|
|||||||
case unoWildDrawFour:
|
case unoWildDrawFour:
|
||||||
drawn := game.draw(4)
|
drawn := game.draw(4)
|
||||||
nextPlayer.hand = append(nextPlayer.hand, drawn...)
|
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 = nextIdx
|
||||||
game.currentIdx = game.nextActiveIdx()
|
game.currentIdx = game.nextActiveIdx()
|
||||||
game.turnID++
|
game.turnID++
|
||||||
|
|
||||||
default:
|
default:
|
||||||
// Normal card
|
|
||||||
game.currentIdx = game.nextActiveIdx()
|
game.currentIdx = game.nextActiveIdx()
|
||||||
game.turnID++
|
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)
|
// Book state commentary (occasionally)
|
||||||
if game.turns%4 == 0 {
|
if game.turns%4 == 0 {
|
||||||
if changed := game.updateBookState(); changed {
|
if changed := game.updateBookState(); changed {
|
||||||
@@ -1119,53 +1146,8 @@ func (p *UnoPlugin) botMultiTurn(game *unoMultiGame, roomBuf *strings.Builder) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Apply action effects
|
// Apply action effects
|
||||||
nextIdx := game.nextActiveIdx()
|
eff := p.applyCardEffects(game, card)
|
||||||
nextPlayer := game.players[nextIdx]
|
writeEffectLines(roomBuf, eff)
|
||||||
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++
|
|
||||||
}
|
|
||||||
|
|
||||||
game.turns++
|
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) {
|
func (p *UnoPlugin) autoPlayMultiTurn(game *unoMultiGame, player *unoMultiPlayer) {
|
||||||
name := p.unoDisplayName(player.userID)
|
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(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)
|
p.advanceAndExecute(game)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// applyAutoEffects applies card effects after an auto-play and advances the turn.
|
// applyAutoEffects applies card effects after an auto-play and advances the turn.
|
||||||
func (p *UnoPlugin) applyAutoEffects(game *unoMultiGame, player *unoMultiPlayer, card unoCard) {
|
func (p *UnoPlugin) applyAutoEffects(game *unoMultiGame, _ *unoMultiPlayer, card unoCard) {
|
||||||
nextIdx := game.nextActiveIdx()
|
p.applyCardEffects(game, card)
|
||||||
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++
|
|
||||||
}
|
|
||||||
|
|
||||||
p.executeMultiTurn(game)
|
p.executeMultiTurn(game)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1451,6 +1443,9 @@ func (p *UnoPlugin) multiPlayerWins(game *unoMultiGame, winner *unoMultiPlayer)
|
|||||||
if game.timer != nil {
|
if game.timer != nil {
|
||||||
game.timer.Stop()
|
game.timer.Stop()
|
||||||
}
|
}
|
||||||
|
if game.inactiveTimer != nil {
|
||||||
|
game.inactiveTimer.Stop()
|
||||||
|
}
|
||||||
|
|
||||||
// Calculate pot (all human antes)
|
// Calculate pot (all human antes)
|
||||||
humanCount := 0
|
humanCount := 0
|
||||||
@@ -1481,6 +1476,9 @@ func (p *UnoPlugin) multiBotWins(game *unoMultiGame) {
|
|||||||
if game.timer != nil {
|
if game.timer != nil {
|
||||||
game.timer.Stop()
|
game.timer.Stop()
|
||||||
}
|
}
|
||||||
|
if game.inactiveTimer != nil {
|
||||||
|
game.inactiveTimer.Stop()
|
||||||
|
}
|
||||||
|
|
||||||
humanCount := 0
|
humanCount := 0
|
||||||
for _, pl := range game.players {
|
for _, pl := range game.players {
|
||||||
|
|||||||
Reference in New Issue
Block a user