aboutsummaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
Diffstat (limited to 'internal')
-rw-r--r--internal/casino/baccarat.go377
-rw-r--r--internal/casino/baccarat_test.go355
-rw-r--r--internal/casino/blackjack.go132
-rw-r--r--internal/casino/blackjack_test.go50
-rw-r--r--internal/casino/cards.go80
-rw-r--r--internal/casino/manager.go10
-rw-r--r--internal/casino/manager_test.go8
-rw-r--r--internal/casino/table.go147
-rw-r--r--internal/casino/types.go52
-rw-r--r--internal/game/casino.go143
-rw-r--r--internal/game/casino_test.go64
-rw-r--r--internal/game/cmd_registry.go12
-rw-r--r--internal/game/render_help.go2
13 files changed, 1186 insertions, 246 deletions
diff --git a/internal/casino/baccarat.go b/internal/casino/baccarat.go
new file mode 100644
index 0000000..729851d
--- /dev/null
+++ b/internal/casino/baccarat.go
@@ -0,0 +1,377 @@
+package casino
+
+import (
+ "fmt"
+ "math"
+ "math/rand"
+ "sync"
+)
+
+type baccaratPhase string
+
+const (
+ baccaratWaiting baccaratPhase = "waiting"
+ baccaratBetting baccaratPhase = "betting"
+)
+
+// baccaratSpots are the outcomes a participant may wager on.
+const (
+ baccaratSpotPlayer = "player"
+ baccaratSpotBanker = "banker"
+ baccaratSpotTie = "tie"
+)
+
+func baccaratSpotValid(spot string) bool {
+ return spot == baccaratSpotPlayer || spot == baccaratSpotBanker || spot == baccaratSpotTie
+}
+
+type baccaratPlayer struct {
+ Participant
+ lastBet int
+ lastSpot string
+}
+
+type baccaratTable struct {
+ mu sync.Mutex
+ tableBase
+ Rules BaccaratConfig
+ participants map[string]*baccaratPlayer
+ order []string
+ phase baccaratPhase
+ timer countdown
+ shoe *shoe
+}
+
+func newBaccaratTable(roomID int, cfg TableConfig, rng *rand.Rand) *baccaratTable {
+ cfg = cfg.Normalize()
+ rules := cfg.Baccarat.Normalize()
+ return &baccaratTable{
+ tableBase: tableBase{RoomID: roomID, Config: cfg}, Rules: rules,
+ participants: make(map[string]*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"
+ e.Menu = &MenuView{Kind: MenuBaccaratBet}
+ return e
+}
+
+func (t *baccaratTable) menu() string {
+ return "New hand! You can bet, bet <amount> on player|banker|tie (inc. min/max), or stop."
+}
+
+func (t *baccaratTable) join(name string) []Event {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ if p := t.participants[name]; p != nil {
+ p.Connected = true
+ return []Event{t.menuEvent(name)}
+ }
+ if len(t.participants) >= 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)
+ events := []Event{t.playerAction(name, fmt.Sprintf("%s sits down.", name), "You sit down.")}
+ events = append(events, t.menuEvent(name))
+ return events
+}
+
+func (t *baccaratTable) leave(name string) []Event {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ p := t.participants[name]
+ if p == nil {
+ 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
+ }
+ }
+ events := []Event{t.playerAction(name, fmt.Sprintf("%s stands up.", name), "You stand up.")}
+ if len(t.participants) == 0 {
+ events = append(events, t.event("The baccarat table closes.", true))
+ }
+ return events
+}
+
+func (t *baccaratTable) 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{t.private(name, "You aren't playing baccarat.")}
+ }
+ if spot == "" {
+ spot = p.lastSpot
+ }
+ if spot == "" {
+ return []Event{t.private(name, "Bet on what? Try \"bet <amount> on player\", \"bet <amount> on banker\", or \"bet <amount> on tie\".")}
+ }
+ if !baccaratSpotValid(spot) {
+ return []Event{t.private(name, "Baccarat bets must be on player, banker, or tie.")}
+ }
+ if p.HasBet {
+ return []Event{t.private(name, "You have already bet this round.")}
+ }
+ if amount < t.Config.MinBet || amount > t.Config.MaxBet {
+ return []Event{t.private(name, fmt.Sprintf("Baccarat bets must be between %d and %d.", t.Config.MinBet, t.Config.MaxBet))}
+ }
+ wager, ok := wallet.Wager(amount)
+ if !ok {
+ return []Event{t.private(name, "You don't have enough chips and credits for that bet.")}
+ }
+ p.HasBet, p.Wager, p.Wallet, p.lastBet, p.lastSpot = true, wager, wallet, amount, spot
+ 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 {
+ t.phase = baccaratBetting
+ t.timer.start(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()) {
+ events = append(events, t.deal()...)
+ }
+ return events
+}
+
+func (t *baccaratTable) tick() []Event {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ if t.phase != baccaratBetting {
+ return nil
+ }
+ t.timer.tick()
+ if allBet(t.seats()) {
+ return t.deal()
+ }
+ if !t.timer.expired() {
+ return nil
+ }
+ var events []Event
+ for _, name := range sitOutNames(t.seats()) {
+ events = append(events, t.event(fmt.Sprintf("%s sits out this baccarat round.", name), true))
+ }
+ if anyBet(t.seats()) {
+ events = append(events, t.deal()...)
+ } else {
+ t.resetRound()
+ }
+ return events
+}
+
+func (t *baccaratTable) action(name, action string) []Event {
+ return []Event{t.private(name, "That game has no player actions.")}
+}
+
+func (t *baccaratTable) rebet(name string) (string, int) {
+ if p := t.participants[name]; p != nil && p.lastBet > 0 {
+ return p.lastSpot, p.lastBet
+ }
+ return "", t.Config.MinBet
+}
+
+// deal plays out the hand by the punto banco tableau, settles every wager,
+// and resets the table for the next round.
+func (t *baccaratTable) deal() []Event {
+ player, banker := playTableau(t.shoe)
+ playerTotal, bankerTotal := baccaratTotal(player), baccaratTotal(banker)
+ 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 {
+ 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)
+ if result == "win" {
+ if p.lastSpot == baccaratSpotTie {
+ settlement.Color = "casino_jackpot_win"
+ } else {
+ settlement.Color = "casino_big_win"
+ }
+ }
+ 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))
+ }
+ }
+ return events
+}
+
+func (t *baccaratTable) snapshot(player, banker []card) *BaccaratSnapshot {
+ snapshot := &BaccaratSnapshot{
+ PlayerScore: fmt.Sprintf("%d", baccaratTotal(player)),
+ BankerScore: fmt.Sprintf("%d", baccaratTotal(banker)),
+ }
+ for _, c := range player {
+ snapshot.Player = append(snapshot.Player, CardView{Rank: c.rank, Suit: c.suit})
+ }
+ 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 {
+ continue
+ }
+ snapshot.Bets = append(snapshot.Bets, BaccaratBetView{Name: name, Spot: p.lastSpot, Amount: p.Wager.Total})
+ }
+ return snapshot
+}
+
+func (t *baccaratTable) resetRound() {
+ t.phase = baccaratWaiting
+ t.timer.stop()
+ clearBets(t.seats())
+}
+
+func (t *baccaratTable) 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 *baccaratTable) markConnected(name string) {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ if p := t.participants[name]; p != nil {
+ p.Connected = true
+ }
+}
+
+func (t *baccaratTable) hasParticipant(name string) bool {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ return t.participants[name] != nil
+}
+
+// baccaratTotal scores a hand modulo 10.
+func baccaratTotal(hand []card) int {
+ total := 0
+ for _, c := range hand {
+ total += c.baccaratValue()
+ }
+ return total % 10
+}
+
+// playTableau deals the initial four cards and applies the punto banco
+// drawing rules, returning the final player and banker hands.
+func playTableau(sh *shoe) (player, banker []card) {
+ player = []card{sh.draw(), sh.draw()}
+ banker = []card{sh.draw(), sh.draw()}
+ playerTotal, bankerTotal := baccaratTotal(player), baccaratTotal(banker)
+ if playerTotal >= 8 || bankerTotal >= 8 {
+ // A natural 8 or 9 ends the hand immediately.
+ return player, banker
+ }
+ playerThird := -1
+ if playerTotal <= 5 {
+ c := sh.draw()
+ playerThird = c.baccaratValue()
+ player = append(player, c)
+ }
+ if playerThird < 0 {
+ if bankerTotal <= 5 {
+ banker = append(banker, sh.draw())
+ }
+ return player, banker
+ }
+ if bankerDraws(bankerTotal, playerThird) {
+ banker = append(banker, sh.draw())
+ }
+ return player, banker
+}
+
+// bankerDraws applies the banker tableau when the player took a third card.
+func bankerDraws(bankerTotal, playerThird int) bool {
+ switch bankerTotal {
+ case 0, 1, 2:
+ return true
+ case 3:
+ return playerThird != 8
+ case 4:
+ return playerThird >= 2 && playerThird <= 7
+ case 5:
+ return playerThird >= 4 && playerThird <= 7
+ case 6:
+ return playerThird == 6 || playerThird == 7
+ default: // 7
+ return false
+ }
+}
+
+// settleBaccaratBet returns the result label and total payout (stake plus
+// winnings) for one wager. Player and banker bets push on a tie.
+func settleBaccaratBet(spot string, bet int, playerTotal, bankerTotal int, rules BaccaratConfig) (string, int) {
+ switch {
+ case playerTotal > bankerTotal:
+ if spot == baccaratSpotPlayer {
+ return "win", bet * 2
+ }
+ case bankerTotal > playerTotal:
+ if spot == baccaratSpotBanker {
+ return "win", bet + int(math.Round(float64(bet)*rules.BankerPayout))
+ }
+ default:
+ if spot == baccaratSpotTie {
+ return "win", bet + int(math.Round(float64(bet)*rules.TiePayout))
+ }
+ return "push", bet
+ }
+ return "loss", 0
+}
+
+func baccaratOutcomeMessage(playerTotal, bankerTotal int) string {
+ switch {
+ case playerTotal > bankerTotal:
+ return fmt.Sprintf("Player wins %d over %d.", playerTotal, bankerTotal)
+ case bankerTotal > playerTotal:
+ return fmt.Sprintf("Banker wins %d over %d.", bankerTotal, playerTotal)
+ default:
+ return fmt.Sprintf("Player and banker tie at %d.", playerTotal)
+ }
+}
+
+func baccaratResultMessages(name, result string, payout int) (string, string) {
+ switch result {
+ case "win":
+ return name + " wins!", fmt.Sprintf("You win! +%d credits", payout)
+ case "push":
+ return name + " pushes.", fmt.Sprintf("You push. +%d credits", payout)
+ default:
+ return name + " loses.", "You lose!"
+ }
+}
diff --git a/internal/casino/baccarat_test.go b/internal/casino/baccarat_test.go
new file mode 100644
index 0000000..6d3acb1
--- /dev/null
+++ b/internal/casino/baccarat_test.go
@@ -0,0 +1,355 @@
+package casino
+
+import (
+ "testing"
+)
+
+// tableauShoe builds a shoe that deals the given cards in order; draw pops
+// from the end of the slice, so the deal order is stored reversed.
+func tableauShoe(dealOrder ...card) *shoe {
+ cards := make([]card, len(dealOrder))
+ for i, c := range dealOrder {
+ cards[len(dealOrder)-1-i] = c
+ }
+ return &shoe{cards: cards}
+}
+
+func forceShoe(t *baccaratTable, dealOrder ...card) {
+ t.shoe = tableauShoe(dealOrder...)
+}
+
+func TestBaccaratTotal(t *testing.T) {
+ if got := baccaratTotal([]card{{rank: "K"}, {rank: "Q"}}); got != 0 {
+ t.Fatalf("faces total=%d, want 0", got)
+ }
+ if got := baccaratTotal([]card{{rank: "A"}, {rank: "10"}}); got != 1 {
+ t.Fatalf("ace+ten total=%d, want 1", got)
+ }
+ if got := baccaratTotal([]card{{rank: "9"}, {rank: "8"}, {rank: "7"}}); got != 4 {
+ t.Fatalf("9+8+7 total=%d, want 4 (mod 10)", got)
+ }
+}
+
+func TestBaccaratTableauNaturalStands(t *testing.T) {
+ player, banker := playTableau(tableauShoe(
+ card{rank: "5"}, card{rank: "3"}, // player 8 (natural)
+ card{rank: "2"}, card{rank: "3"}, // banker 5
+ card{rank: "9"}, card{rank: "9"}, // would-be thirds must stay in the shoe
+ ))
+ if len(player) != 2 || len(banker) != 2 {
+ t.Fatalf("natural drew cards: player=%d banker=%d", len(player), len(banker))
+ }
+}
+
+func TestBaccaratTableauPlayerStandsBankerDrawsToFive(t *testing.T) {
+ player, banker := playTableau(tableauShoe(
+ card{rank: "4"}, card{rank: "3"}, // player 7, stands
+ card{rank: "2"}, card{rank: "3"}, // banker 5, draws
+ card{rank: "9"}, // banker third card
+ ))
+ if len(player) != 2 || len(banker) != 3 {
+ t.Fatalf("player stand: player=%d banker=%d, want 2/3", len(player), len(banker))
+ }
+}
+
+func TestBaccaratTableauPlayerStandsBankerStandsOnSix(t *testing.T) {
+ player, banker := playTableau(tableauShoe(
+ card{rank: "4"}, card{rank: "3"}, // player 7, stands
+ card{rank: "4"}, card{rank: "2"}, // banker 6, stands
+ card{rank: "9"},
+ ))
+ if len(player) != 2 || len(banker) != 2 {
+ t.Fatalf("banker 6 vs standing player: player=%d banker=%d, want 2/2", len(player), len(banker))
+ }
+}
+
+func TestBaccaratTableauBankerThirdCardRules(t *testing.T) {
+ tests := []struct {
+ name string
+ bankerCards [2]card
+ playerThird card
+ wantDraw bool
+ }{
+ {"banker 2 always draws", [2]card{{rank: "A"}, {rank: "A"}}, card{rank: "8"}, true},
+ {"banker 3 draws vs 5", [2]card{{rank: "2"}, {rank: "A"}}, card{rank: "5"}, true},
+ {"banker 3 stands vs 8", [2]card{{rank: "2"}, {rank: "A"}}, card{rank: "8"}, false},
+ {"banker 4 draws vs 2", [2]card{{rank: "3"}, {rank: "A"}}, card{rank: "2"}, true},
+ {"banker 4 stands vs A", [2]card{{rank: "3"}, {rank: "A"}}, card{rank: "A"}, false},
+ {"banker 5 draws vs 4", [2]card{{rank: "4"}, {rank: "A"}}, card{rank: "4"}, true},
+ {"banker 5 stands vs 3", [2]card{{rank: "4"}, {rank: "A"}}, card{rank: "3"}, false},
+ {"banker 6 draws vs 6", [2]card{{rank: "5"}, {rank: "A"}}, card{rank: "6"}, true},
+ {"banker 6 stands vs 5", [2]card{{rank: "5"}, {rank: "A"}}, card{rank: "5"}, false},
+ {"banker 7 always stands", [2]card{{rank: "6"}, {rank: "A"}}, card{rank: "6"}, false},
+ }
+ for _, tt := range tests {
+ player, banker := playTableau(tableauShoe(
+ card{rank: "2"}, card{rank: "3"}, // player 5, draws
+ tt.bankerCards[0], tt.bankerCards[1],
+ tt.playerThird, // player third card
+ card{rank: "9"}, // banker third card (if drawn)
+ ))
+ if got := len(banker) == 3; got != tt.wantDraw {
+ t.Errorf("%s: banker drew=%v, want %v (player=%d cards)", tt.name, got, tt.wantDraw, len(player))
+ }
+ if len(player) != 3 {
+ t.Errorf("%s: player=%d cards, want 3", tt.name, len(player))
+ }
+ }
+}
+
+func TestBaccaratSettlement(t *testing.T) {
+ rules := (BaccaratConfig{}).Normalize()
+ if result, payout := settleBaccaratBet(baccaratSpotPlayer, 100, 6, 4, rules); result != "win" || payout != 200 {
+ t.Fatalf("player win: result=%s payout=%d, want win/200", result, payout)
+ }
+ if result, payout := settleBaccaratBet(baccaratSpotBanker, 100, 6, 4, rules); result != "loss" || payout != 0 {
+ t.Fatalf("banker bet on player win: result=%s payout=%d, want loss/0", result, payout)
+ }
+ 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(baccaratSpotTie, 100, 7, 7, rules); result != "win" || payout != 900 {
+ t.Fatalf("tie win: result=%s payout=%d, want win/900 (8:1)", result, payout)
+ }
+ if result, payout := settleBaccaratBet(baccaratSpotPlayer, 100, 7, 7, rules); result != "push" || payout != 100 {
+ t.Fatalf("player bet on tie: result=%s payout=%d, want push/100", result, payout)
+ }
+ if result, payout := settleBaccaratBet(baccaratSpotBanker, 100, 7, 7, rules); result != "push" || payout != 100 {
+ t.Fatalf("banker bet on tie: result=%s payout=%d, want push/100", result, payout)
+ }
+ if result, payout := settleBaccaratBet(baccaratSpotTie, 100, 6, 4, rules); result != "loss" || payout != 0 {
+ t.Fatalf("tie bet on player win: result=%s payout=%d, want loss/0", result, payout)
+ }
+}
+
+func newBaccaratTestTable(cfg TableConfig) *baccaratTable {
+ return newBaccaratTable(1, cfg, newTestRand())
+}
+
+func TestBaccaratSoloBetDealsImmediately(t *testing.T) {
+ table := newBaccaratTestTable(TableConfig{ID: "baccarat", Game: GameBaccarat})
+ table.join("alice")
+ forceShoe(table,
+ card{rank: "5"}, card{rank: "4"}, // player 9 (natural)
+ card{rank: "2"}, card{rank: "3"}, // banker 5
+ )
+ w := &testWallet{credits: 100}
+ events := table.bet("alice", baccaratSpotPlayer, 100, w)
+ if !hasMessage(events, "New baccarat hand!") || !hasMessage(events, "Player wins 9 over 5.") {
+ t.Fatalf("solo bet did not deal: %+v", events)
+ }
+ if w.paid != 200 {
+ t.Fatalf("payout=%d, want 200 (stake + 1:1)", w.paid)
+ }
+ if table.phase != baccaratWaiting || table.participants["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.
+ var snapshot *BaccaratSnapshot
+ for _, event := range events {
+ if event.Baccarat != nil {
+ snapshot = event.Baccarat
+ }
+ }
+ if snapshot == nil || len(snapshot.Player) != 2 || len(snapshot.Banker) != 2 ||
+ snapshot.PlayerScore != "9" || snapshot.BankerScore != "5" ||
+ len(snapshot.Bets) != 1 || snapshot.Bets[0].Spot != baccaratSpotPlayer || snapshot.Bets[0].Amount != 100 {
+ t.Fatalf("unexpected snapshot: %+v", snapshot)
+ }
+}
+
+func TestBaccaratWaitsForAllPlayersToBet(t *testing.T) {
+ table := newBaccaratTestTable(TableConfig{ID: "baccarat", Game: GameBaccarat, MaxPlayers: 2})
+ table.join("alice")
+ table.join("bob")
+ forceShoe(table,
+ card{rank: "5"}, card{rank: "4"}, // player 9
+ card{rank: "2"}, card{rank: "3"}, // banker 5
+ )
+ wa, wb := &testWallet{credits: 100}, &testWallet{credits: 100}
+ if events := table.bet("alice", baccaratSpotBanker, 10, wa); hasMessage(events, "New baccarat hand!") {
+ t.Fatal("deal started before all players bet")
+ }
+ events := table.bet("bob", baccaratSpotPlayer, 10, wb)
+ if !hasMessage(events, "New baccarat hand!") {
+ t.Fatalf("deal did not start after all players bet: %+v", events)
+ }
+ if wa.paid != 0 || wb.paid != 20 {
+ t.Fatalf("payouts alice=%d bob=%d, want 0/20", wa.paid, wb.paid)
+ }
+}
+
+func TestBaccaratBettingWindowTimesOut(t *testing.T) {
+ table := newBaccaratTestTable(TableConfig{ID: "baccarat", Game: GameBaccarat, MaxPlayers: 2, BettingTicks: 1})
+ table.join("alice")
+ table.join("bob")
+ forceShoe(table,
+ card{rank: "5"}, card{rank: "4"}, // player 9
+ card{rank: "2"}, card{rank: "3"}, // banker 5
+ )
+ w := &testWallet{credits: 100}
+ table.bet("alice", baccaratSpotPlayer, 10, w)
+ events := table.tick()
+ if !hasMessage(events, "bob sits out this baccarat round.") {
+ t.Fatalf("missing sit-out message: %+v", events)
+ }
+ if !hasMessage(events, "New baccarat hand!") {
+ t.Fatalf("betting timeout did not deal for the bettor: %+v", events)
+ }
+ if w.paid != 20 {
+ t.Fatalf("payout=%d, want 20", w.paid)
+ }
+}
+
+func TestBaccaratBettingIgnoresDisconnected(t *testing.T) {
+ table := newBaccaratTestTable(TableConfig{ID: "baccarat", Game: GameBaccarat, MaxPlayers: 2})
+ table.join("alice")
+ table.join("bob")
+ table.markDisconnected("bob")
+ forceShoe(table,
+ card{rank: "5"}, card{rank: "4"}, // player 9
+ card{rank: "2"}, card{rank: "3"}, // banker 5
+ )
+ 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)
+ }
+}
+
+func TestBaccaratTiePushesAndPaysJackpotColor(t *testing.T) {
+ table := newBaccaratTestTable(TableConfig{ID: "baccarat", Game: GameBaccarat, MaxPlayers: 2})
+ table.join("alice")
+ table.join("bob")
+ forceShoe(table,
+ card{rank: "5"}, card{rank: "2"}, // player 7
+ card{rank: "4"}, card{rank: "3"}, // banker 7
+ )
+ wa, wb := &testWallet{credits: 1000}, &testWallet{credits: 1000}
+ table.bet("alice", baccaratSpotPlayer, 100, wa)
+ events := table.bet("bob", baccaratSpotTie, 100, wb)
+ if !hasMessage(events, "Player and banker tie at 7.") {
+ t.Fatalf("missing tie outcome: %+v", events)
+ }
+ if wa.paid != 100 || wb.paid != 900 {
+ t.Fatalf("payouts alice=%d bob=%d, want 100 (push)/900 (8:1)", wa.paid, wb.paid)
+ }
+ for _, event := range events {
+ if event.PrivateTo == "bob" && event.PrivateMessage == "You win! +900 credits" && event.Color != "casino_jackpot_win" {
+ t.Fatalf("tie win color=%q, want casino_jackpot_win", event.Color)
+ }
+ }
+}
+
+func TestBaccaratBetValidation(t *testing.T) {
+ table := newBaccaratTestTable(TableConfig{ID: "baccarat", Game: GameBaccarat, MinBet: 5, MaxBet: 100})
+ table.join("alice")
+ w := &testWallet{credits: 1000}
+ if events := table.bet("alice", "", 10, w); !hasMessage(events, "Bet on what?") {
+ t.Fatalf("spotless first bet not guided: %+v", events)
+ }
+ if events := table.bet("alice", "red", 10, w); !hasMessage(events, "must be on player, banker, or tie") {
+ t.Fatalf("invalid spot accepted: %+v", events)
+ }
+ if events := table.bet("alice", baccaratSpotPlayer, 200, w); !hasMessage(events, "between 5 and 100") {
+ t.Fatalf("out-of-range bet accepted: %+v", events)
+ }
+ forceShoe(table,
+ card{rank: "5"}, card{rank: "4"}, // player 9
+ card{rank: "2"}, card{rank: "3"}, // banker 5
+ )
+ table.bet("alice", baccaratSpotPlayer, 10, w)
+ // After the round, the last spot is remembered: an empty spot reuses it.
+ if spot, amount := table.rebet("alice"); spot != baccaratSpotPlayer || amount != 10 {
+ t.Fatalf("rebet=(%q, %d), want (player, 10)", spot, amount)
+ }
+ forceShoe(table,
+ card{rank: "5"}, card{rank: "4"}, // player 9
+ card{rank: "2"}, card{rank: "3"}, // banker 5
+ )
+ if events := table.bet("alice", "", 20, w); !hasMessage(events, "You bet 20 credits on player.") {
+ t.Fatalf("empty spot did not reuse last spot: %+v", events)
+ }
+ if w.paid != 20+40 {
+ t.Fatalf("payout=%d, want 60 across both wins", w.paid)
+ }
+}
+
+func TestBaccaratDoubleBetRejected(t *testing.T) {
+ table := newBaccaratTestTable(TableConfig{ID: "baccarat", Game: GameBaccarat, MaxPlayers: 2})
+ table.join("alice")
+ table.join("bob")
+ w := &testWallet{credits: 1000}
+ table.bet("alice", baccaratSpotPlayer, 10, w)
+ if events := table.bet("alice", baccaratSpotBanker, 10, w); !hasMessage(events, "already bet") {
+ t.Fatalf("second bet in a round accepted: %+v", events)
+ }
+}
+
+func TestBaccaratLeaveAndTableFull(t *testing.T) {
+ table := newBaccaratTestTable(TableConfig{ID: "baccarat", Game: GameBaccarat, MaxPlayers: 1})
+ table.join("alice")
+ if events := table.join("bob"); !hasMessage(events, "full") {
+ t.Fatalf("full table admitted a second player: %+v", events)
+ }
+ w := &testWallet{credits: 100}
+ table.bet("alice", baccaratSpotPlayer, 10, w) // solo: deals immediately
+ if events := table.leave("alice"); !hasMessage(events, "You stand up.") || !hasMessage(events, "closes") {
+ t.Fatalf("leave events missing: %+v", events)
+ }
+
+ busy := newBaccaratTestTable(TableConfig{ID: "baccarat", Game: GameBaccarat, MaxPlayers: 2})
+ busy.join("alice")
+ busy.join("bob")
+ busy.bet("alice", baccaratSpotPlayer, 10, w) // opens the betting window
+ if events := busy.leave("alice"); !hasMessage(events, "middle of a baccarat round") {
+ t.Fatalf("leave during betting not blocked: %+v", events)
+ }
+}
+
+func TestBaccaratMenuAndActions(t *testing.T) {
+ table := newBaccaratTestTable(TableConfig{ID: "baccarat", Game: GameBaccarat})
+ events := table.join("alice")
+ var menu *MenuView
+ for _, event := range events {
+ if event.Menu != nil {
+ menu = event.Menu
+ }
+ }
+ if menu == nil || menu.Kind != MenuBaccaratBet {
+ t.Fatalf("join menu=%+v, want MenuBaccaratBet", menu)
+ }
+ if events := table.action("alice", "hit"); !hasMessage(events, "no player actions") {
+ t.Fatalf("baccarat action not rejected: %+v", events)
+ }
+}
+
+func TestBaccaratConfigDefaults(t *testing.T) {
+ rules := (BaccaratConfig{}).Normalize()
+ if rules.Decks != 8 || rules.ShuffleAt != 0.25 || rules.TiePayout != 8 || rules.BankerPayout != 0.95 {
+ t.Fatalf("unexpected defaults: %+v", rules)
+ }
+}
+
+func TestBaccaratShoeUsesConfiguredDecks(t *testing.T) {
+ table := newBaccaratTestTable(TableConfig{ID: "baccarat", Game: GameBaccarat, Baccarat: BaccaratConfig{Decks: 2}})
+ if table.shoe.decks != 2 || len(table.shoe.cards) != 104 {
+ t.Fatalf("shoe decks=%d cards=%d, want 2 decks/104 cards", table.shoe.decks, len(table.shoe.cards))
+ }
+ def := newBaccaratTestTable(TableConfig{ID: "baccarat", Game: GameBaccarat})
+ if def.shoe.decks != 8 || len(def.shoe.cards) != 416 {
+ t.Fatalf("default shoe decks=%d cards=%d, want 8 decks/416 cards", def.shoe.decks, len(def.shoe.cards))
+ }
+}
+
+func TestBaccaratTableRoutesThroughTable(t *testing.T) {
+ table := newTable(1, TableConfig{ID: "baccarat", Game: GameBaccarat}, newTestRand())
+ if _, isBaccarat := table.game.(*baccaratTable); !isBaccarat {
+ t.Fatalf("newTable dispatched %T, want *baccaratTable", table.game)
+ }
+ table.join("alice")
+ w := &testWallet{credits: 100}
+ if events := table.bet("alice", baccaratSpotPlayer, 10, w); !hasMessage(events, "New baccarat hand!") {
+ t.Fatalf("routed bet did not deal: %+v", events)
+ }
+}
diff --git a/internal/casino/blackjack.go b/internal/casino/blackjack.go
index a158a24..80a4d15 100644
--- a/internal/casino/blackjack.go
+++ b/internal/casino/blackjack.go
@@ -17,28 +17,8 @@ const (
blackjackPlaying blackjackPhase = "playing"
)
-type blackjackCard struct {
- rank string
- suit string
-}
-
-func (c blackjackCard) String() string { return c.rank + " of " + c.suit }
-
-func (c blackjackCard) value() int {
- switch c.rank {
- case "A":
- return 11
- case "K", "Q", "J":
- return 10
- default:
- var value int
- fmt.Sscanf(c.rank, "%d", &value)
- return value
- }
-}
-
type blackjackHand struct {
- cards []blackjackCard
+ cards []card
bet int
fromSplit bool
doubled bool
@@ -49,9 +29,9 @@ type blackjackHand struct {
func (h *blackjackHand) total() (int, bool) {
total, aces := 0, 0
- for _, card := range h.cards {
- total += card.value()
- if card.rank == "A" {
+ for _, c := range h.cards {
+ total += c.blackjackValue()
+ if c.rank == "A" {
aces++
}
}
@@ -76,45 +56,9 @@ type blackjackPlayer struct {
lastBet int
}
-type blackjackShoe struct {
- cards []blackjackCard
- rng *rand.Rand
- decks int
-}
-
-func newBlackjackShoe(decks int, rng *rand.Rand) *blackjackShoe {
- s := &blackjackShoe{rng: rng, decks: decks}
- s.shuffle()
- return s
-}
-
-func (s *blackjackShoe) shuffle() {
- ranks := []string{"A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K"}
- suits := []string{"clubs", "diamonds", "hearts", "spades"}
- s.cards = make([]blackjackCard, 0, s.decks*52)
- for deck := 0; deck < s.decks; deck++ {
- for _, suit := range suits {
- for _, rank := range ranks {
- s.cards = append(s.cards, blackjackCard{rank: rank, suit: suit})
- }
- }
- }
- s.rng.Shuffle(len(s.cards), func(i, j int) { s.cards[i], s.cards[j] = s.cards[j], s.cards[i] })
-}
-
-func (s *blackjackShoe) draw(rules BlackjackConfig) blackjackCard {
- if len(s.cards) <= int(float64(s.decks*52)*rules.ShuffleAt) {
- s.shuffle()
- }
- card := s.cards[len(s.cards)-1]
- s.cards = s.cards[:len(s.cards)-1]
- return card
-}
-
type blackjackTable struct {
- mu sync.Mutex
- RoomID int
- Config TableConfig
+ mu sync.Mutex
+ tableBase
Rules BlackjackConfig
participants map[string]*blackjackPlayer
order []string
@@ -123,16 +67,16 @@ type blackjackTable struct {
activePlayer int
activeHand int
dealer blackjackHand
- shoe *blackjackShoe
+ shoe *shoe
}
func newBlackjackTable(roomID int, cfg TableConfig, rng *rand.Rand) *blackjackTable {
cfg = cfg.Normalize()
rules := cfg.Blackjack.Normalize()
return &blackjackTable{
- RoomID: roomID, Config: cfg, Rules: rules,
+ tableBase: tableBase{RoomID: roomID, Config: cfg}, Rules: rules,
participants: make(map[string]*blackjackPlayer), phase: blackjackWaiting,
- shoe: newBlackjackShoe(rules.Decks, rng),
+ shoe: newShoe(rules.Decks, rules.ShuffleAt, rng),
}
}
@@ -146,23 +90,6 @@ func (t *blackjackTable) seats() []*Participant {
return seats
}
-func (t *blackjackTable) event(message string, public bool) Event {
- return Event{RoomID: t.RoomID, TableID: t.Config.ID, Type: EventResult, Message: message, Public: public, Color: "casino_action"}
-}
-
-func (t *blackjackTable) private(name, message string) Event {
- e := t.event(message, false)
- e.PrivateTo = name
- return e
-}
-
-func (t *blackjackTable) playerAction(name, publicMessage, privateMessage string) Event {
- e := t.event(publicMessage, true)
- e.PrivateTo = name
- e.PrivateMessage = privateMessage
- return e
-}
-
func (t *blackjackTable) menuEvent(name string) Event {
e := t.private(name, t.menu())
e.Color = "casino_menu"
@@ -218,13 +145,16 @@ func (t *blackjackTable) leave(name string) []Event {
return events
}
-func (t *blackjackTable) bet(name string, amount int, wallet Wallet) []Event {
+func (t *blackjackTable) 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{t.private(name, "You aren't playing blackjack.")}
}
+ if spot != "" {
+ return []Event{t.private(name, "Blackjack bets don't take a bet target.")}
+ }
if t.phase != blackjackWaiting && t.phase != blackjackBetting {
return []Event{t.private(name, "That blackjack table isn't accepting bets.")}
}
@@ -317,7 +247,7 @@ func (t *blackjackTable) action(name, action string) []Event {
if fromSplitAces(hand) {
return []Event{t.private(name, "Split aces cannot be hit.")}
}
- hand.cards = append(hand.cards, t.shoe.draw(t.Rules))
+ hand.cards = append(hand.cards, t.shoe.draw())
total, _ := hand.total()
if total > 21 {
hand.bust = true
@@ -344,7 +274,7 @@ func (t *blackjackTable) action(name, action string) []Event {
hand.bet *= 2
hand.doubled = true
hand.stood = true
- hand.cards = append(hand.cards, t.shoe.draw(t.Rules))
+ hand.cards = append(hand.cards, t.shoe.draw())
if total, _ := hand.total(); total > 21 {
hand.bust = true
}
@@ -430,10 +360,10 @@ func (t *blackjackTable) splitHand(name string, p *blackjackPlayer, hand *blackj
if _, ok := p.Wallet.Wager(hand.bet); !ok {
return []Event{t.private(name, "You don't have enough chips and credits to split that hand.")}
}
- left := &blackjackHand{cards: []blackjackCard{hand.cards[0]}, bet: hand.bet, fromSplit: true}
- right := &blackjackHand{cards: []blackjackCard{hand.cards[1]}, bet: hand.bet, fromSplit: true}
- left.cards = append(left.cards, t.shoe.draw(t.Rules))
- right.cards = append(right.cards, t.shoe.draw(t.Rules))
+ left := &blackjackHand{cards: []card{hand.cards[0]}, bet: hand.bet, fromSplit: true}
+ right := &blackjackHand{cards: []card{hand.cards[1]}, bet: hand.bet, fromSplit: true}
+ left.cards = append(left.cards, t.shoe.draw())
+ right.cards = append(right.cards, t.shoe.draw())
index := t.activeHand
p.hands = append(p.hands[:index], append([]*blackjackHand{left, right}, p.hands[index+1:]...)...)
e := t.playerAction(name, fmt.Sprintf("%s splits the hand.", name), "You split the hand.")
@@ -449,21 +379,21 @@ func (t *blackjackTable) splitHand(name string, p *blackjackPlayer, hand *blackj
return events
}
-func splitCompatible(a, b blackjackCard, unlikeTens bool) bool {
+func splitCompatible(a, b card, unlikeTens bool) bool {
if a.rank == b.rank {
return true
}
- return unlikeTens && a.value() == 10 && b.value() == 10
+ return unlikeTens && a.blackjackValue() == 10 && b.blackjackValue() == 10
}
func (t *blackjackTable) startRound() []Event {
- t.dealer = blackjackHand{cards: []blackjackCard{t.shoe.draw(t.Rules), t.shoe.draw(t.Rules)}}
+ 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 {
continue
}
- p.hands = []*blackjackHand{{cards: []blackjackCard{t.shoe.draw(t.Rules), t.shoe.draw(t.Rules)}, bet: p.Wager.Total}}
+ p.hands = []*blackjackHand{{cards: []card{t.shoe.draw(), t.shoe.draw()}, bet: p.Wager.Total}}
if p.hands[0].bet/2 <= 0 {
// A half-bet that rounds to zero cannot be insured.
p.declinedInsurance = true
@@ -595,7 +525,7 @@ func (t *blackjackTable) settle() []Event {
if total > 17 || total == 17 && (!soft || !t.Rules.DealerHitsSoft17()) {
break
}
- t.dealer.cards = append(t.dealer.cards, t.shoe.draw(t.Rules))
+ t.dealer.cards = append(t.dealer.cards, t.shoe.draw())
}
dealerTotal, _ := t.dealer.total()
dealerEvent := t.event("Dealer's hand:", true)
@@ -679,11 +609,11 @@ func (t *blackjackTable) resetRound() {
}
}
-func (t *blackjackTable) rebetAmount(name string) int {
+func (t *blackjackTable) rebet(name string) (string, int) {
if p := t.participants[name]; p != nil && p.lastBet > 0 {
- return p.lastBet
+ return "", p.lastBet
}
- return t.Config.MinBet
+ return "", t.Config.MinBet
}
func (t *blackjackTable) bettorCount() int {
@@ -700,10 +630,10 @@ func (t *blackjackTable) snapshot(revealDealer bool) *BlackjackSnapshot {
snapshot := &BlackjackSnapshot{}
for i, card := range t.dealer.cards {
if i == 0 && !revealDealer {
- snapshot.Dealer = append(snapshot.Dealer, BlackjackCardView{Rank: "?", Hidden: true})
+ snapshot.Dealer = append(snapshot.Dealer, CardView{Rank: "?", Hidden: true})
continue
}
- snapshot.Dealer = append(snapshot.Dealer, BlackjackCardView{Rank: card.rank, Suit: card.suit})
+ snapshot.Dealer = append(snapshot.Dealer, CardView{Rank: card.rank, Suit: card.suit})
}
for _, name := range t.order {
p := t.participants[name]
@@ -713,13 +643,13 @@ func (t *blackjackTable) snapshot(revealDealer bool) *BlackjackSnapshot {
for _, hand := range p.hands {
view := BlackjackPlayerView{Name: name, Score: handScore(hand)}
for _, card := range hand.cards {
- view.Cards = append(view.Cards, BlackjackCardView{Rank: card.rank, Suit: card.suit})
+ view.Cards = append(view.Cards, CardView{Rank: card.rank, Suit: card.suit})
}
snapshot.Players = append(snapshot.Players, view)
}
}
if len(t.dealer.cards) > 1 {
- snapshot.DealerShownScore = handScore(&blackjackHand{cards: []blackjackCard{t.dealer.cards[1]}})
+ snapshot.DealerShownScore = handScore(&blackjackHand{cards: []card{t.dealer.cards[1]}})
}
snapshot.DealerScore = handScore(&t.dealer)
snapshot.DealerRevealed = revealDealer
diff --git a/internal/casino/blackjack_test.go b/internal/casino/blackjack_test.go
index e8381fb..f1bef99 100644
--- a/internal/casino/blackjack_test.go
+++ b/internal/casino/blackjack_test.go
@@ -7,12 +7,12 @@ import (
)
func TestBlackjackHandTreatsAceAsElevenWhenPossible(t *testing.T) {
- hand := &blackjackHand{cards: []blackjackCard{{rank: "A"}, {rank: "6"}}}
+ hand := &blackjackHand{cards: []card{{rank: "A"}, {rank: "6"}}}
total, soft := hand.total()
if total != 17 || !soft {
t.Fatalf("total=%d soft=%v, want soft 17", total, soft)
}
- hand.cards = append(hand.cards, blackjackCard{rank: "K"})
+ hand.cards = append(hand.cards, card{rank: "K"})
total, soft = hand.total()
if total != 17 || soft {
t.Fatalf("total=%d soft=%v, want hard 17", total, soft)
@@ -21,18 +21,18 @@ func TestBlackjackHandTreatsAceAsElevenWhenPossible(t *testing.T) {
func TestBlackjackSettlementRules(t *testing.T) {
rules := (BlackjackConfig{}).Normalize()
- natural := &blackjackHand{bet: 10, cards: []blackjackCard{{rank: "A"}, {rank: "K"}}}
+ natural := &blackjackHand{bet: 10, cards: []card{{rank: "A"}, {rank: "K"}}}
if result, payout := settleHand(natural, 20, false, rules); result != "blackjack" || payout != 25 {
t.Fatalf("natural result=%s payout=%d", result, payout)
}
- dealerNatural := &blackjackHand{bet: 10, cards: []blackjackCard{{rank: "A"}, {rank: "Q"}}}
+ 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)
}
- if result, payout := settleHand(&blackjackHand{bet: 10, cards: []blackjackCard{{rank: "10"}, {rank: "8"}}}, 21, true, rules); result != "dealer blackjack" || payout != 0 {
+ if result, payout := settleHand(&blackjackHand{bet: 10, cards: []card{{rank: "10"}, {rank: "8"}}}, 21, true, rules); result != "dealer blackjack" || payout != 0 {
t.Fatalf("dealer blackjack result=%s payout=%d", result, payout)
}
- if result, payout := settleHand(&blackjackHand{bet: 10, cards: []blackjackCard{{rank: "10"}, {rank: "8"}}}, 22, false, rules); result != "win" || payout != 20 {
+ if result, payout := settleHand(&blackjackHand{bet: 10, cards: []card{{rank: "10"}, {rank: "8"}}}, 22, false, rules); result != "win" || payout != 20 {
t.Fatalf("dealer bust result=%s payout=%d", result, payout)
}
}
@@ -42,10 +42,10 @@ func TestBlackjackTableStartsAfterAllPlayersBet(t *testing.T) {
table.join("alice")
table.join("bob")
wa, wb := &testWallet{credits: 100}, &testWallet{credits: 100}
- if events := table.bet("alice", 10, wa); hasMessage(events, "round begins") {
+ if events := table.bet("alice", "", 10, wa); hasMessage(events, "round begins") {
t.Fatal("round started before all players bet")
}
- events := table.bet("bob", 10, wb)
+ events := table.bet("bob", "", 10, wb)
if !hasMessage(events, "New blackjack hand") {
t.Fatalf("round did not start after all players bet: %+v", events)
}
@@ -55,7 +55,7 @@ func TestSoloBlackjackTurnDoesNotTimeout(t *testing.T) {
table := newBlackjackTable(1, TableConfig{ID: "blackjack", Game: GameBlackjack, Blackjack: BlackjackConfig{ActionTicks: 1}}, newTestRand())
table.participants["alice"] = &blackjackPlayer{
Participant: Participant{Name: "alice", HasBet: true, Connected: true},
- hands: []*blackjackHand{{bet: 10, cards: []blackjackCard{{rank: "10"}, {rank: "6"}}}},
+ hands: []*blackjackHand{{bet: 10, cards: []card{{rank: "10"}, {rank: "6"}}}},
}
table.order = []string{"alice"}
table.phase = blackjackPlaying
@@ -70,18 +70,18 @@ func TestSoloBlackjackTurnDoesNotTimeout(t *testing.T) {
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}
- if got := table.rebetAmount("alice"); got != 25 {
- t.Fatalf("rebet=%d, want 25", got)
+ if spot, got := table.rebet("alice"); got != 25 || spot != "" {
+ t.Fatalf("rebet=(%q, %d), want (\"\", 25)", spot, got)
}
}
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: []blackjackCard{{rank: "K"}, {rank: "6"}}}}}
+ 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.activePlayer = 0
table.activeHand = 0
- table.dealer.cards = []blackjackCard{{rank: "10"}, {rank: "7"}}
+ table.dealer.cards = []card{{rank: "10"}, {rank: "7"}}
event := table.prompt()[0]
if event.Message != "Your turn (K,6).\nActions: hit, stand, double" {
t.Fatalf("prompt=%q", event.Message)
@@ -105,10 +105,10 @@ func TestBlackjackInsurancePaysOnDealerBlackjack(t *testing.T) {
w := &testWallet{credits: 100}
table.participants["alice"] = &blackjackPlayer{
Participant: Participant{Name: "alice", HasBet: true, Connected: true, Wallet: w, Wager: Wager{Total: 10}},
- hands: []*blackjackHand{{bet: 10, cards: []blackjackCard{{rank: "10"}, {rank: "8"}}}},
+ hands: []*blackjackHand{{bet: 10, cards: []card{{rank: "10"}, {rank: "8"}}}},
}
table.order = []string{"alice"}
- table.dealer.cards = []blackjackCard{{rank: "K"}, {rank: "A"}}
+ table.dealer.cards = []card{{rank: "K"}, {rank: "A"}}
table.phase = blackjackInsurance
events := table.action("alice", "insurance")
if w.credits != 95 {
@@ -130,10 +130,10 @@ func TestBlackjackInsuranceDeclineContinuesRound(t *testing.T) {
w := &testWallet{credits: 100}
table.participants["alice"] = &blackjackPlayer{
Participant: Participant{Name: "alice", HasBet: true, Connected: true, Wallet: w, Wager: Wager{Total: 10}},
- hands: []*blackjackHand{{bet: 10, cards: []blackjackCard{{rank: "10"}, {rank: "8"}}}},
+ hands: []*blackjackHand{{bet: 10, cards: []card{{rank: "10"}, {rank: "8"}}}},
}
table.order = []string{"alice"}
- table.dealer.cards = []blackjackCard{{rank: "9"}, {rank: "A"}}
+ table.dealer.cards = []card{{rank: "9"}, {rank: "A"}}
table.phase = blackjackInsurance
events := table.action("alice", "stand")
if !hasMessage(events, "You decline insurance.") {
@@ -150,14 +150,14 @@ func TestBlackjackInsuranceDeclineContinuesRound(t *testing.T) {
func TestBlackjackDoubleSetsBust(t *testing.T) {
table := newBlackjackTable(1, TableConfig{ID: "blackjack", Game: GameBlackjack}, newTestRand())
// decks 0 disables the shuffle threshold so the forced card is drawn.
- table.shoe = &blackjackShoe{cards: []blackjackCard{{rank: "K"}}}
+ table.shoe = &shoe{cards: []card{{rank: "K"}}}
w := &testWallet{credits: 100}
table.participants["alice"] = &blackjackPlayer{
Participant: Participant{Name: "alice", HasBet: true, Connected: true, Wallet: w, Wager: Wager{Total: 10}},
- hands: []*blackjackHand{{bet: 10, cards: []blackjackCard{{rank: "10"}, {rank: "6"}}}},
+ hands: []*blackjackHand{{bet: 10, cards: []card{{rank: "10"}, {rank: "6"}}}},
}
table.order = []string{"alice"}
- table.dealer.cards = []blackjackCard{{rank: "10"}, {rank: "7"}}
+ table.dealer.cards = []card{{rank: "10"}, {rank: "7"}}
table.phase = blackjackPlaying
hand := table.participants["alice"].hands[0]
events := table.action("alice", "double")
@@ -175,10 +175,10 @@ func TestBlackjackSplitAcesOnlyAllowResplit(t *testing.T) {
w := &testWallet{credits: 100}
table.participants["alice"] = &blackjackPlayer{
Participant: Participant{Name: "alice", HasBet: true, Connected: true, Wallet: w, Wager: Wager{Total: 10}},
- hands: []*blackjackHand{{bet: 10, fromSplit: true, cards: []blackjackCard{{rank: "A"}, {rank: "A"}}}},
+ hands: []*blackjackHand{{bet: 10, fromSplit: true, cards: []card{{rank: "A"}, {rank: "A"}}}},
}
table.order = []string{"alice"}
- table.dealer.cards = []blackjackCard{{rank: "10"}, {rank: "7"}}
+ table.dealer.cards = []card{{rank: "10"}, {rank: "7"}}
table.phase = blackjackPlaying
p := table.participants["alice"]
actions := table.availableActions(p, p.hands[0])
@@ -196,7 +196,7 @@ func TestBlackjackBettingIgnoresDisconnected(t *testing.T) {
table.join("bob")
table.markDisconnected("bob")
w := &testWallet{credits: 100}
- events := table.bet("alice", 10, w)
+ events := table.bet("alice", "", 10, w)
if !hasMessage(events, "New blackjack hand") {
t.Fatalf("disconnected player stalled the round: %+v", events)
}
@@ -206,10 +206,10 @@ 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: []blackjackCard{{rank: "10"}, {rank: "8"}}}},
+ hands: []*blackjackHand{{bet: 10, cards: []card{{rank: "10"}, {rank: "8"}}}},
}
table.order = []string{"alice"}
- table.dealer.cards = []blackjackCard{{rank: "10"}, {rank: "7"}}
+ table.dealer.cards = []card{{rank: "10"}, {rank: "7"}}
table.phase = blackjackPlaying
events := table.join("alice")
if len(events) != 1 || events[0].Blackjack == nil {
diff --git a/internal/casino/cards.go b/internal/casino/cards.go
new file mode 100644
index 0000000..0c32e06
--- /dev/null
+++ b/internal/casino/cards.go
@@ -0,0 +1,80 @@
+package casino
+
+import (
+ "fmt"
+ "math/rand"
+)
+
+// card is a single playing card shared by every card-based table game.
+type card struct {
+ rank string
+ suit string
+}
+
+func (c card) String() string { return c.rank + " of " + c.suit }
+
+// blackjackValue scores aces as 11 and faces as 10.
+func (c card) blackjackValue() int {
+ switch c.rank {
+ case "A":
+ return 11
+ case "K", "Q", "J":
+ return 10
+ default:
+ var value int
+ fmt.Sscanf(c.rank, "%d", &value)
+ return value
+ }
+}
+
+// baccaratValue scores aces as 1 and faces/tens as 0.
+func (c card) baccaratValue() int {
+ switch c.rank {
+ case "K", "Q", "J", "10":
+ return 0
+ case "A":
+ return 1
+ default:
+ var value int
+ fmt.Sscanf(c.rank, "%d", &value)
+ return value
+ }
+}
+
+// shoe is a shuffled multi-deck card shoe shared by card table games. It
+// reshuffles once the remaining cards fall to the shuffleAt fraction.
+type shoe struct {
+ cards []card
+ rng *rand.Rand
+ decks int
+ shuffleAt float64
+}
+
+func newShoe(decks int, shuffleAt float64, rng *rand.Rand) *shoe {
+ s := &shoe{rng: rng, decks: decks, shuffleAt: shuffleAt}
+ s.shuffle()
+ return s
+}
+
+func (s *shoe) shuffle() {
+ ranks := []string{"A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K"}
+ suits := []string{"clubs", "diamonds", "hearts", "spades"}
+ s.cards = make([]card, 0, s.decks*52)
+ for deck := 0; deck < s.decks; deck++ {
+ for _, suit := range suits {
+ for _, rank := range ranks {
+ s.cards = append(s.cards, card{rank: rank, suit: suit})
+ }
+ }
+ }
+ s.rng.Shuffle(len(s.cards), func(i, j int) { s.cards[i], s.cards[j] = s.cards[j], s.cards[i] })
+}
+
+func (s *shoe) draw() card {
+ if s.shuffleAt > 0 && len(s.cards) <= int(float64(s.decks*52)*s.shuffleAt) {
+ s.shuffle()
+ }
+ card := s.cards[len(s.cards)-1]
+ s.cards = s.cards[:len(s.cards)-1]
+ return card
+}
diff --git a/internal/casino/manager.go b/internal/casino/manager.go
index 46ad692..c876226 100644
--- a/internal/casino/manager.go
+++ b/internal/casino/manager.go
@@ -158,9 +158,9 @@ func (m *Manager) Leave(roomID int, tableID, name string) []Event {
return nil
}
-func (m *Manager) Bet(roomID int, tableID, name string, amount int, wallet Wallet) []Event {
+func (m *Manager) Bet(roomID int, tableID, name, spot string, amount int, wallet Wallet) []Event {
if table := m.Table(roomID, tableID); table != nil {
- return table.bet(name, amount, wallet)
+ return table.bet(name, spot, amount, wallet)
}
return []Event{{RoomID: roomID, TableID: tableID, Type: EventResult, Player: name, PrivateTo: name, Message: "That game is not available here."}}
}
@@ -172,11 +172,11 @@ func (m *Manager) Action(roomID int, tableID, name, action string) []Event {
return []Event{{RoomID: roomID, TableID: tableID, Type: EventResult, PrivateTo: name, Message: "That game is not available here."}}
}
-func (m *Manager) RebetAmount(roomID int, tableID, name string) int {
+func (m *Manager) Rebet(roomID int, tableID, name string) (string, int) {
if table := m.Table(roomID, tableID); table != nil {
- return table.rebetAmount(name)
+ return table.rebet(name)
}
- return 0
+ return "", 0
}
func (m *Manager) MarkDisconnected(name string) []Event {
diff --git a/internal/casino/manager_test.go b/internal/casino/manager_test.go
index 8d6dd3d..f0809be 100644
--- a/internal/casino/manager_test.go
+++ b/internal/casino/manager_test.go
@@ -37,7 +37,7 @@ func TestSoloBetResolvesImmediately(t *testing.T) {
cfg := TableConfig{ID: "slots", Game: GameSlots}
m.Join(1, cfg, "alice")
w := &testWallet{credits: 100}
- events := m.Bet(1, "slots", "alice", 10, w)
+ events := m.Bet(1, "slots", "alice", "", 10, w)
found := false
for _, event := range events {
if event.Type == EventRoundStarted {
@@ -55,13 +55,13 @@ func TestMultiplePlayersWaitForBets(t *testing.T) {
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)
+ 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)
+ m.Bet(1, "slots", "bob", "", 10, wb)
}
func TestTableBettingIgnoresDisconnected(t *testing.T) {
@@ -71,7 +71,7 @@ func TestTableBettingIgnoresDisconnected(t *testing.T) {
m.Join(1, cfg, "bob")
m.MarkDisconnected("bob")
w := &testWallet{credits: 100}
- events := m.Bet(1, "slots", "alice", 10, w)
+ events := m.Bet(1, "slots", "alice", "", 10, w)
found := false
for _, event := range events {
if event.Type == EventRoundStarted {
diff --git a/internal/casino/table.go b/internal/casino/table.go
index e993cb8..be2ea18 100644
--- a/internal/casino/table.go
+++ b/internal/casino/table.go
@@ -6,6 +6,81 @@ import (
"sync"
)
+// tableGame is a multiplayer table game implementation. Table routes every
+// player interaction through this interface so new games plug in without
+// changing the dispatch layer.
+type tableGame interface {
+ join(name string) []Event
+ leave(name string) []Event
+ bet(name, spot string, amount int, wallet Wallet) []Event
+ tick() []Event
+ action(name, action string) []Event
+ rebet(name string) (spot string, amount int)
+ markDisconnected(name string) []Event
+ markConnected(name string)
+ hasParticipant(name string) bool
+}
+
+// tableBase carries the identity every table game needs to build events.
+type tableBase struct {
+ RoomID int
+ Config TableConfig
+}
+
+func (b tableBase) event(message string, public bool) Event {
+ return Event{RoomID: b.RoomID, TableID: b.Config.ID, Type: EventResult, Message: message, Public: public, Color: "casino_action"}
+}
+
+func (b tableBase) private(name, message string) Event {
+ e := b.event(message, false)
+ e.PrivateTo = name
+ return e
+}
+
+func (b tableBase) playerAction(name, publicMessage, privateMessage string) Event {
+ e := b.event(publicMessage, true)
+ e.PrivateTo = name
+ e.PrivateMessage = privateMessage
+ return e
+}
+
+// Table is the manager-facing handle for a multiplayer table. It only holds
+// routing state; all game logic lives in the tableGame implementation.
+type Table struct {
+ RoomID int
+ Config TableConfig
+ game tableGame
+}
+
+func newTable(roomID int, cfg TableConfig, rng *rand.Rand) *Table {
+ cfg = cfg.Normalize()
+ t := &Table{RoomID: roomID, Config: cfg}
+ switch cfg.Game {
+ case GameBlackjack:
+ t.game = newBlackjackTable(roomID, cfg, rng)
+ case GameBaccarat:
+ t.game = newBaccaratTable(roomID, cfg, rng)
+ default:
+ t.game = newGenericTable(roomID, cfg, rng)
+ }
+ return t
+}
+
+func (t *Table) join(name string) []Event { return t.game.join(name) }
+func (t *Table) leave(name string) []Event { return t.game.leave(name) }
+
+func (t *Table) bet(name, spot string, amount int, wallet Wallet) []Event {
+ return t.game.bet(name, spot, amount, wallet)
+}
+
+func (t *Table) tick() []Event { return t.game.tick() }
+func (t *Table) action(name, action string) []Event { return t.game.action(name, action) }
+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 (
@@ -13,7 +88,9 @@ const (
phaseBetting tablePhase = "betting"
)
-type Table struct {
+// 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
@@ -23,24 +100,19 @@ type Table struct {
phase tablePhase
timer countdown
rng *rand.Rand
- blackjack *blackjackTable
}
-func newTable(roomID int, cfg TableConfig, rng *rand.Rand) *Table {
- t := &Table{
+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,
}
- if cfg.Game == GameBlackjack {
- t.blackjack = newBlackjackTable(roomID, cfg, rng)
- }
- return t
}
-func (t *Table) seats() []*Participant {
+func (t *genericTable) seats() []*Participant {
seats := make([]*Participant, 0, len(t.order))
for _, name := range t.order {
if p := t.participants[name]; p != nil {
@@ -50,10 +122,7 @@ func (t *Table) seats() []*Participant {
return seats
}
-func (t *Table) join(name string) []Event {
- if t.blackjack != nil {
- return t.blackjack.join(name)
- }
+func (t *genericTable) join(name string) []Event {
t.mu.Lock()
defer t.mu.Unlock()
if p := t.participants[name]; p != nil {
@@ -68,10 +137,7 @@ func (t *Table) join(name string) []Event {
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 *Table) leave(name string) []Event {
- if t.blackjack != nil {
- return t.blackjack.leave(name)
- }
+func (t *genericTable) leave(name string) []Event {
t.mu.Lock()
defer t.mu.Unlock()
p := t.participants[name]
@@ -95,16 +161,16 @@ func (t *Table) leave(name string) []Event {
return events
}
-func (t *Table) bet(name string, amount int, wallet Wallet) []Event {
- if t.blackjack != nil {
- return t.blackjack.bet(name, amount, wallet)
- }
+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."}}
}
@@ -135,10 +201,7 @@ func (t *Table) bet(name string, amount int, wallet Wallet) []Event {
return events
}
-func (t *Table) tick() []Event {
- if t.blackjack != nil {
- return t.blackjack.tick()
- }
+func (t *genericTable) tick() []Event {
t.mu.Lock()
defer t.mu.Unlock()
if t.phase != phaseBetting {
@@ -163,7 +226,7 @@ func (t *Table) tick() []Event {
return events
}
-func (t *Table) resolveRound() []Event {
+func (t *genericTable) resolveRound() []Event {
var events []Event
for _, name := range t.order {
p := t.participants[name]
@@ -184,16 +247,13 @@ func (t *Table) resolveRound() []Event {
return events
}
-func (t *Table) resetRound() {
+func (t *genericTable) resetRound() {
t.phase = phaseWaiting
t.timer.stop()
clearBets(t.seats())
}
-func (t *Table) markDisconnected(name string) []Event {
- if t.blackjack != nil {
- return t.blackjack.markDisconnected(name)
- }
+func (t *genericTable) markDisconnected(name string) []Event {
t.mu.Lock()
defer t.mu.Unlock()
if p := t.participants[name]; p != nil {
@@ -202,11 +262,7 @@ func (t *Table) markDisconnected(name string) []Event {
return nil
}
-func (t *Table) markConnected(name string) {
- if t.blackjack != nil {
- t.blackjack.markConnected(name)
- return
- }
+func (t *genericTable) markConnected(name string) {
t.mu.Lock()
defer t.mu.Unlock()
if p := t.participants[name]; p != nil {
@@ -214,25 +270,16 @@ func (t *Table) markConnected(name string) {
}
}
-func (t *Table) hasParticipant(name string) bool {
- if t.blackjack != nil {
- return t.blackjack.hasParticipant(name)
- }
+func (t *genericTable) hasParticipant(name string) bool {
t.mu.Lock()
defer t.mu.Unlock()
return t.participants[name] != nil
}
-func (t *Table) action(name, action string) []Event {
- if t.blackjack == nil {
- return []Event{{RoomID: t.RoomID, TableID: t.Config.ID, Type: EventResult, PrivateTo: name, Message: "That game has no player actions."}}
- }
- return t.blackjack.action(name, action)
+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 *Table) rebetAmount(name string) int {
- if t.blackjack != nil {
- return t.blackjack.rebetAmount(name)
- }
- return t.Config.MinBet
+func (t *genericTable) rebet(name string) (string, int) {
+ return "", t.Config.MinBet
}
diff --git a/internal/casino/types.go b/internal/casino/types.go
index 1c4326a..c28b471 100644
--- a/internal/casino/types.go
+++ b/internal/casino/types.go
@@ -5,6 +5,7 @@ type GameName string
const (
GameSlots GameName = "slots"
GameBlackjack GameName = "blackjack"
+ GameBaccarat GameName = "baccarat"
)
type BlackjackConfig struct {
@@ -60,6 +61,32 @@ func (c BlackjackConfig) Normalize() BlackjackConfig {
return c
}
+// BaccaratConfig holds punto banco house rules: shoe composition and payout
+// multipliers. Banker wins pay BankerPayout:1 (a 5% commission game is 0.95);
+// tie wins pay TiePayout:1.
+type BaccaratConfig struct {
+ Decks int `yaml:"decks,omitempty"`
+ ShuffleAt float64 `yaml:"shuffle_at,omitempty"`
+ TiePayout float64 `yaml:"tie_payout,omitempty"`
+ BankerPayout float64 `yaml:"banker_payout,omitempty"`
+}
+
+func (c BaccaratConfig) Normalize() BaccaratConfig {
+ if c.Decks <= 0 {
+ c.Decks = 8
+ }
+ if c.ShuffleAt <= 0 || c.ShuffleAt >= 1 {
+ c.ShuffleAt = 0.25
+ }
+ if c.TiePayout <= 0 {
+ c.TiePayout = 8
+ }
+ if c.BankerPayout <= 0 {
+ c.BankerPayout = 0.95
+ }
+ return c
+}
+
type TableConfig struct {
ID string `yaml:"id"`
Game GameName `yaml:"game"`
@@ -68,6 +95,7 @@ type TableConfig struct {
MinBet int `yaml:"min_bet,omitempty"`
MaxBet int `yaml:"max_bet,omitempty"`
Blackjack BlackjackConfig `yaml:"blackjack,omitempty"`
+ Baccarat BaccaratConfig `yaml:"baccarat,omitempty"`
}
type MachineConfig struct {
@@ -146,6 +174,7 @@ const (
MenuSlotCommands MenuKind = "slot_commands"
MenuBlackjackBet MenuKind = "blackjack_bet"
MenuBlackjackTurn MenuKind = "blackjack_turn"
+ MenuBaccaratBet MenuKind = "baccarat_bet"
)
// MenuView carries a structured menu alongside its plain-text Message so the
@@ -170,6 +199,7 @@ type Event struct {
Public bool
Slot *SlotSnapshot
Blackjack *BlackjackSnapshot
+ Baccarat *BaccaratSnapshot
Menu *MenuView
}
@@ -178,7 +208,8 @@ type SlotSnapshot struct {
StoppedReels int
}
-type BlackjackCardView struct {
+// CardView renders one card for any card-based table game.
+type CardView struct {
Rank string
Suit string
Hidden bool
@@ -186,18 +217,33 @@ type BlackjackCardView struct {
type BlackjackPlayerView struct {
Name string
- Cards []BlackjackCardView
+ Cards []CardView
Score string
}
type BlackjackSnapshot struct {
- Dealer []BlackjackCardView
+ Dealer []CardView
DealerShownScore string
DealerScore string
DealerRevealed bool
Players []BlackjackPlayerView
}
+// BaccaratBetView lists one participant's wager on a baccarat outcome.
+type BaccaratBetView struct {
+ Name string
+ Spot string
+ Amount int
+}
+
+type BaccaratSnapshot struct {
+ Player []CardView
+ Banker []CardView
+ PlayerScore string
+ BankerScore string
+ Bets []BaccaratBetView
+}
+
type Participant struct {
Name string
Connected bool
diff --git a/internal/game/casino.go b/internal/game/casino.go
index 801c887..9666c41 100644
--- a/internal/game/casino.go
+++ b/internal/game/casino.go
@@ -137,44 +137,61 @@ func (g *Game) executeBet(sess *net.Session, args []string, rawInput string) {
sess.WriteLine("Use bet, bet <amount>, or bet max.")
return
}
- if len(args) == 0 {
- if table, playing := g.Casino.Participation(p.Name); playing && table != nil && table.Config.Game == casino.GameBlackjack {
- amount := g.Casino.RebetAmount(table.RoomID, table.Config.ID, p.Name)
- g.emitCasinoEvents(g.Casino.Bet(table.RoomID, table.Config.ID, p.Name, amount, casinoWallet{g: g, p: p}))
- return
- }
- sess.WriteLine("Bet how much?")
+ table, playing := g.Casino.Participation(p.Name)
+ if !playing || table == nil {
+ sess.WriteLine("You're not playing a game.")
return
}
- if table, playing := g.Casino.Participation(p.Name); playing && table != nil && table.Config.Game == casino.GameBlackjack {
- amount := 0
- switch args[0] {
- case "min":
- amount = table.Config.MinBet
- case "max":
- amount = table.Config.MaxBet
- default:
- var err error
- amount, err = strconv.Atoi(args[0])
- if err != nil {
- sess.WriteLine("Use bet, bet <amount>, bet min, or bet max.")
+ // Table bet syntax: bet | bet <amount> [on] [spot] | bet min|max [on] [spot].
+ // Tokens that parse as an amount (or min/max) set the wager; any other
+ // token is the bet target and the table game validates it.
+ amount, spot, hasAmount := 0, "", false
+ for _, arg := range args {
+ if strings.EqualFold(arg, "on") {
+ continue
+ }
+ if strings.EqualFold(arg, "min") {
+ if hasAmount {
+ sess.WriteLine("Use bet, bet <amount> [on <target>], bet min, or bet max.")
return
}
+ amount, hasAmount = table.Config.MinBet, true
+ continue
}
- g.emitCasinoEvents(g.Casino.Bet(table.RoomID, table.Config.ID, p.Name, amount, casinoWallet{g: g, p: p}))
- return
- }
- amount, err := strconv.Atoi(args[0])
- if err != nil || amount <= 0 {
- sess.WriteLine("Your bet must be a positive whole number.")
- return
+ if strings.EqualFold(arg, "max") {
+ if hasAmount {
+ sess.WriteLine("Use bet, bet <amount> [on <target>], bet min, or bet max.")
+ return
+ }
+ amount, hasAmount = table.Config.MaxBet, true
+ continue
+ }
+ if value, err := strconv.Atoi(arg); err == nil && value > 0 {
+ if hasAmount {
+ sess.WriteLine("Use bet, bet <amount> [on <target>], bet min, or bet max.")
+ return
+ }
+ amount, hasAmount = value, true
+ continue
+ }
+ if spot != "" {
+ sess.WriteLine("Use bet, bet <amount> [on <target>], bet min, or bet max.")
+ return
+ }
+ spot = strings.ToLower(arg)
}
- table, playing := g.Casino.Participation(p.Name)
- if !playing {
- sess.WriteLine("You're not playing a game.")
- return
+ if !hasAmount {
+ var lastSpot string
+ lastSpot, amount = g.Casino.Rebet(table.RoomID, table.Config.ID, p.Name)
+ if spot == "" {
+ spot = lastSpot
+ }
+ if amount <= 0 {
+ sess.WriteLine("Bet how much?")
+ return
+ }
}
- g.emitCasinoEvents(g.Casino.Bet(table.RoomID, table.Config.ID, p.Name, amount, casinoWallet{g: g, p: p}))
+ g.emitCasinoEvents(g.Casino.Bet(table.RoomID, table.Config.ID, p.Name, spot, amount, casinoWallet{g: g, p: p}))
}
func (g *Game) executeAutoBet(sess *net.Session, args []string, rawInput string) {
@@ -189,7 +206,7 @@ func (g *Game) executeAutoBet(sess *net.Session, args []string, rawInput string)
sess.WriteLine("Autobet is only available at games without player choices.")
}
-func (g *Game) executeBlackjackAction(sess *net.Session, action string) {
+func (g *Game) executeTableAction(sess *net.Session, action string) {
p := sess.Player
if p == nil {
return
@@ -229,6 +246,9 @@ func (g *Game) emitCasinoEvents(events []casino.Event) {
if event.Blackjack != nil && sess.Player != nil {
message += "\n" + renderBlackjackSnapshot(event.Blackjack, g.colorMode(sess), sess.Player.OptionBool("unicode"), sess.Player.OptionInt("card_size"), sess.Player.OptionString("card_style"), g.CardArt)
}
+ if event.Baccarat != nil && sess.Player != nil {
+ message += "\n" + renderBaccaratSnapshot(event.Baccarat, g.colorMode(sess), sess.Player.OptionBool("unicode"), sess.Player.OptionInt("card_size"), sess.Player.OptionString("card_style"), g.CardArt)
+ }
sess.WriteLine(g.colorize(sess, category, message))
}
}
@@ -252,6 +272,9 @@ func (g *Game) emitCasinoEvents(events []casino.Event) {
if event.Blackjack != nil && sess.Player != nil {
privateMessage += "\n" + renderBlackjackSnapshot(event.Blackjack, g.colorMode(sess), sess.Player.OptionBool("unicode"), sess.Player.OptionInt("card_size"), sess.Player.OptionString("card_style"), g.CardArt)
}
+ if event.Baccarat != nil && sess.Player != nil {
+ privateMessage += "\n" + renderBaccaratSnapshot(event.Baccarat, g.colorMode(sess), sess.Player.OptionBool("unicode"), sess.Player.OptionInt("card_size"), sess.Player.OptionString("card_style"), g.CardArt)
+ }
sess.WriteLine(g.renderCasinoMessage(sess, category, event.Menu, privateMessage))
if category == "casino_menu" {
g.writePrompt(sess)
@@ -293,6 +316,10 @@ func (g *Game) casinoPrivateMessage(sess *net.Session, category string, menu *ca
commands[i] = g.colorize(sess, "casino_command", action)
}
return menu.Title + "\nActions: " + strings.Join(commands, ", ")
+ case casino.MenuBaccaratBet:
+ command := func(value string) string { return g.colorize(sess, "casino_command", value) }
+ return fmt.Sprintf("%s\n\nNew hand! You can %s, %s on player|banker|tie (inc. min/max), or %s.",
+ balance, command("bet"), command("bet <amount>"), command("stop"))
case casino.MenuSlotCommands:
if message == "" {
return balance + "\n\n" + menu.Body
@@ -306,7 +333,7 @@ func renderBlackjackSnapshot(snapshot *casino.BlackjackSnapshot, mode string, un
if snapshot == nil {
return ""
}
- size = blackjackCardSize(unicode, size)
+ size = cardRenderSize(unicode, size)
var lines []string
dealerTitle := "Dealer:"
if snapshot.DealerRevealed {
@@ -314,22 +341,40 @@ func renderBlackjackSnapshot(snapshot *casino.BlackjackSnapshot, mode string, un
} else if snapshot.DealerShownScore != "" {
dealerTitle = "Dealer (" + snapshot.DealerShownScore + "):"
}
- lines = append(lines, blackjackCardLine(mode, dealerTitle))
- lines = append(lines, blackjackCardsLine(mode, snapshot.Dealer, size, unicode, style, art)...)
+ lines = append(lines, cardLine(mode, dealerTitle))
+ lines = append(lines, cardsLine(mode, snapshot.Dealer, size, unicode, style, art)...)
for _, player := range snapshot.Players {
- lines = append(lines, blackjackCardLine(mode, fmt.Sprintf("%s (%s):", player.Name, player.Score)))
- lines = append(lines, blackjackCardsLine(mode, player.Cards, size, unicode, style, art)...)
+ lines = append(lines, cardLine(mode, fmt.Sprintf("%s (%s):", player.Name, player.Score)))
+ lines = append(lines, cardsLine(mode, player.Cards, size, unicode, style, art)...)
+ }
+ return strings.Join(lines, "\n")
+}
+
+// renderBaccaratSnapshot draws the player and banker hands followed by the
+// list of wagers riding on the round.
+func renderBaccaratSnapshot(snapshot *casino.BaccaratSnapshot, mode string, unicode bool, size int, style string, art *cardart.Store) string {
+ if snapshot == nil {
+ return ""
+ }
+ size = cardRenderSize(unicode, size)
+ var lines []string
+ lines = append(lines, cardLine(mode, "Player ("+snapshot.PlayerScore+"):"))
+ lines = append(lines, cardsLine(mode, snapshot.Player, size, unicode, style, art)...)
+ lines = append(lines, cardLine(mode, "Banker ("+snapshot.BankerScore+"):"))
+ lines = append(lines, cardsLine(mode, snapshot.Banker, size, unicode, style, art)...)
+ for _, bet := range snapshot.Bets {
+ lines = append(lines, cardLine(mode, fmt.Sprintf("%s bets %d on %s.", bet.Name, bet.Amount, bet.Spot)))
}
return strings.Join(lines, "\n")
}
-func blackjackCardLine(mode, text string) string {
+func cardLine(mode, text string) string {
spec := color.NoColor()
spec.Fg = 39
return color.Render(mode, spec, text)
}
-func blackjackCardsLine(mode string, cards []casino.BlackjackCardView, size int, unicode bool, style string, art *cardart.Store) []string {
+func cardsLine(mode string, cards []casino.CardView, size int, unicode bool, style string, art *cardart.Store) []string {
if len(cards) == 0 {
return nil
}
@@ -342,7 +387,7 @@ func blackjackCardsLine(mode string, cards []casino.BlackjackCardView, size int,
}
artLines := make([][]string, len(cards))
for i, card := range cards {
- artLines[i] = blackjackCardArt(card, size, unicode, style, art)
+ artLines[i] = cardArtLines(card, size, unicode, style, art)
}
lines := make([]string, size)
for row := 0; row < size; row++ {
@@ -355,7 +400,7 @@ func blackjackCardsLine(mode string, cards []casino.BlackjackCardView, size int,
return lines
}
-func blackjackCardSize(unicode bool, size int) int {
+func cardRenderSize(unicode bool, size int) int {
if !unicode {
return 5
}
@@ -365,16 +410,16 @@ func blackjackCardSize(unicode bool, size int) int {
return 7
}
-var blackjackSuitSymbols = map[string]string{"hearts": "♥", "spades": "♠", "diamonds": "♦", "clubs": "♣"}
+var suitSymbols = map[string]string{"hearts": "♥", "spades": "♠", "diamonds": "♦", "clubs": "♣"}
-func blackjackSymbol(card casino.BlackjackCardView) string {
+func cardSymbol(card casino.CardView) string {
if card.Hidden {
return "?"
}
- return card.Rank + blackjackSuitSymbols[card.Suit]
+ return card.Rank + suitSymbols[card.Suit]
}
-func blackjackCardArt(card casino.BlackjackCardView, size int, unicode bool, style string, art *cardart.Store) []string {
+func cardArtLines(card casino.CardView, size int, unicode bool, style string, art *cardart.Store) []string {
if card.Hidden {
return generatedCardArt("?", "", size, unicode, style)
}
@@ -442,10 +487,10 @@ func suitLetter(suit string, unicode bool) string {
if !unicode {
return strings.ToUpper(suit[:1])
}
- return blackjackSuitSymbols[suit]
+ return suitSymbols[suit]
}
-func colorizeCardLine(mode, line string, card casino.BlackjackCardView, row, size int, style string) string {
+func colorizeCardLine(mode, line string, card casino.CardView, row, size int, style string) string {
red := card.Suit == "hearts" || card.Suit == "diamonds"
rankColor := 27
if red {
@@ -487,7 +532,7 @@ func colorizeCardLine(mode, line string, card casino.BlackjackCardView, row, siz
return builder.String()
}
-func colorizeCardSymbol(mode string, card casino.BlackjackCardView) string {
+func colorizeCardSymbol(mode string, card casino.CardView) string {
if card.Hidden {
return color.Render(mode, color.ColorSpec{Fg: 15}, "?")
}
@@ -495,7 +540,7 @@ func colorizeCardSymbol(mode string, card casino.BlackjackCardView) string {
if card.Suit == "hearts" || card.Suit == "diamonds" {
rankColor, suitColor = 196, 196
}
- runes := []rune(blackjackSymbol(card))
+ runes := []rune(cardSymbol(card))
rankLength := len([]rune(card.Rank))
return color.Render(mode, color.ColorSpec{Fg: rankColor}, string(runes[:rankLength])) +
color.Render(mode, color.ColorSpec{Fg: suitColor}, string(runes[rankLength:]))
diff --git a/internal/game/casino_test.go b/internal/game/casino_test.go
index b74baab..d7b5ae9 100644
--- a/internal/game/casino_test.go
+++ b/internal/game/casino_test.go
@@ -45,6 +45,66 @@ func TestExecuteBetRejectsInvalidMachineArgs(t *testing.T) {
}
}
+func TestExecuteBetParsesBaccaratSpots(t *testing.T) {
+ g := &Game{Casino: casino.NewManager(1)}
+ cfg := casino.TableConfig{ID: "baccarat", Game: casino.GameBaccarat}
+ g.Casino.Join(1, cfg, "alice")
+ conn := &casinoTestConn{}
+ sess := &net.Session{Player: player.New("alice"), Conn: conn}
+ g.loggedInChars = map[string]*net.Session{"alice": sess}
+ // Valid spot syntaxes reach the table and fail only at the (empty) wallet.
+ for _, args := range [][]string{{"tie", "100"}, {"100", "tie"}, {"100", "on", "banker"}, {"banker", "100"}} {
+ conn.data = nil
+ g.executeBet(sess, args, "bet "+strings.Join(args, " "))
+ if !strings.Contains(string(conn.data), "You don't have enough chips and credits") {
+ t.Fatalf("bet %v: expected wallet failure, got %q", args, string(conn.data))
+ }
+ }
+ // Invalid spots are rejected by the table.
+ conn.data = nil
+ g.executeBet(sess, []string{"red", "100"}, "bet red 100")
+ if !strings.Contains(string(conn.data), "must be on player, banker, or tie") {
+ t.Fatalf("bet red 100: expected spot rejection, got %q", string(conn.data))
+ }
+ // Two amounts or two spots are usage errors.
+ for _, args := range [][]string{{"10", "20"}, {"player", "banker"}} {
+ conn.data = nil
+ g.executeBet(sess, args, "bet "+strings.Join(args, " "))
+ if !strings.Contains(string(conn.data), "Use bet,") {
+ t.Fatalf("bet %v: expected usage error, got %q", args, string(conn.data))
+ }
+ }
+ // A bare first bet asks for a target (via the rebet path).
+ conn.data = nil
+ g.executeBet(sess, nil, "bet")
+ if !strings.Contains(string(conn.data), "Bet on what?") {
+ t.Fatalf("bare bet: expected target guidance, got %q", string(conn.data))
+ }
+}
+
+func TestRenderBaccaratSnapshot(t *testing.T) {
+ snapshot := &casino.BaccaratSnapshot{
+ Player: []casino.CardView{{Rank: "5", Suit: "clubs"}, {Rank: "4", Suit: "hearts"}},
+ Banker: []casino.CardView{{Rank: "2", Suit: "spades"}, {Rank: "3", Suit: "diamonds"}},
+ PlayerScore: "9",
+ BankerScore: "5",
+ Bets: []casino.BaccaratBetView{{Name: "alice", Spot: "player", Amount: 100}},
+ }
+ plain := renderBaccaratSnapshot(snapshot, "none", false, 5, "dark", nil)
+ if !strings.Contains(plain, "Player (9):") || !strings.Contains(plain, "Banker (5):") || !strings.Contains(plain, "alice bets 100 on player.") {
+ t.Fatalf("unexpected baccarat render: %q", plain)
+ }
+ output := renderBaccaratSnapshot(snapshot, "xterm256", true, 7, "dark", nil)
+ if !strings.Contains(output, "\033[38;5;") {
+ t.Fatalf("expected colored baccarat art: %q", output)
+ }
+ for _, line := range strings.Split(output, "\n") {
+ if color.VisibleLen(line) > 80 {
+ t.Fatalf("baccarat line exceeds console width: %d", color.VisibleLen(line))
+ }
+ }
+}
+
func TestRenderSlotSnapshotColorsWordSymbols(t *testing.T) {
snapshot := &casino.SlotSnapshot{StoppedReels: 1}
snapshot.Grid[0][0] = "scatter"
@@ -67,8 +127,8 @@ func TestRenderSlotSnapshotColorsWordSymbols(t *testing.T) {
func TestRenderBlackjackSnapshotUsesCompactASCIIArt(t *testing.T) {
snapshot := &casino.BlackjackSnapshot{
- Dealer: []casino.BlackjackCardView{{Rank: "?", Hidden: true}, {Rank: "K", Suit: "hearts"}},
- Players: []casino.BlackjackPlayerView{{Name: "alice", Score: "20", Cards: []casino.BlackjackCardView{{Rank: "5", Suit: "clubs"}, {Rank: "Q", Suit: "spades"}}}},
+ Dealer: []casino.CardView{{Rank: "?", Hidden: true}, {Rank: "K", Suit: "hearts"}},
+ Players: []casino.BlackjackPlayerView{{Name: "alice", Score: "20", Cards: []casino.CardView{{Rank: "5", Suit: "clubs"}, {Rank: "Q", Suit: "spades"}}}},
}
plain := renderBlackjackSnapshot(snapshot, "none", false, 5, "dark", nil)
if !strings.Contains(plain, "+---+") || !strings.Contains(plain, "Dealer:") || !strings.Contains(plain, "alice (20):") {
diff --git a/internal/game/cmd_registry.go b/internal/game/cmd_registry.go
index db6cd96..0fee89e 100644
--- a/internal/game/cmd_registry.go
+++ b/internal/game/cmd_registry.go
@@ -28,12 +28,12 @@ var commandRegistry = map[string]commandDef{
"spin": {(*Game).executeBet, ClassActive},
"autobet": {(*Game).executeAutoBet, ClassActive},
"autospin": {(*Game).executeAutoBet, ClassActive},
- "hit": {func(g *Game, s *net.Session, _ []string, _ string) { g.executeBlackjackAction(s, "hit") }, ClassActive},
- "stand": {func(g *Game, s *net.Session, _ []string, _ string) { g.executeBlackjackAction(s, "stand") }, ClassActive},
- "double": {func(g *Game, s *net.Session, _ []string, _ string) { g.executeBlackjackAction(s, "double") }, ClassActive},
- "split": {func(g *Game, s *net.Session, _ []string, _ string) { g.executeBlackjackAction(s, "split") }, ClassActive},
- "insurance": {func(g *Game, s *net.Session, _ []string, _ string) { g.executeBlackjackAction(s, "insurance") }, ClassActive},
- "surrender": {func(g *Game, s *net.Session, _ []string, _ string) { g.executeBlackjackAction(s, "surrender") }, ClassActive},
+ "hit": {func(g *Game, s *net.Session, _ []string, _ string) { g.executeTableAction(s, "hit") }, ClassActive},
+ "stand": {func(g *Game, s *net.Session, _ []string, _ string) { g.executeTableAction(s, "stand") }, ClassActive},
+ "double": {func(g *Game, s *net.Session, _ []string, _ string) { g.executeTableAction(s, "double") }, ClassActive},
+ "split": {func(g *Game, s *net.Session, _ []string, _ string) { g.executeTableAction(s, "split") }, ClassActive},
+ "insurance": {func(g *Game, s *net.Session, _ []string, _ string) { g.executeTableAction(s, "insurance") }, ClassActive},
+ "surrender": {func(g *Game, s *net.Session, _ []string, _ string) { g.executeTableAction(s, "surrender") }, ClassActive},
"style": {(*Game).executeStyle, ClassInstant},
"look": {(*Game).executeLook, ClassInstant},
"l": {(*Game).executeLook, ClassInstant},
diff --git a/internal/game/render_help.go b/internal/game/render_help.go
index afd5b86..4a283d1 100644
--- a/internal/game/render_help.go
+++ b/internal/game/render_help.go
@@ -54,7 +54,7 @@ var commandList = []cmdEntry{
{"look / l", "Instant", "Look around or examine things"},
{"map", "Instant", "Display an ASCII map of the area"},
{"play", "Active", "Join a casino game in the current room"},
- {"bet", "Active", "Place a casino wager"},
+ {"bet", "Active", "Place a casino wager (e.g. bet 100 on banker)"},
{"autobet / autospin", "Active", "Automatically repeat a casino wager"},
{"spin", "Active", "Spin the current casino wager"},
{"hit / stand / double / split", "Active", "Play a blackjack hand"},