aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--internal/casino/baccarat.go123
-rw-r--r--internal/casino/baccarat_test.go37
-rw-r--r--internal/casino/betting.go24
-rw-r--r--internal/casino/blackjack.go188
-rw-r--r--internal/casino/blackjack_test.go145
-rw-r--r--internal/casino/cards.go8
-rw-r--r--internal/casino/machine.go57
-rw-r--r--internal/casino/machine_test.go53
-rw-r--r--internal/casino/manager.go137
-rw-r--r--internal/casino/manager_test.go52
-rw-r--r--internal/casino/seats.go83
-rw-r--r--internal/casino/slots.go34
-rw-r--r--internal/casino/table.go211
-rw-r--r--internal/game/casino.go24
-rw-r--r--internal/game/casino_test.go6
-rw-r--r--internal/game/cmd_quit.go1
-rw-r--r--internal/game/core_login_char.go1
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)