diff options
| -rw-r--r-- | internal/casino/baccarat.go | 123 | ||||
| -rw-r--r-- | internal/casino/baccarat_test.go | 37 | ||||
| -rw-r--r-- | internal/casino/betting.go | 24 | ||||
| -rw-r--r-- | internal/casino/blackjack.go | 188 | ||||
| -rw-r--r-- | internal/casino/blackjack_test.go | 145 | ||||
| -rw-r--r-- | internal/casino/cards.go | 8 | ||||
| -rw-r--r-- | internal/casino/machine.go | 57 | ||||
| -rw-r--r-- | internal/casino/machine_test.go | 53 | ||||
| -rw-r--r-- | internal/casino/manager.go | 137 | ||||
| -rw-r--r-- | internal/casino/manager_test.go | 52 | ||||
| -rw-r--r-- | internal/casino/seats.go | 83 | ||||
| -rw-r--r-- | internal/casino/slots.go | 34 | ||||
| -rw-r--r-- | internal/casino/table.go | 211 | ||||
| -rw-r--r-- | internal/game/casino.go | 24 | ||||
| -rw-r--r-- | internal/game/casino_test.go | 6 | ||||
| -rw-r--r-- | internal/game/cmd_quit.go | 1 | ||||
| -rw-r--r-- | internal/game/core_login_char.go | 1 |
17 files changed, 585 insertions, 599 deletions
diff --git a/internal/casino/baccarat.go b/internal/casino/baccarat.go index 4261270..6a72caa 100644 --- a/internal/casino/baccarat.go +++ b/internal/casino/baccarat.go @@ -31,15 +31,16 @@ type baccaratPlayer struct { lastSpot string } +func (p *baccaratPlayer) participantPtr() *Participant { return &p.Participant } + type baccaratTable struct { mu sync.Mutex tableBase - Rules BaccaratConfig - participants map[string]*baccaratPlayer - order []string - phase baccaratPhase - timer countdown - shoe *shoe + Rules BaccaratConfig + players seatSet[*baccaratPlayer] + phase baccaratPhase + window bettingWindow + shoe *shoe } func newBaccaratTable(roomID int, cfg TableConfig, rng *rand.Rand) *baccaratTable { @@ -47,21 +48,11 @@ func newBaccaratTable(roomID int, cfg TableConfig, rng *rand.Rand) *baccaratTabl rules := cfg.Baccarat.Normalize() return &baccaratTable{ tableBase: tableBase{RoomID: roomID, Config: cfg}, Rules: rules, - participants: make(map[string]*baccaratPlayer), phase: baccaratWaiting, + players: newSeatSet[*baccaratPlayer](), phase: baccaratWaiting, shoe: newShoe(rules.Decks, rules.ShuffleAt, rng), } } -func (t *baccaratTable) seats() []*Participant { - seats := make([]*Participant, 0, len(t.order)) - for _, name := range t.order { - if p := t.participants[name]; p != nil { - seats = append(seats, &p.Participant) - } - } - return seats -} - func (t *baccaratTable) menuEvent(name string) Event { e := t.private(name, t.menu()) e.Color = "casino_menu" @@ -80,15 +71,13 @@ func (t *baccaratTable) menu() string { func (t *baccaratTable) join(name string) []Event { t.mu.Lock() defer t.mu.Unlock() - if p := t.participants[name]; p != nil { - p.Connected = true + if t.players.has(name) { return []Event{t.menuEvent(name)} } - if len(t.participants) >= t.Config.MaxPlayers { + if t.players.count() >= t.Config.MaxPlayers { return []Event{t.private(name, "That baccarat table is full.")} } - t.participants[name] = &baccaratPlayer{Participant: Participant{Name: name, Connected: true}} - t.order = append(t.order, name) + t.players.add(name, &baccaratPlayer{Participant: Participant{Name: name, Connected: true}}) events := []Event{t.playerAction(name, fmt.Sprintf("%s sits down.", name), "You sit down.")} events = append(events, t.menuEvent(name)) return events @@ -97,22 +86,15 @@ func (t *baccaratTable) join(name string) []Event { func (t *baccaratTable) leave(name string) []Event { t.mu.Lock() defer t.mu.Unlock() - p := t.participants[name] - if p == nil { + if !t.players.has(name) { return nil } if t.phase != baccaratWaiting { return []Event{t.private(name, "You're in the middle of a baccarat round!")} } - delete(t.participants, name) - for i, n := range t.order { - if n == name { - t.order = append(t.order[:i], t.order[i+1:]...) - break - } - } + t.players.remove(name) events := []Event{t.playerAction(name, fmt.Sprintf("%s stands up.", name), "You stand up.")} - if len(t.participants) == 0 { + if t.players.count() == 0 { events = append(events, t.event("The baccarat table closes.", true)) } return events @@ -121,7 +103,7 @@ func (t *baccaratTable) leave(name string) []Event { func (t *baccaratTable) bet(name, spot string, amount int, wallet Wallet) []Event { t.mu.Lock() defer t.mu.Unlock() - p := t.participants[name] + p := t.players.get(name) if p == nil { return []Event{t.private(name, "You aren't playing baccarat.")} } @@ -148,12 +130,12 @@ func (t *baccaratTable) bet(name, spot string, amount int, wallet Wallet) []Even events := []Event{t.playerAction(name, fmt.Sprintf("%s bets %d credits on %s.", name, amount, spot), fmt.Sprintf("You bet %d credits on %s.", amount, spot))} - if len(t.participants) > 1 && t.phase == baccaratWaiting { + if t.players.count() > 1 && t.phase == baccaratWaiting { t.phase = baccaratBetting - t.timer.start(t.Config.BettingTicks) + t.window.open(t.Config.BettingTicks) events = append(events, t.event(fmt.Sprintf("Baccarat betting is open for %d ticks.", t.Config.BettingTicks), true)) } - if allBet(t.seats()) { + if allBet(t.players.participants()) { events = append(events, t.deal()...) } return events @@ -165,18 +147,15 @@ func (t *baccaratTable) tick() []Event { if t.phase != baccaratBetting { return nil } - t.timer.tick() - if allBet(t.seats()) { - return t.deal() - } - if !t.timer.expired() { + sitOuts, resolve, pending := t.window.advance(t.players.participants()) + if pending { return nil } var events []Event - for _, name := range sitOutNames(t.seats()) { + for _, name := range sitOuts { events = append(events, t.event(fmt.Sprintf("%s sits out this baccarat round.", name), true)) } - if anyBet(t.seats()) { + if resolve { events = append(events, t.deal()...) } else { t.resetRound() @@ -189,7 +168,7 @@ func (t *baccaratTable) action(name, action string) []Event { } func (t *baccaratTable) rebet(name string) (string, int) { - if p := t.participants[name]; p != nil && p.lastBet > 0 { + if p := t.players.get(name); p != nil && p.lastBet > 0 { return p.lastSpot, p.lastBet } return "", t.Config.MinBet @@ -203,17 +182,16 @@ func (t *baccaratTable) deal() []Event { round := t.event("New baccarat hand!", true) round.Baccarat = t.snapshot(player, banker) events := []Event{round, t.event(baccaratOutcomeMessage(playerTotal, bankerTotal), true)} - for _, name := range t.order { - p := t.participants[name] - if p == nil || !p.HasBet { + for _, p := range t.players.ordered() { + if !p.HasBet { continue } result, payout := settleBaccaratBet(p.lastSpot, p.Wager.Total, playerTotal, bankerTotal, t.Rules) if payout > 0 && p.Wallet != nil { p.Wallet.PayCredits(payout) } - publicResult, privateResult := baccaratResultMessages(name, result, payout) - settlement := t.playerAction(name, publicResult, privateResult) + publicResult, privateResult := baccaratResultMessages(p.Name, result, payout) + settlement := t.playerAction(p.Name, publicResult, privateResult) if result == "win" { if p.lastSpot == baccaratSpotTie { settlement.Color = "casino_jackpot_win" @@ -224,9 +202,9 @@ func (t *baccaratTable) deal() []Event { events = append(events, settlement) } t.resetRound() - for _, name := range t.order { - if p := t.participants[name]; p != nil && p.Connected { - events = append(events, t.menuEvent(name)) + for _, p := range t.players.ordered() { + if p.Connected { + events = append(events, t.menuEvent(p.Name)) } } return events @@ -243,43 +221,42 @@ func (t *baccaratTable) snapshot(player, banker []card) *BaccaratSnapshot { for _, c := range banker { snapshot.Banker = append(snapshot.Banker, CardView{Rank: c.rank, Suit: c.suit}) } - for _, name := range t.order { - p := t.participants[name] - if p == nil || !p.HasBet { + for _, p := range t.players.ordered() { + if !p.HasBet { continue } - snapshot.Bets = append(snapshot.Bets, BaccaratBetView{Name: name, Spot: p.lastSpot, Amount: p.Wager.Total}) + snapshot.Bets = append(snapshot.Bets, BaccaratBetView{Name: p.Name, Spot: p.lastSpot, Amount: p.Wager.Total}) } return snapshot } func (t *baccaratTable) resetRound() { t.phase = baccaratWaiting - t.timer.stop() - clearBets(t.seats()) + t.window.close() + clearBets(t.players.participants()) } +// markDisconnected removes the player from the table, forfeiting any wager +// they committed (deal only settles seated bettors). An open betting window +// resolves or resets on the next tick now that they no longer hold it open. func (t *baccaratTable) markDisconnected(name string) []Event { t.mu.Lock() defer t.mu.Unlock() - if p := t.participants[name]; p != nil { - p.Connected = false + if !t.players.has(name) { + return nil } - return nil -} - -func (t *baccaratTable) markConnected(name string) { - t.mu.Lock() - defer t.mu.Unlock() - if p := t.participants[name]; p != nil { - p.Connected = true + t.players.remove(name) + events := []Event{t.event(fmt.Sprintf("%s stands up.", name), true)} + if t.players.count() == 0 { + events = append(events, t.event("The baccarat table closes.", true)) } + return events } func (t *baccaratTable) hasParticipant(name string) bool { t.mu.Lock() defer t.mu.Unlock() - return t.participants[name] != nil + return t.players.has(name) } // baccaratTotal scores a hand modulo 10. @@ -338,7 +315,9 @@ func bankerDraws(bankerTotal, playerThird int) bool { } // settleBaccaratBet returns the result label and total payout (stake plus -// winnings) for one wager. Player and banker bets push on a tie. +// winnings) for one wager. Player and banker bets push on a tie. Winnings +// round down to whole credits, so the banker commission effectively rounds +// up: never bet less than one commission unit. func settleBaccaratBet(spot string, bet int, playerTotal, bankerTotal int, rules BaccaratConfig) (string, int) { switch { case playerTotal > bankerTotal: @@ -347,11 +326,11 @@ func settleBaccaratBet(spot string, bet int, playerTotal, bankerTotal int, rules } case bankerTotal > playerTotal: if spot == baccaratSpotBanker { - return "win", bet + int(math.Round(float64(bet)*rules.BankerPayout)) + return "win", bet + int(math.Floor(float64(bet)*rules.BankerPayout)) } default: if spot == baccaratSpotTie { - return "win", bet + int(math.Round(float64(bet)*rules.TiePayout)) + return "win", bet + int(math.Floor(float64(bet)*rules.TiePayout)) } return "push", bet } diff --git a/internal/casino/baccarat_test.go b/internal/casino/baccarat_test.go index 2cb932e..fc12e4b 100644 --- a/internal/casino/baccarat_test.go +++ b/internal/casino/baccarat_test.go @@ -108,6 +108,9 @@ func TestBaccaratSettlement(t *testing.T) { if result, payout := settleBaccaratBet(baccaratSpotBanker, 100, 4, 6, rules); result != "win" || payout != 195 { t.Fatalf("banker win: result=%s payout=%d, want win/195 (5%% commission)", result, payout) } + if result, payout := settleBaccaratBet(baccaratSpotBanker, 1, 4, 6, rules); result != "win" || payout != 1 { + t.Fatalf("banker win on bet 1: result=%s payout=%d, want win/1 (commission rounds up)", result, payout) + } if result, payout := settleBaccaratBet(baccaratSpotTie, 100, 7, 7, rules); result != "win" || payout != 900 { t.Fatalf("tie win: result=%s payout=%d, want win/900 (8:1)", result, payout) } @@ -141,7 +144,7 @@ func TestBaccaratSoloBetDealsImmediately(t *testing.T) { if w.paid != 200 { t.Fatalf("payout=%d, want 200 (stake + 1:1)", w.paid) } - if table.phase != baccaratWaiting || table.participants["alice"].HasBet { + if table.phase != baccaratWaiting || table.players.get("alice").HasBet { t.Fatalf("table did not reset after deal: phase=%s", table.phase) } // The round event carries a snapshot of both hands and the bets. @@ -205,7 +208,7 @@ func TestBaccaratBettingIgnoresDisconnected(t *testing.T) { table := newBaccaratTestTable(TableConfig{ID: "baccarat", Game: GameBaccarat, MaxPlayers: 2}) table.join("alice") table.join("bob") - table.markDisconnected("bob") + table.markDisconnected("bob") // removes bob, not just marks him forceShoe(table, card{rank: "5"}, card{rank: "4"}, // player 9 card{rank: "2"}, card{rank: "3"}, // banker 5 @@ -213,7 +216,35 @@ func TestBaccaratBettingIgnoresDisconnected(t *testing.T) { w := &testWallet{credits: 100} events := table.bet("alice", baccaratSpotPlayer, 10, w) if !hasMessage(events, "New baccarat hand!") { - t.Fatalf("disconnected player stalled the round: %+v", events) + t.Fatalf("removed player stalled the round: %+v", events) + } +} + +func TestBaccaratDisconnectFreesSeat(t *testing.T) { + table := newBaccaratTestTable(TableConfig{ID: "baccarat", Game: GameBaccarat, MaxPlayers: 2}) + table.join("alice") + table.join("bob") + w := &testWallet{credits: 100} + table.bet("alice", baccaratSpotPlayer, 10, w) // opens betting, alice has bet + // Bob disconnects without betting — his seat is freed and his non-wager + // is skipped; the round resolves on the next tick for the remaining + // connected bettor. + events := table.markDisconnected("bob") + if !hasMessage(events, "bob stands up") { + t.Fatalf("disconnect event missing: %+v", events) + } + if table.players.count() != 1 { + t.Fatalf("bob's seat was not freed: count=%d", table.players.count()) + } + // Alice's bet should resolve on the next tick (only connected bettor + // remains, allBet is true). + forceShoe(table, + card{rank: "5"}, card{rank: "4"}, // player 9 + card{rank: "2"}, card{rank: "3"}, // banker 5 + ) + tickEvents := table.tick() + if !hasMessage(tickEvents, "New baccarat hand!") { + t.Fatalf("tick after removal did not resolve the round: %+v", tickEvents) } } diff --git a/internal/casino/betting.go b/internal/casino/betting.go index 3ffee88..fa2d2dc 100644 --- a/internal/casino/betting.go +++ b/internal/casino/betting.go @@ -12,6 +12,30 @@ func (c *countdown) stop() { c.clock, c.deadline = 0, 0 } func (c *countdown) tick() { c.clock++ } func (c *countdown) expired() bool { return c.deadline > 0 && c.clock >= c.deadline } +// bettingWindow is the timed betting phase shared by multiplayer table games. +// The owning table opens it when the first wager lands with multiple seated +// players and decides the outcome (deal/resolve vs reset) from advance. +type bettingWindow struct { + timer countdown +} + +func (w *bettingWindow) open(ticks int) { w.timer.start(ticks) } +func (w *bettingWindow) close() { w.timer.stop() } + +// advance moves the window one tick. pending means the window is still open; +// otherwise resolve reports whether wagers are down and the round should play +// out, and sitOuts names the seated players who never bet. +func (w *bettingWindow) advance(seats []*Participant) (sitOuts []string, resolve, pending bool) { + w.timer.tick() + if allBet(seats) { + return nil, true, false + } + if !w.timer.expired() { + return nil, false, true + } + return sitOutNames(seats), anyBet(seats), false +} + // allBet reports whether at least one connected participant exists and every // connected participant has placed a bet. Disconnected participants never // hold a round open. diff --git a/internal/casino/blackjack.go b/internal/casino/blackjack.go index 3c4dc09..90d6feb 100644 --- a/internal/casino/blackjack.go +++ b/internal/casino/blackjack.go @@ -56,13 +56,15 @@ type blackjackPlayer struct { lastBet int } +func (p *blackjackPlayer) participantPtr() *Participant { return &p.Participant } + type blackjackTable struct { mu sync.Mutex tableBase Rules BlackjackConfig - participants map[string]*blackjackPlayer - order []string + players seatSet[*blackjackPlayer] phase blackjackPhase + window bettingWindow timer countdown activePlayer int activeHand int @@ -75,21 +77,11 @@ func newBlackjackTable(roomID int, cfg TableConfig, rng *rand.Rand) *blackjackTa rules := cfg.Blackjack.Normalize() return &blackjackTable{ tableBase: tableBase{RoomID: roomID, Config: cfg}, Rules: rules, - participants: make(map[string]*blackjackPlayer), phase: blackjackWaiting, + players: newSeatSet[*blackjackPlayer](), phase: blackjackWaiting, shoe: newShoe(rules.Decks, rules.ShuffleAt, rng), } } -func (t *blackjackTable) seats() []*Participant { - seats := make([]*Participant, 0, len(t.order)) - for _, name := range t.order { - if p := t.participants[name]; p != nil { - seats = append(seats, &p.Participant) - } - } - return seats -} - func (t *blackjackTable) menuEvent(name string) Event { e := t.private(name, t.menu()) e.Color = "casino_menu" @@ -104,22 +96,13 @@ func (t *blackjackTable) menuEvent(name string) Event { func (t *blackjackTable) join(name string) []Event { t.mu.Lock() defer t.mu.Unlock() - if p := t.participants[name]; p != nil { - p.Connected = true - if t.phase != blackjackWaiting { - e := t.private(name, "You resume your place.") - if t.phase != blackjackBetting { - e.Blackjack = t.snapshot(false) - } - return []Event{e} - } + if t.players.has(name) { return []Event{t.menuEvent(name)} } - if len(t.participants) >= t.Config.MaxPlayers { + if t.players.count() >= t.Config.MaxPlayers { return []Event{t.private(name, "That blackjack table is full.")} } - t.participants[name] = &blackjackPlayer{Participant: Participant{Name: name, Connected: true}} - t.order = append(t.order, name) + t.players.add(name, &blackjackPlayer{Participant: Participant{Name: name, Connected: true}}) events := []Event{t.playerAction(name, fmt.Sprintf("%s sits down.", name), "You sit down.")} events = append(events, t.menuEvent(name)) return events @@ -128,22 +111,15 @@ func (t *blackjackTable) join(name string) []Event { func (t *blackjackTable) leave(name string) []Event { t.mu.Lock() defer t.mu.Unlock() - p := t.participants[name] - if p == nil { + if !t.players.has(name) { return nil } if t.phase != blackjackWaiting { return []Event{t.private(name, "You're in the middle of a blackjack round!")} } - delete(t.participants, name) - for i, n := range t.order { - if n == name { - t.order = append(t.order[:i], t.order[i+1:]...) - break - } - } + t.players.remove(name) events := []Event{t.playerAction(name, fmt.Sprintf("%s stands up.", name), "You stand up.")} - if len(t.participants) == 0 { + if t.players.count() == 0 { events = append(events, t.event("The blackjack table closes.", true)) } return events @@ -152,7 +128,7 @@ func (t *blackjackTable) leave(name string) []Event { func (t *blackjackTable) bet(name, spot string, amount int, wallet Wallet) []Event { t.mu.Lock() defer t.mu.Unlock() - p := t.participants[name] + p := t.players.get(name) if p == nil { return []Event{t.private(name, "You aren't playing blackjack.")} } @@ -174,12 +150,12 @@ func (t *blackjackTable) bet(name, spot string, amount int, wallet Wallet) []Eve } p.HasBet, p.Wager, p.Wallet, p.lastBet = true, wager, wallet, amount events := []Event{t.playerAction(name, fmt.Sprintf("%s bets %d credits.", name, amount), fmt.Sprintf("You bet %d credits.", amount))} - if len(t.participants) > 1 && t.phase == blackjackWaiting { + if t.players.count() > 1 && t.phase == blackjackWaiting { t.phase = blackjackBetting - t.timer.start(t.Config.BettingTicks) + t.window.open(t.Config.BettingTicks) events = append(events, t.event(fmt.Sprintf("Blackjack betting is open for %d ticks.", t.Config.BettingTicks), true)) } - if allBet(t.seats()) { + if allBet(t.players.participants()) { events = append(events, t.startRound()...) } return events @@ -190,18 +166,15 @@ func (t *blackjackTable) tick() []Event { defer t.mu.Unlock() switch t.phase { case blackjackBetting: - t.timer.tick() - if allBet(t.seats()) { - return t.startRound() - } - if !t.timer.expired() { + sitOuts, resolve, pending := t.window.advance(t.players.participants()) + if pending { return nil } var events []Event - for _, name := range sitOutNames(t.seats()) { + for _, name := range sitOuts { events = append(events, t.event(fmt.Sprintf("%s sits out this blackjack round.", name), true)) } - if anyBet(t.seats()) { + if resolve { events = append(events, t.startRound()...) } else { t.resetRound() @@ -217,15 +190,26 @@ func (t *blackjackTable) tick() []Event { } return t.finishInsurance() case blackjackPlaying: - if t.bettorCount() <= 1 { + name := t.activeName() + if name == "" { + return nil + } + active := t.players.get(name) + if active == nil { + // Unreachable while mid-round leaves are blocked; keep the game moving. + t.activePlayer++ + return t.finishHand() + } + if active.Connected && t.bettorCount() <= 1 { + // A lone connected player is never timed out. A disconnected + // active player always is, so the table can never stall. return nil } t.timer.tick() if !t.timer.expired() { return nil } - name := t.activeName() - t.participants[name].hands[t.activeHand].stood = true + active.hands[t.activeHand].stood = true events := []Event{t.private(name, "Your action timed out; standing.")} events = append(events, t.finishHand()...) return events @@ -243,7 +227,7 @@ func (t *blackjackTable) action(name, action string) []Event { if t.phase != blackjackPlaying || t.activeName() != name { return []Event{t.private(name, "It's not your turn.")} } - p := t.participants[name] + p := t.players.get(name) hand := p.hands[t.activeHand] t.timer.start(t.Rules.ActionTicks) switch action { @@ -301,7 +285,7 @@ func (t *blackjackTable) action(name, action string) []Event { // insuranceAction handles commands during the insurance offer window: // "insurance" buys cover for half the base bet; any other action declines. func (t *blackjackTable) insuranceAction(name, action string) []Event { - p := t.participants[name] + p := t.players.get(name) if p == nil || !p.HasBet { return []Event{t.private(name, "Insurance is only offered to players with a bet this round.")} } @@ -332,9 +316,8 @@ func (t *blackjackTable) insuranceAction(name, action string) []Event { // insuranceSettled reports whether every connected bettor has taken or // declined insurance. func (t *blackjackTable) insuranceSettled() bool { - for _, name := range t.order { - p := t.participants[name] - if p == nil || !p.HasBet || !p.Connected { + for _, p := range t.players.ordered() { + if !p.HasBet || !p.Connected { continue } if p.insurance == 0 && !p.declinedInsurance { @@ -392,9 +375,8 @@ func splitCompatible(a, b card, unlikeTens bool) bool { func (t *blackjackTable) startRound() []Event { t.dealer = blackjackHand{cards: []card{t.shoe.draw(), t.shoe.draw()}} - for _, name := range t.order { - p := t.participants[name] - if p == nil || !p.HasBet { + for _, p := range t.players.ordered() { + if !p.HasBet { continue } p.hands = []*blackjackHand{{cards: []card{t.shoe.draw(), t.shoe.draw()}, bet: p.Wager.Total}} @@ -410,12 +392,11 @@ func (t *blackjackTable) startRound() []Event { t.phase = blackjackInsurance t.timer.start(t.Rules.ActionTicks) events = append(events, t.event("The dealer shows an ace. Insurance is on offer.", true)) - for _, name := range t.order { - p := t.participants[name] - if p == nil || !p.HasBet || !p.Connected || p.declinedInsurance { + for _, p := range t.players.ordered() { + if !p.HasBet || !p.Connected || p.declinedInsurance { continue } - events = append(events, t.private(name, fmt.Sprintf("Insure your %d-credit bet for %d credits? Type \"insurance\" to buy, or any other action to decline.", p.hands[0].bet, p.hands[0].bet/2))) + events = append(events, t.private(p.Name, fmt.Sprintf("Insure your %d-credit bet for %d credits? Type \"insurance\" to buy, or any other action to decline.", p.hands[0].bet, p.hands[0].bet/2))) } if t.insuranceSettled() { events = append(events, t.finishInsurance()...) @@ -441,7 +422,7 @@ func (t *blackjackTable) prompt() []Event { if name == "" { return t.settle() } - p := t.participants[name] + p := t.players.get(name) h := p.hands[t.activeHand] ranks := make([]string, len(h.cards)) for i, card := range h.cards { @@ -506,8 +487,8 @@ func (t *blackjackTable) finishHand() []Event { } func (t *blackjackTable) skipCompletedHands() { - for t.activePlayer < len(t.order) { - p := t.participants[t.order[t.activePlayer]] + for t.activePlayer < t.players.count() { + p := t.players.get(t.players.nameAt(t.activePlayer)) if p == nil || !p.HasBet { t.activePlayer++ t.activeHand = 0 @@ -528,10 +509,7 @@ func (t *blackjackTable) skipCompletedHands() { } func (t *blackjackTable) activeName() string { - if t.activePlayer >= len(t.order) { - return "" - } - return t.order[t.activePlayer] + return t.players.nameAt(t.activePlayer) } func (t *blackjackTable) dealerBlackjack() bool { @@ -551,9 +529,8 @@ func (t *blackjackTable) settle() []Event { dealerEvent.Blackjack = t.snapshot(true) events := []Event{dealerEvent} dealerBlackjack := t.dealerBlackjack() - for _, name := range t.order { - p := t.participants[name] - if p == nil || !p.HasBet { + for _, p := range t.players.ordered() { + if !p.HasBet { continue } for _, hand := range p.hands { @@ -561,8 +538,8 @@ func (t *blackjackTable) settle() []Event { if payout > 0 && p.Wallet != nil { p.Wallet.PayCredits(payout) } - publicResult, privateResult := blackjackResultMessages(name, result, payout) - settlement := t.playerAction(name, publicResult, privateResult) + publicResult, privateResult := blackjackResultMessages(p.Name, result, payout) + settlement := t.playerAction(p.Name, publicResult, privateResult) if result == "blackjack" { settlement.Color = "casino_jackpot_win" } else if result == "win" { @@ -573,18 +550,18 @@ func (t *blackjackTable) settle() []Event { if p.insurance > 0 { insurancePayout := 0 if dealerBlackjack { - insurancePayout = int(math.Round(float64(p.insurance) * (1 + t.Rules.InsurancePayout))) + insurancePayout = int(math.Floor(float64(p.insurance) * (1 + t.Rules.InsurancePayout))) } if insurancePayout > 0 { p.Wallet.PayCredits(insurancePayout) } - events = append(events, t.private(name, fmt.Sprintf("Insurance pays %d credits.", insurancePayout))) + events = append(events, t.private(p.Name, fmt.Sprintf("Insurance pays %d credits.", insurancePayout))) } } t.resetRound() - for _, name := range t.order { - if p := t.participants[name]; p != nil && p.Connected { - events = append(events, t.menuEvent(name)) + for _, p := range t.players.ordered() { + if p.Connected { + events = append(events, t.menuEvent(p.Name)) } } return events @@ -606,7 +583,8 @@ func settleHand(hand *blackjackHand, dealerTotal int, dealerBlackjack bool, rule return "dealer blackjack", 0 } if playerBlackjack { - return "blackjack", int(math.Round(float64(hand.bet) * (1 + rules.BlackjackPayout))) + // Winnings round down to whole credits; never bet less than two. + return "blackjack", hand.bet + int(math.Floor(float64(hand.bet)*rules.BlackjackPayout)) } if total > 21 || dealerTotal > total && dealerTotal <= 21 { return "loss", 0 @@ -622,14 +600,15 @@ func settleHand(hand *blackjackHand, dealerTotal int, dealerBlackjack bool, rule func (t *blackjackTable) resetRound() { t.phase = blackjackWaiting + t.window.close() t.timer.stop() - for _, p := range t.participants { + for _, p := range t.players.ordered() { p.HasBet, p.Wager, p.hands, p.insurance, p.declinedInsurance = false, Wager{}, nil, 0, false } } func (t *blackjackTable) rebet(name string) (string, int) { - if p := t.participants[name]; p != nil && p.lastBet > 0 { + if p := t.players.get(name); p != nil && p.lastBet > 0 { return "", p.lastBet } return "", t.Config.MinBet @@ -637,7 +616,7 @@ func (t *blackjackTable) rebet(name string) (string, int) { func (t *blackjackTable) bettorCount() int { count := 0 - for _, p := range t.participants { + for _, p := range t.players.ordered() { if p.HasBet { count++ } @@ -654,13 +633,12 @@ func (t *blackjackTable) snapshot(revealDealer bool) *BlackjackSnapshot { } snapshot.Dealer = append(snapshot.Dealer, CardView{Rank: card.rank, Suit: card.suit}) } - for _, name := range t.order { - p := t.participants[name] - if p == nil || !p.HasBet { + for _, p := range t.players.ordered() { + if !p.HasBet { continue } for _, hand := range p.hands { - view := BlackjackPlayerView{Name: name, Score: handScore(hand)} + view := BlackjackPlayerView{Name: p.Name, Score: handScore(hand)} for _, card := range hand.cards { view.Cards = append(view.Cards, CardView{Rank: card.rank, Suit: card.suit}) } @@ -698,25 +676,41 @@ func blackjackResultMessages(name, result string, payout int) (string, string) { } } +// markDisconnected removes the player from the table, forfeiting any wager +// they committed (settle only pays seated bettors). Removing a seat shifts +// the seat indices the active turn points into, so mid-round play is fixed +// up and handed to the next bettor (or settled) immediately. func (t *blackjackTable) markDisconnected(name string) []Event { t.mu.Lock() defer t.mu.Unlock() - if p := t.participants[name]; p != nil { - p.Connected = false + index := t.players.indexOf(name) + if index < 0 { + return nil } - return nil -} - -func (t *blackjackTable) markConnected(name string) { - t.mu.Lock() - defer t.mu.Unlock() - if p := t.participants[name]; p != nil { - p.Connected = true + t.players.remove(name) + events := []Event{t.event(fmt.Sprintf("%s stands up.", name), true)} + if t.players.count() == 0 { + events = append(events, t.event("The blackjack table closes.", true)) + } + if t.phase != blackjackPlaying { + // Betting and insurance resolve against the remaining seats on the + // next tick; waiting has no round in flight. + return events } + switch { + case index < t.activePlayer: + // The same player stays active, one seat earlier. + t.activePlayer-- + case index == t.activePlayer: + // The next bettor slides into the active seat. + t.activeHand = 0 + } + t.timer.start(t.Rules.ActionTicks) + return append(events, t.finishHand()...) } func (t *blackjackTable) hasParticipant(name string) bool { t.mu.Lock() defer t.mu.Unlock() - return t.participants[name] != nil + return t.players.has(name) } diff --git a/internal/casino/blackjack_test.go b/internal/casino/blackjack_test.go index a6735c0..510077d 100644 --- a/internal/casino/blackjack_test.go +++ b/internal/casino/blackjack_test.go @@ -37,6 +37,10 @@ func TestBlackjackSettlementRules(t *testing.T) { if result, payout := settleHand(natural, 20, false, rules); result != "blackjack" || payout != 25 { t.Fatalf("natural result=%s payout=%d", result, payout) } + smallNatural := &blackjackHand{bet: 1, cards: []card{{rank: "A"}, {rank: "K"}}} + if result, payout := settleHand(smallNatural, 20, false, rules); result != "blackjack" || payout != 2 { + t.Fatalf("small natural result=%s payout=%d, want blackjack/2 (winnings round down)", result, payout) + } dealerNatural := &blackjackHand{bet: 10, cards: []card{{rank: "A"}, {rank: "Q"}}} if result, payout := settleHand(dealerNatural, 21, true, rules); result != "push" || payout != 10 { t.Fatalf("dealer natural result=%s payout=%d", result, payout) @@ -65,11 +69,10 @@ func TestBlackjackTableStartsAfterAllPlayersBet(t *testing.T) { func TestSoloBlackjackTurnDoesNotTimeout(t *testing.T) { table := newBlackjackTable(1, TableConfig{ID: "blackjack", Game: GameBlackjack, Blackjack: BlackjackConfig{ActionTicks: 1}}, newTestRand()) - table.participants["alice"] = &blackjackPlayer{ + table.players.add("alice", &blackjackPlayer{ Participant: Participant{Name: "alice", HasBet: true, Connected: true}, hands: []*blackjackHand{{bet: 10, cards: []card{{rank: "10"}, {rank: "6"}}}}, - } - table.order = []string{"alice"} + }) table.phase = blackjackPlaying if events := table.tick(); len(events) != 0 { t.Fatalf("solo action timed out: %+v", events) @@ -79,9 +82,39 @@ func TestSoloBlackjackTurnDoesNotTimeout(t *testing.T) { } } +func TestBlackjackDisconnectedSoloPlayerTimesOut(t *testing.T) { + table := newBlackjackTable(1, TableConfig{ID: "blackjack", Game: GameBlackjack, Blackjack: BlackjackConfig{ActionTicks: 1}}, newTestRand()) + table.shoe = &shoe{cards: []card{{rank: "9"}}} // dealer stands on 17; spare card just in case + w := &testWallet{credits: 100} + table.players.add("alice", &blackjackPlayer{ + Participant: Participant{Name: "alice", HasBet: true, Connected: false, Wallet: w, Wager: Wager{Total: 10}}, + hands: []*blackjackHand{{bet: 10, cards: []card{{rank: "10"}, {rank: "6"}}}}, + }) + table.dealer.cards = []card{{rank: "10"}, {rank: "7"}} + table.phase = blackjackPlaying + table.timer.start(1) // beginPlay starts the action timer in production + events := table.tick() + if !hasMessage(events, "Your action timed out; standing.") { + t.Fatalf("disconnected player was not timed out: %+v", events) + } + if table.phase != blackjackWaiting { + t.Fatalf("phase=%s, want waiting after the round settles", table.phase) + } + if w.paid != 0 { + t.Fatalf("stood 16 against dealer 17 should lose, paid=%d", w.paid) + } + // The table recovers: a new player can sit and bet. + table.shoe = tableauShoe(card{rank: "10"}, card{rank: "7"}, card{rank: "10"}, card{rank: "8"}) + table.join("bob") + wb := &testWallet{credits: 100} + if events := table.bet("bob", "", 10, wb); !hasSnapshot(events) { + t.Fatalf("table did not recover after the disconnected player timed out: %+v", events) + } +} + func TestBlackjackRebetKeepsLastWager(t *testing.T) { table := newBlackjackTable(1, TableConfig{ID: "blackjack", Game: GameBlackjack, MinBet: 5, MaxBet: 100}, newTestRand()) - table.participants["alice"] = &blackjackPlayer{Participant: Participant{Name: "alice"}, lastBet: 25} + table.players.add("alice", &blackjackPlayer{Participant: Participant{Name: "alice"}, lastBet: 25}) if spot, got := table.rebet("alice"); got != 25 || spot != "" { t.Fatalf("rebet=(%q, %d), want (\"\", 25)", spot, got) } @@ -89,8 +122,7 @@ func TestBlackjackRebetKeepsLastWager(t *testing.T) { func TestBlackjackPromptNamesActiveHandAndOnlyOffersAvailableActions(t *testing.T) { table := newBlackjackTable(1, TableConfig{ID: "blackjack", Game: GameBlackjack}, newTestRand()) - table.participants["alice"] = &blackjackPlayer{Participant: Participant{Name: "alice", HasBet: true, Connected: true}, hands: []*blackjackHand{{bet: 10, cards: []card{{rank: "K"}, {rank: "6"}}}}} - table.order = []string{"alice"} + table.players.add("alice", &blackjackPlayer{Participant: Participant{Name: "alice", HasBet: true, Connected: true}, hands: []*blackjackHand{{bet: 10, cards: []card{{rank: "K"}, {rank: "6"}}}}}) table.activePlayer = 0 table.activeHand = 0 table.dealer.cards = []card{{rank: "10"}, {rank: "7"}} @@ -130,11 +162,10 @@ func TestBlackjackActionCommandsDescribeEachAction(t *testing.T) { func TestBlackjackInsurancePaysOnDealerBlackjack(t *testing.T) { table := newBlackjackTable(1, TableConfig{ID: "blackjack", Game: GameBlackjack}, newTestRand()) w := &testWallet{credits: 100} - table.participants["alice"] = &blackjackPlayer{ + table.players.add("alice", &blackjackPlayer{ Participant: Participant{Name: "alice", HasBet: true, Connected: true, Wallet: w, Wager: Wager{Total: 10}}, hands: []*blackjackHand{{bet: 10, cards: []card{{rank: "10"}, {rank: "8"}}}}, - } - table.order = []string{"alice"} + }) table.dealer.cards = []card{{rank: "K"}, {rank: "A"}} table.phase = blackjackInsurance events := table.action("alice", "insurance") @@ -155,11 +186,10 @@ func TestBlackjackInsurancePaysOnDealerBlackjack(t *testing.T) { func TestBlackjackInsuranceDeclineContinuesRound(t *testing.T) { table := newBlackjackTable(1, TableConfig{ID: "blackjack", Game: GameBlackjack}, newTestRand()) w := &testWallet{credits: 100} - table.participants["alice"] = &blackjackPlayer{ + table.players.add("alice", &blackjackPlayer{ Participant: Participant{Name: "alice", HasBet: true, Connected: true, Wallet: w, Wager: Wager{Total: 10}}, hands: []*blackjackHand{{bet: 10, cards: []card{{rank: "10"}, {rank: "8"}}}}, - } - table.order = []string{"alice"} + }) table.dealer.cards = []card{{rank: "9"}, {rank: "A"}} table.phase = blackjackInsurance events := table.action("alice", "stand") @@ -179,14 +209,13 @@ func TestBlackjackDoubleSetsBust(t *testing.T) { // decks 0 disables the shuffle threshold so the forced card is drawn. table.shoe = &shoe{cards: []card{{rank: "K"}}} w := &testWallet{credits: 100} - table.participants["alice"] = &blackjackPlayer{ + table.players.add("alice", &blackjackPlayer{ Participant: Participant{Name: "alice", HasBet: true, Connected: true, Wallet: w, Wager: Wager{Total: 10}}, hands: []*blackjackHand{{bet: 10, cards: []card{{rank: "10"}, {rank: "6"}}}}, - } - table.order = []string{"alice"} + }) table.dealer.cards = []card{{rank: "10"}, {rank: "7"}} table.phase = blackjackPlaying - hand := table.participants["alice"].hands[0] + hand := table.players.get("alice").hands[0] events := table.action("alice", "double") if !hand.bust { t.Fatal("doubling into 26 did not set the bust flag") @@ -200,14 +229,13 @@ func TestBlackjackSplitAcesOnlyAllowResplit(t *testing.T) { resplit := true table := newBlackjackTable(1, TableConfig{ID: "blackjack", Game: GameBlackjack, Blackjack: BlackjackConfig{ResplitAces: &resplit}}, newTestRand()) w := &testWallet{credits: 100} - table.participants["alice"] = &blackjackPlayer{ + table.players.add("alice", &blackjackPlayer{ Participant: Participant{Name: "alice", HasBet: true, Connected: true, Wallet: w, Wager: Wager{Total: 10}}, hands: []*blackjackHand{{bet: 10, fromSplit: true, cards: []card{{rank: "A"}, {rank: "A"}}}}, - } - table.order = []string{"alice"} + }) table.dealer.cards = []card{{rank: "10"}, {rank: "7"}} table.phase = blackjackPlaying - p := table.participants["alice"] + p := table.players.get("alice") actions := table.availableActions(p, p.hands[0]) if len(actions) != 2 || actions[0] != "stand" || actions[1] != "split" { t.Fatalf("split ace actions=%v, want [stand split]", actions) @@ -221,26 +249,77 @@ func TestBlackjackBettingIgnoresDisconnected(t *testing.T) { table := newBlackjackTable(1, TableConfig{ID: "blackjack", Game: GameBlackjack, MaxPlayers: 2}, newTestRand()) table.join("alice") table.join("bob") - table.markDisconnected("bob") + table.markDisconnected("bob") // removes bob; alice is the only bettor w := &testWallet{credits: 100} events := table.bet("alice", "", 10, w) if !hasSnapshot(events) { - t.Fatalf("disconnected player stalled the round: %+v", events) + t.Fatalf("removed player stalled the round: %+v", events) } } -func TestBlackjackReconnectShowsTableSnapshot(t *testing.T) { - table := newBlackjackTable(1, TableConfig{ID: "blackjack", Game: GameBlackjack}, newTestRand()) - table.participants["alice"] = &blackjackPlayer{ - Participant: Participant{Name: "alice", HasBet: true, Connected: true}, - hands: []*blackjackHand{{bet: 10, cards: []card{{rank: "10"}, {rank: "8"}}}}, +func TestBlackjackDisconnectActivePassesPlay(t *testing.T) { + // Two players; the active one disconnects. Play passes to the next + // bettor and the forfeited wager is never paid out. + table := newBlackjackTable(1, TableConfig{ID: "blackjack", Game: GameBlackjack, MaxPlayers: 2, Blackjack: BlackjackConfig{ActionTicks: 1}}, newTestRand()) + table.shoe = tableauShoe(card{rank: "10"}, card{rank: "7"}, card{rank: "K"}, card{rank: "Q"}, card{rank: "10"}, card{rank: "6"}) + wa, wb := &testWallet{credits: 100}, &testWallet{credits: 100} + table.join("alice") + table.join("bob") + table.bet("alice", "", 10, wa) + events := table.bet("bob", "", 10, wb) + if !hasSnapshot(events) { + t.Fatalf("round did not start: %+v", events) } - table.order = []string{"alice"} - table.dealer.cards = []card{{rank: "10"}, {rank: "7"}} - table.phase = blackjackPlaying - events := table.join("alice") - if len(events) != 1 || events[0].Blackjack == nil { - t.Fatalf("reconnect during play lacked a table snapshot: %+v", events) + // Both players dealt; alice active. Alice disconnects. + disEvt := table.markDisconnected("alice") + if !hasMessage(disEvt, "alice stands up") || len(disEvt) == 0 { + t.Fatalf("missing disconnect event: %+v", disEvt) + } + // Alice forfeits (never paid). + if wa.paid != 0 { + t.Fatalf("alice's forfeited wager was paid: %d", wa.paid) + } + // Bob is now prompted for his turn (10,6 against dealer 10-up). + if !hasMessage(disEvt, "Your turn (10,6)") { + t.Fatalf("play did not pass to bob after alice left: %+v", disEvt) + } + // Bob stands; round settles. + settle := table.action("bob", "stand") + if !hasMessage(settle, "You lose!") { + t.Fatalf("bob 16 vs dealer 17: expected loss, got %+v", settle) + } + if wb.paid != 0 { + t.Fatalf("bob should lose 16v17, paid=%d", wb.paid) + } +} + +func TestBlackjackDisconnectEarlierSeatPreservesTurn(t *testing.T) { + // Two players; the earlier seat disconnects during play. The active + // player's turn is unaffected (index fixup). + table := newBlackjackTable(1, TableConfig{ID: "blackjack", Game: GameBlackjack, MaxPlayers: 2, Blackjack: BlackjackConfig{ActionTicks: 1}}, newTestRand()) + table.shoe = tableauShoe(card{rank: "10"}, card{rank: "7"}, card{rank: "10"}, card{rank: "9"}, card{rank: "K"}, card{rank: "Q"}) + table.join("alice") + table.join("bob") + wa := &testWallet{credits: 100} + table.bet("alice", "", 10, wa) + events := table.bet("bob", "", 10, &testWallet{credits: 100}) + if !hasSnapshot(events) { + t.Fatalf("round did not start: %+v", events) + } + // Advance past alice's turn (bob is picked up after skipCompletedHands). + // Dealer 10-7. Let alice stand. + table.action("alice", "stand") + // Bob is now active. Remove alice (seat index 0; bob at 1). + disEvt := table.markDisconnected("alice") + // Bob (now seat 0) is still prompted — no "your turn" means finishHand + // skipped straight to settle (one bettor, bob 10-9 stands, 19 vs 17 → win). + // Actually finishHand → skipCompletedHands → bob's hand incomplete → + // prompt. Let's verify: + if !hasMessage(disEvt, "Your turn") { + t.Fatalf("bob's turn not preserved after alice was removed: %+v", disEvt) + } + if wa.paid != 0 { + t.Fatalf("alice should not be paid after removal, got %d", wa.paid) } } diff --git a/internal/casino/cards.go b/internal/casino/cards.go index 0c32e06..f263eb6 100644 --- a/internal/casino/cards.go +++ b/internal/casino/cards.go @@ -1,8 +1,8 @@ package casino import ( - "fmt" "math/rand" + "strconv" ) // card is a single playing card shared by every card-based table game. @@ -21,8 +21,7 @@ func (c card) blackjackValue() int { case "K", "Q", "J": return 10 default: - var value int - fmt.Sscanf(c.rank, "%d", &value) + value, _ := strconv.Atoi(c.rank) return value } } @@ -35,8 +34,7 @@ func (c card) baccaratValue() int { case "A": return 1 default: - var value int - fmt.Sscanf(c.rank, "%d", &value) + value, _ := strconv.Atoi(c.rank) return value } } diff --git a/internal/casino/machine.go b/internal/casino/machine.go index 79e4cb6..c1e93fe 100644 --- a/internal/casino/machine.go +++ b/internal/casino/machine.go @@ -47,21 +47,14 @@ func newMachine(roomID int, cfg MachineConfig, rng *rand.Rand) *Machine { func (m *Machine) join(name string) []Event { m.mu.Lock() defer m.mu.Unlock() - if m.player != nil && m.player.Name != name { + if m.player != nil { return []Event{{RoomID: m.RoomID, TableID: m.Config.ID, Type: EventResult, PrivateTo: name, Message: "That slot machine is occupied."}} } - if m.player == nil { - m.player = &Participant{Name: name, Connected: true} - events := []Event{{RoomID: m.RoomID, TableID: m.Config.ID, Type: EventJoined, Player: name, - Message: fmt.Sprintf("%s sits down at the slots machine.", name), - PrivateMessage: "You sit down at the slot machine.", PrivateTo: name, Color: "casino_action", Public: true}} - return append(events, m.privateMenu("You sit down at the slot machine.")...) - } - m.player.Connected = true - if m.autoBet { - return m.private("Autobet is active. Type \"stop\" to stop playing.") - } - return m.menuEvents("You resume your place at the slot machine.") + m.player = &Participant{Name: name, Connected: true} + events := []Event{{RoomID: m.RoomID, TableID: m.Config.ID, Type: EventJoined, Player: name, + Message: fmt.Sprintf("%s sits down at the slots machine.", name), + PrivateMessage: "You sit down at the slot machine.", PrivateTo: name, Color: "casino_action", Public: true}} + return append(events, m.privateMenu("You sit down at the slot machine.")...) } func (m *Machine) setBet(name string, amount int, wallet Wallet) []Event { @@ -152,6 +145,10 @@ func (m *Machine) stopAutoBet(name string) ([]Event, bool) { func (m *Machine) tick() []Event { m.mu.Lock() defer m.mu.Unlock() + if m.autoBet && m.player == nil { + // Defensive: autobet can never run without a seated player. + m.autoBet, m.autoCountdown = false, 0 + } if m.phase == machineReady && m.autoBet { return m.autoBetTick() } @@ -264,28 +261,36 @@ func (m *Machine) leave(name string) []Event { if m.phase != machineReady { return m.private("You're in the middle of a spin!") } - m.player = nil - m.bet = 0 - m.payoutRemainder = 0 + m.resetSeat() return []Event{{RoomID: m.RoomID, TableID: m.Config.ID, Type: EventLeft, Player: name, Message: fmt.Sprintf("%s stands up from the slots machine.", name), PrivateMessage: "You stand up from the slot machine.", PrivateTo: name, Color: "casino_action", Public: true}} } -func (m *Machine) markDisconnected(name string) []Event { - m.mu.Lock() - defer m.mu.Unlock() - if m.owns(name) { - m.player.Connected = false - } - return nil +// resetSeat frees the machine, discarding any in-flight spin and autobet +// state. A wager already taken for a spin in progress is forfeited. +func (m *Machine) resetSeat() { + m.player = nil + m.bet = 0 + m.spin = nil + m.spinBet = 0 + m.phase = machineReady + m.reel, m.clock, m.suspense = 0, 0, false + m.freeSpins = 0 + m.payoutRemainder = 0 + m.autoBet, m.autoCountdown = false, 0 } -func (m *Machine) markConnected(name string) { +// markDisconnected removes the player from the machine, forfeiting any +// in-flight wager, so the seat is immediately free for someone else. +func (m *Machine) markDisconnected(name string) []Event { m.mu.Lock() defer m.mu.Unlock() - if m.owns(name) { - m.player.Connected = true + if !m.owns(name) { + return nil } + m.resetSeat() + return []Event{{RoomID: m.RoomID, TableID: m.Config.ID, Type: EventLeft, Player: name, + Message: fmt.Sprintf("%s stands up from the slots machine.", name), Color: "casino_action", Public: true}} } func (m *Machine) hasParticipant(name string) bool { diff --git a/internal/casino/machine_test.go b/internal/casino/machine_test.go index 386e4f9..79d38fc 100644 --- a/internal/casino/machine_test.go +++ b/internal/casino/machine_test.go @@ -42,3 +42,56 @@ func TestMachineLeaveResetsPayoutRemainder(t *testing.T) { t.Fatalf("payoutRemainder=%v leaked to the next player", m.payoutRemainder) } } + +func TestMachineLeaveStopsAutoBet(t *testing.T) { + m := newMachine(1, MachineConfig{ID: "slots", Game: GameSlots}, rand.New(rand.NewSource(7))) + m.player = &Participant{Name: "alice", Connected: true} + m.autoBet, m.autoCountdown = true, 2 + m.leave("alice") + if m.autoBet || m.autoCountdown != 0 { + t.Fatalf("leave did not stop autobet: autoBet=%v countdown=%d", m.autoBet, m.autoCountdown) + } + if events := m.tick(); len(events) != 0 { + t.Fatalf("empty machine with stale autobet emitted events: %+v", events) + } +} + +func TestMachineDisconnectFreesSeatAndForfeitsSpin(t *testing.T) { + m := newMachine(1, MachineConfig{ID: "slots", Game: GameSlots, SpinTicks: 1}, rand.New(rand.NewSource(7))) + w := &testWallet{credits: 100} + m.player = &Participant{Name: "alice", Connected: true, Wallet: w} + m.autoBet, m.autoCountdown = true, 2 + m.spin = &SlotSpin{} + m.phase = machineSpinning + m.bet = 20 + // Disconnect removes the player and discards the in-flight spin. + events := m.markDisconnected("alice") + if m.player != nil { + t.Fatal("disconnect did not free the seat") + } + if m.spin != nil || m.phase != machineReady { + t.Fatalf("in-flight spin was not discarded: spin=%v phase=%s", m.spin, m.phase) + } + if m.autoBet || m.autoCountdown != 0 { + t.Fatalf("autobet not cleared: autoBet=%v countdown=%d", m.autoBet, m.autoCountdown) + } + if m.bet != 0 || m.payoutRemainder != 0 { + t.Fatalf("wager state leaked: bet=%d remainder=%.2f", m.bet, m.payoutRemainder) + } + if len(events) == 0 || events[0].Type != EventLeft { + t.Fatalf("no public stand-up event: %+v", events) + } + // The seatel is immediately available. + m.join("bob") + if m.player == nil || m.player.Name != "bob" { + t.Fatalf("seat not available for the next player: player=%+v", m.player) + } + // The forfeited spin never pays out. + if w.paid != 0 { + t.Fatalf("forfeited spin paid credits: paid=%d", w.paid) + } + // The machine is idle after a disconnect. + if events := m.tick(); len(events) != 0 { + t.Fatalf("empty machine emitted events after disconnect: %+v", events) + } +} diff --git a/internal/casino/manager.go b/internal/casino/manager.go index 60bf14d..a9bab92 100644 --- a/internal/casino/manager.go +++ b/internal/casino/manager.go @@ -22,6 +22,10 @@ func NewManager(seed int64) *Manager { } } +func tableKey(roomID int, tableID string) string { + return fmt.Sprintf("%d:%s", roomID, tableID) +} + func (m *Manager) EnsureMachine(roomID int, cfg MachineConfig) *Machine { cfg = cfg.Normalize() key := tableKey(roomID, cfg.ID) @@ -35,10 +39,8 @@ func (m *Manager) EnsureMachine(roomID int, cfg MachineConfig) *Machine { return machine } -func tableKey(roomID int, tableID string) string { - return fmt.Sprintf("%d:%s", roomID, tableID) -} - +// EnsureTable returns the table for cfg, or nil when cfg.Game has no +// multiplayer table implementation. func (m *Manager) EnsureTable(roomID int, cfg TableConfig) *Table { cfg = cfg.Normalize() key := tableKey(roomID, cfg.ID) @@ -48,6 +50,9 @@ func (m *Manager) EnsureTable(roomID int, cfg TableConfig) *Table { return table } table := newTable(roomID, cfg, m.rng) + if table == nil { + return nil + } m.tables[key] = table return table } @@ -58,13 +63,30 @@ func (m *Manager) Table(roomID int, tableID string) *Table { return m.tables[tableKey(roomID, tableID)] } -func (m *Manager) Tick() []Event { +// snapshotTables copies the live table list under the lock so callers can +// iterate without holding it (table methods take their own locks). +func (m *Manager) snapshotTables() []*Table { m.mu.Lock() tables := make([]*Table, 0, len(m.tables)) for _, table := range m.tables { tables = append(tables, table) } m.mu.Unlock() + return tables +} + +func (m *Manager) snapshotMachines() []*Machine { + m.mu.Lock() + machines := make([]*Machine, 0, len(m.machines)) + for _, machine := range m.machines { + machines = append(machines, machine) + } + m.mu.Unlock() + return machines +} + +func (m *Manager) Tick() []Event { + tables := m.snapshotTables() sort.Slice(tables, func(i, j int) bool { if tables[i].RoomID != tables[j].RoomID { return tables[i].RoomID < tables[j].RoomID @@ -75,12 +97,7 @@ func (m *Manager) Tick() []Event { for _, table := range tables { events = append(events, table.tick()...) } - m.mu.Lock() - machines := make([]*Machine, 0, len(m.machines)) - for _, machine := range m.machines { - machines = append(machines, machine) - } - m.mu.Unlock() + machines := m.snapshotMachines() sort.Slice(machines, func(i, j int) bool { if machines[i].RoomID != machines[j].RoomID { return machines[i].RoomID < machines[j].RoomID @@ -93,62 +110,61 @@ func (m *Manager) Tick() []Event { return events } -func (m *Manager) JoinMachine(roomID int, cfg MachineConfig, name string) []Event { - return m.EnsureMachine(roomID, cfg).join(name) -} - -func (m *Manager) SetMachineBet(roomID int, machineID, name string, amount int, wallet Wallet) []Event { +// withMachine runs fn against the named machine when it exists. +func (m *Manager) withMachine(roomID int, machineID string, fn func(*Machine) []Event) []Event { m.mu.Lock() machine := m.machines[tableKey(roomID, machineID)] m.mu.Unlock() if machine == nil { return nil } - return machine.setBet(name, amount, wallet) + return fn(machine) +} + +func (m *Manager) JoinMachine(roomID int, cfg MachineConfig, name string) []Event { + return m.EnsureMachine(roomID, cfg).join(name) +} + +func (m *Manager) SetMachineBet(roomID int, machineID, name string, amount int, wallet Wallet) []Event { + return m.withMachine(roomID, machineID, func(machine *Machine) []Event { + return machine.setBet(name, amount, wallet) + }) } func (m *Manager) StartMachine(roomID int, machineID, name string, wallet Wallet) []Event { - m.mu.Lock() - machine := m.machines[tableKey(roomID, machineID)] - m.mu.Unlock() - if machine == nil { - return nil - } - return machine.start(name, wallet) + return m.withMachine(roomID, machineID, func(machine *Machine) []Event { + return machine.start(name, wallet) + }) } func (m *Manager) EnableMachineAutoBet(roomID int, machineID, name string, wallet Wallet) []Event { - m.mu.Lock() - machine := m.machines[tableKey(roomID, machineID)] - m.mu.Unlock() - if machine == nil { - return nil - } - return machine.enableAutoBet(name, wallet) + return m.withMachine(roomID, machineID, func(machine *Machine) []Event { + return machine.enableAutoBet(name, wallet) + }) } func (m *Manager) StopMachineAutoBet(roomID int, machineID, name string) ([]Event, bool) { - m.mu.Lock() - machine := m.machines[tableKey(roomID, machineID)] - m.mu.Unlock() - if machine == nil { - return nil, false - } - return machine.stopAutoBet(name) + stopped := false + events := m.withMachine(roomID, machineID, func(machine *Machine) []Event { + events, ok := machine.stopAutoBet(name) + stopped = ok + return events + }) + return events, stopped } func (m *Manager) LeaveMachine(roomID int, machineID, name string) []Event { - m.mu.Lock() - machine := m.machines[tableKey(roomID, machineID)] - m.mu.Unlock() - if machine == nil { - return nil - } - return machine.leave(name) + return m.withMachine(roomID, machineID, func(machine *Machine) []Event { + return machine.leave(name) + }) } func (m *Manager) Join(roomID int, cfg TableConfig, name string) []Event { - return m.EnsureTable(roomID, cfg).join(name) + table := m.EnsureTable(roomID, cfg) + if table == nil { + return []Event{{RoomID: roomID, TableID: cfg.ID, Type: EventResult, PrivateTo: name, Message: "That game is not available here."}} + } + return table.join(name) } func (m *Manager) Leave(roomID int, tableID, name string) []Event { @@ -179,40 +195,19 @@ func (m *Manager) Rebet(roomID int, tableID, name string) (string, int) { return "", 0 } +// MarkDisconnected removes the player from every table and machine, +// forfeiting any wager they had committed, so seats free up immediately. func (m *Manager) MarkDisconnected(name string) []Event { - m.mu.Lock() - tables := make([]*Table, 0, len(m.tables)) - for _, table := range m.tables { - tables = append(tables, table) - } - m.mu.Unlock() var events []Event - for _, table := range tables { + for _, table := range m.snapshotTables() { events = append(events, table.markDisconnected(name)...) } - m.mu.Lock() - machines := make([]*Machine, 0, len(m.machines)) - for _, machine := range m.machines { - machines = append(machines, machine) - } - m.mu.Unlock() - for _, machine := range machines { + for _, machine := range m.snapshotMachines() { events = append(events, machine.markDisconnected(name)...) } return events } -func (m *Manager) MarkConnected(name string) { - m.mu.Lock() - defer m.mu.Unlock() - for _, table := range m.tables { - table.markConnected(name) - } - for _, machine := range m.machines { - machine.markConnected(name) - } -} - func (m *Manager) Participation(name string) (*Table, bool) { m.mu.Lock() defer m.mu.Unlock() diff --git a/internal/casino/manager_test.go b/internal/casino/manager_test.go index 818f397..134798a 100644 --- a/internal/casino/manager_test.go +++ b/internal/casino/manager_test.go @@ -32,54 +32,14 @@ func TestMixedWagerUsesChipsFirst(t *testing.T) { } } -func TestSoloBetResolvesImmediately(t *testing.T) { +func TestJoinUnknownTableGameRejected(t *testing.T) { m := NewManager(1) - cfg := TableConfig{ID: "slots", Game: GameSlots} - m.Join(1, cfg, "alice") - w := &testWallet{credits: 100} - events := m.Bet(1, "slots", "alice", "", 10, w) - found := false - for _, event := range events { - if event.Type == EventRoundStarted { - found = true - } - } - if !found { - t.Fatal("solo bet did not resolve immediately") - } -} - -func TestMultiplePlayersWaitForBets(t *testing.T) { - m := NewManager(1) - cfg := TableConfig{ID: "slots", Game: GameSlots, BettingTicks: 2} - m.Join(1, cfg, "alice") - m.Join(1, cfg, "bob") - wa, wb := &testWallet{credits: 100}, &testWallet{credits: 100} - events := m.Bet(1, "slots", "alice", "", 10, wa) - for _, event := range events { - if event.Type == EventRoundStarted { - t.Fatal("round started before all players bet or timeout") - } - } - m.Bet(1, "slots", "bob", "", 10, wb) -} - -func TestTableBettingIgnoresDisconnected(t *testing.T) { - m := NewManager(1) - cfg := TableConfig{ID: "slots", Game: GameSlots, BettingTicks: 20} - m.Join(1, cfg, "alice") - m.Join(1, cfg, "bob") - m.MarkDisconnected("bob") - w := &testWallet{credits: 100} - events := m.Bet(1, "slots", "alice", "", 10, w) - found := false - for _, event := range events { - if event.Type == EventRoundStarted { - found = true - } + events := m.Join(1, TableConfig{ID: "slots", Game: GameSlots}, "alice") + if len(events) != 1 || !contains(events[0].Message, "not available") { + t.Fatalf("joining an unsupported table game: %+v", events) } - if !found { - t.Fatal("disconnected player stalled the table's round") + if m.Table(1, "slots") != nil { + t.Fatal("unsupported game was cached as a table") } } diff --git a/internal/casino/seats.go b/internal/casino/seats.go new file mode 100644 index 0000000..6f44d6b --- /dev/null +++ b/internal/casino/seats.go @@ -0,0 +1,83 @@ +package casino + +// seatedPlayer is implemented by every table-game player type; they all embed +// Participant and expose it here so the seat set can manage shared state +// (connection, wagers) without knowing game-specific fields. +type seatedPlayer interface { + participantPtr() *Participant +} + +// seatSet tracks the ordered occupants of a multiplayer table. It owns the +// participant map and seat-order slice every table game used to maintain by +// hand; game structs keep their own player type and delegate the plumbing. +type seatSet[P seatedPlayer] struct { + order []string + players map[string]P +} + +func newSeatSet[P seatedPlayer]() seatSet[P] { + return seatSet[P]{players: make(map[string]P)} +} + +func (s *seatSet[P]) get(name string) P { return s.players[name] } + +func (s *seatSet[P]) has(name string) bool { + _, ok := s.players[name] + return ok +} + +// count reports the number of seated players. +func (s *seatSet[P]) count() int { return len(s.order) } + +// nameAt returns the player name at a seat index, or "" when out of range. +func (s *seatSet[P]) nameAt(i int) string { + if i < 0 || i >= len(s.order) { + return "" + } + return s.order[i] +} + +// indexOf returns the seat index of a player, or -1 when not seated. +func (s *seatSet[P]) indexOf(name string) int { + for i, n := range s.order { + if n == name { + return i + } + } + return -1 +} + +func (s *seatSet[P]) add(name string, p P) { + s.players[name] = p + s.order = append(s.order, name) +} + +func (s *seatSet[P]) remove(name string) { + delete(s.players, name) + for i, n := range s.order { + if n == name { + s.order = append(s.order[:i], s.order[i+1:]...) + break + } + } +} + +// ordered lists the seated players in seat order. +func (s *seatSet[P]) ordered() []P { + out := make([]P, 0, len(s.order)) + for _, name := range s.order { + if p, ok := s.players[name]; ok { + out = append(out, p) + } + } + return out +} + +// participants is the seat-ordered shared view used by the betting helpers. +func (s *seatSet[P]) participants() []*Participant { + out := make([]*Participant, 0, len(s.order)) + for _, p := range s.ordered() { + out = append(out, p.participantPtr()) + } + return out +} diff --git a/internal/casino/slots.go b/internal/casino/slots.go index 5c387c8..aba8f91 100644 --- a/internal/casino/slots.go +++ b/internal/casino/slots.go @@ -37,18 +37,6 @@ type SlotWin struct { Payout float64 } -// slotResult preserves the small rules-engine hook used by the unfinished -// generic table implementation while slots move onto SlotMachine. -type slotSpin struct { - Display string - Payout int -} - -func slotResult(rng *rand.Rand, bet int) slotSpin { - spin := generateSlotSpin(rng, bet) - return slotSpin{Display: spin.render(5), Payout: int(math.Floor(spin.Payout + 0.5))} -} - func DefaultHuffAndPuffProfile() SlotProfile { return SlotProfile{ Rows: 3, Reels: 5, Ways: 243, @@ -92,10 +80,6 @@ func (p SlotProfile) symbol(id string) *SlotSymbol { return nil } -func generateSlotSpin(rng *rand.Rand, bet int) *SlotSpin { - return generateSlotSpinWithProfile(rng, bet, DefaultHuffAndPuffProfile()) -} - func generateSlotSpinWithProfile(rng *rand.Rand, bet int, profile SlotProfile) *SlotSpin { p := profile.Normalize() spin := &SlotSpin{} @@ -175,24 +159,6 @@ func matchingLength(grid [3][5]string, symbol string) (int, int) { return length, ways } -func (s *SlotSpin) render(stoppedReels int) string { - var out string - out += "+---------+---------+---------+---------+---------+\n" - for row := 0; row < 3; row++ { - out += "|" - for reel := 0; reel < 5; reel++ { - value := "..." - if reel < stoppedReels { - value = s.Grid[row][reel] - } - out += fmt.Sprintf(" %-7s |", value) - } - out += "\n" - } - out += "+---------+---------+---------+---------+---------+" - return out -} - // CalculateRTP returns the exact mathematical RTP for the independent-cell // model used by the baseline machine, before integer-credit rounding. func CalculateRTP(profile SlotProfile) float64 { diff --git a/internal/casino/table.go b/internal/casino/table.go index be2ea18..80ae451 100644 --- a/internal/casino/table.go +++ b/internal/casino/table.go @@ -1,9 +1,7 @@ package casino import ( - "fmt" "math/rand" - "sync" ) // tableGame is a multiplayer table game implementation. Table routes every @@ -17,7 +15,6 @@ type tableGame interface { action(name, action string) []Event rebet(name string) (spot string, amount int) markDisconnected(name string) []Event - markConnected(name string) hasParticipant(name string) bool } @@ -52,6 +49,8 @@ type Table struct { game tableGame } +// newTable builds the table game for cfg.Game, or nil when the game has no +// multiplayer table implementation. func newTable(roomID int, cfg TableConfig, rng *rand.Rand) *Table { cfg = cfg.Normalize() t := &Table{RoomID: roomID, Config: cfg} @@ -61,7 +60,7 @@ func newTable(roomID int, cfg TableConfig, rng *rand.Rand) *Table { case GameBaccarat: t.game = newBaccaratTable(roomID, cfg, rng) default: - t.game = newGenericTable(roomID, cfg, rng) + return nil } return t } @@ -78,208 +77,4 @@ func (t *Table) action(name, action string) []Event { return t.game.action(name, func (t *Table) rebet(name string) (string, int) { return t.game.rebet(name) } func (t *Table) markDisconnected(name string) []Event { return t.game.markDisconnected(name) } -func (t *Table) markConnected(name string) { t.game.markConnected(name) } func (t *Table) hasParticipant(name string) bool { return t.game.hasParticipant(name) } - -type tablePhase string - -const ( - phaseWaiting tablePhase = "waiting" - phaseBetting tablePhase = "betting" -) - -// genericTable is the fallback table game: every bettor's wager resolves -// immediately against a simple spin once all connected players have bet. -type genericTable struct { - mu sync.Mutex - RoomID int - Config TableConfig - - participants map[string]*Participant - order []string - phase tablePhase - timer countdown - rng *rand.Rand -} - -func newGenericTable(roomID int, cfg TableConfig, rng *rand.Rand) *genericTable { - return &genericTable{ - RoomID: roomID, - Config: cfg, - phase: phaseWaiting, - participants: make(map[string]*Participant), - rng: rng, - } -} - -func (t *genericTable) seats() []*Participant { - seats := make([]*Participant, 0, len(t.order)) - for _, name := range t.order { - if p := t.participants[name]; p != nil { - seats = append(seats, p) - } - } - return seats -} - -func (t *genericTable) join(name string) []Event { - t.mu.Lock() - defer t.mu.Unlock() - if p := t.participants[name]; p != nil { - p.Connected = true - return []Event{{RoomID: t.RoomID, TableID: t.Config.ID, Type: EventJoined, Player: name, PrivateTo: name, Message: fmt.Sprintf("You resume your place at the %s table.", t.Config.ID)}} - } - if len(t.participants) >= t.Config.MaxPlayers { - return []Event{{RoomID: t.RoomID, TableID: t.Config.ID, Type: EventResult, Player: name, PrivateTo: name, Message: "That table is full."}} - } - t.participants[name] = &Participant{Name: name, Connected: true} - t.order = append(t.order, name) - return []Event{{RoomID: t.RoomID, TableID: t.Config.ID, Type: EventJoined, Player: name, Message: fmt.Sprintf("%s joins the %s table.", name, t.Config.ID), Public: true}} -} - -func (t *genericTable) leave(name string) []Event { - t.mu.Lock() - defer t.mu.Unlock() - p := t.participants[name] - if p == nil { - return nil - } - if t.phase != phaseWaiting { - return []Event{{RoomID: t.RoomID, TableID: t.Config.ID, Type: EventResult, Player: name, PrivateTo: name, Message: "You're in the middle of a game!"}} - } - delete(t.participants, name) - for i, n := range t.order { - if n == name { - t.order = append(t.order[:i], t.order[i+1:]...) - break - } - } - events := []Event{{RoomID: t.RoomID, TableID: t.Config.ID, Type: EventLeft, Player: name, Message: fmt.Sprintf("%s stops playing at the %s table.", name, t.Config.ID), Public: true}} - if len(t.participants) == 0 { - events = append(events, Event{RoomID: t.RoomID, TableID: t.Config.ID, Type: EventClosed, Message: fmt.Sprintf("The %s table closes.", t.Config.ID), Public: true}) - } - return events -} - -func (t *genericTable) bet(name, spot string, amount int, wallet Wallet) []Event { - t.mu.Lock() - defer t.mu.Unlock() - p := t.participants[name] - if p == nil { - return []Event{{RoomID: t.RoomID, TableID: t.Config.ID, Type: EventResult, Player: name, PrivateTo: name, Message: "You aren't playing at that table."}} - } - if spot != "" { - return []Event{{RoomID: t.RoomID, TableID: t.Config.ID, Type: EventResult, Player: name, PrivateTo: name, Message: "That table doesn't take a bet target."}} - } - if t.phase != phaseWaiting && t.phase != phaseBetting { - return []Event{{RoomID: t.RoomID, TableID: t.Config.ID, Type: EventResult, Player: name, PrivateTo: name, Message: "That table isn't accepting bets."}} - } - if p.HasBet { - return []Event{{RoomID: t.RoomID, TableID: t.Config.ID, Type: EventResult, Player: name, PrivateTo: name, Message: "You have already bet this round."}} - } - if amount < t.Config.MinBet || amount > t.Config.MaxBet { - return []Event{{RoomID: t.RoomID, TableID: t.Config.ID, Type: EventResult, Player: name, PrivateTo: name, Message: fmt.Sprintf("Bets must be between %d and %d.", t.Config.MinBet, t.Config.MaxBet)}} - } - wager, ok := wallet.Wager(amount) - if !ok { - return []Event{{RoomID: t.RoomID, TableID: t.Config.ID, Type: EventResult, Player: name, PrivateTo: name, Message: "You don't have enough chips and credits for that bet."}} - } - p.HasBet = true - p.Wager = wager - p.Wallet = wallet - if t.phase == phaseWaiting && len(t.participants) > 1 { - t.phase = phaseBetting - t.timer.start(t.Config.BettingTicks) - } - events := []Event{{RoomID: t.RoomID, TableID: t.Config.ID, Type: EventBetPlaced, Player: name, Message: fmt.Sprintf("%s bets %d credits at the %s table.", name, amount, t.Config.ID), Public: true}} - if t.phase == phaseBetting && t.timer.clock == 0 { - events = append(events, Event{RoomID: t.RoomID, TableID: t.Config.ID, Type: EventBetting, Message: fmt.Sprintf("Betting is open at the %s table for %d ticks.", t.Config.ID, t.Config.BettingTicks), Public: true}) - } - if len(t.participants) == 1 || allBet(t.seats()) { - events = append(events, t.resolveRound()...) - } - return events -} - -func (t *genericTable) tick() []Event { - t.mu.Lock() - defer t.mu.Unlock() - if t.phase != phaseBetting { - return nil - } - t.timer.tick() - if allBet(t.seats()) { - return t.resolveRound() - } - if !t.timer.expired() { - return nil - } - var events []Event - for _, name := range sitOutNames(t.seats()) { - events = append(events, Event{RoomID: t.RoomID, TableID: t.Config.ID, Type: EventTimedOut, Player: name, Message: fmt.Sprintf("%s sits out this round.", name), Public: true}) - } - if anyBet(t.seats()) { - events = append(events, t.resolveRound()...) - } else { - t.resetRound() - } - return events -} - -func (t *genericTable) resolveRound() []Event { - var events []Event - for _, name := range t.order { - p := t.participants[name] - if p == nil || !p.HasBet { - continue - } - result := slotResult(t.rng, p.Wager.Total) - message := fmt.Sprintf("%s spins: %s", name, result.Display) - events = append(events, Event{RoomID: t.RoomID, TableID: t.Config.ID, Type: EventRoundStarted, Player: name, Message: message, Public: true}) - if result.Payout > 0 { - if p.Wallet != nil { - p.Wallet.PayCredits(result.Payout) - } - events = append(events, Event{RoomID: t.RoomID, TableID: t.Config.ID, Type: EventPayout, Player: name, PrivateTo: name, Message: fmt.Sprintf("You win %d credits!", result.Payout)}) - } - } - t.resetRound() - return events -} - -func (t *genericTable) resetRound() { - t.phase = phaseWaiting - t.timer.stop() - clearBets(t.seats()) -} - -func (t *genericTable) markDisconnected(name string) []Event { - t.mu.Lock() - defer t.mu.Unlock() - if p := t.participants[name]; p != nil { - p.Connected = false - } - return nil -} - -func (t *genericTable) markConnected(name string) { - t.mu.Lock() - defer t.mu.Unlock() - if p := t.participants[name]; p != nil { - p.Connected = true - } -} - -func (t *genericTable) hasParticipant(name string) bool { - t.mu.Lock() - defer t.mu.Unlock() - return t.participants[name] != nil -} - -func (t *genericTable) action(name, action string) []Event { - return []Event{{RoomID: t.RoomID, TableID: t.Config.ID, Type: EventResult, PrivateTo: name, Message: "That game has no player actions."}} -} - -func (t *genericTable) rebet(name string) (string, int) { - return "", t.Config.MinBet -} diff --git a/internal/game/casino.go b/internal/game/casino.go index 785666e..9f3b524 100644 --- a/internal/game/casino.go +++ b/internal/game/casino.go @@ -43,6 +43,10 @@ func (w casinoWallet) PayCredits(amount int) { w.g.AccountStore.SaveCharacter(w.p) } +// casinoAffordable is the most a player can wager: chips are spent first, +// then credits. +func casinoAffordable(p *player.Player) int { return p.Credits + countChips(p) } + func (g *Game) casinoTable(roomID int, gameName string) (*casino.Table, bool) { room, err := g.World.LoadRoom(roomID) if err != nil { @@ -50,7 +54,11 @@ func (g *Game) casinoTable(roomID int, gameName string) (*casino.Table, bool) { } for _, cfg := range room.CasinoTables { if strings.EqualFold(string(cfg.Game), gameName) { - return g.Casino.EnsureTable(roomID, cfg), true + table := g.Casino.EnsureTable(roomID, cfg) + if table == nil { + return nil, false + } + return table, true } } return nil, false @@ -126,7 +134,12 @@ func (g *Game) executeBet(sess *net.Session, args []string, rawInput string) { } if len(args) == 1 { if args[0] == "max" { - g.emitCasinoEvents(g.Casino.SetMachineBet(machine.RoomID, machine.Config.ID, p.Name, machine.Config.MaxBet, casinoWallet{g: g, p: p})) + amount := min(machine.Config.MaxBet, casinoAffordable(p)) + if amount < 1 { + sess.WriteLine("You don't have any chips or credits to bet.") + return + } + g.emitCasinoEvents(g.Casino.SetMachineBet(machine.RoomID, machine.Config.ID, p.Name, amount, casinoWallet{g: g, p: p})) return } if amount, err := strconv.Atoi(args[0]); err == nil && amount > 0 { @@ -163,7 +176,12 @@ func (g *Game) executeBet(sess *net.Session, args []string, rawInput string) { sess.WriteLine("Use bet, bet <amount> [on <target>], bet min, or bet max.") return } - amount, hasAmount = table.Config.MaxBet, true + amount = min(table.Config.MaxBet, casinoAffordable(p)) + if amount < 1 { + sess.WriteLine("You don't have any chips or credits to bet.") + return + } + hasAmount = true continue } if value, err := strconv.Atoi(arg); err == nil && value > 0 { diff --git a/internal/game/casino_test.go b/internal/game/casino_test.go index 8affe29..9f31a37 100644 --- a/internal/game/casino_test.go +++ b/internal/game/casino_test.go @@ -42,6 +42,12 @@ func TestExecuteBetRejectsInvalidMachineArgs(t *testing.T) { if !strings.Contains(string(conn.data), "You don't have enough chips and credits") { t.Fatalf("bet 10 should attempt an auto-spin and fail at the empty wallet, got %q", string(conn.data)) } + // Bet max with an empty wallet is rejected before any wager is attempted. + conn.data = nil + g.executeBet(sess, []string{"max"}, "bet max") + if !strings.Contains(string(conn.data), "You don't have any chips or credits to bet.") { + t.Fatalf("bet max with empty wallet: expected no-funds message, got %q", string(conn.data)) + } } func TestExecuteBetParsesBaccaratSpots(t *testing.T) { diff --git a/internal/game/cmd_quit.go b/internal/game/cmd_quit.go index b7ec972..fa4038b 100644 --- a/internal/game/cmd_quit.go +++ b/internal/game/cmd_quit.go @@ -38,6 +38,7 @@ func (g *Game) doQuit(sess *net.Session) { case 0: g.cancelRest(p.Name) g.restoreGodPlayer(p) + g.emitCasinoEvents(g.Casino.MarkDisconnected(p.Name)) g.AccountStore.SaveCharacter(p) g.charsMu.Lock() delete(g.loggedInChars, p.Name) diff --git a/internal/game/core_login_char.go b/internal/game/core_login_char.go index 4f59f1b..38499e2 100644 --- a/internal/game/core_login_char.go +++ b/internal/game/core_login_char.go @@ -108,7 +108,6 @@ func (g *Game) connectCharacter(sess *net.Session, name string) { if g.Hub != nil { g.Hub.EnterRoom(sess, p.RoomID) } - g.Casino.MarkConnected(name) g.doLook(sess) g.checkAggro(sess) |
