diff options
| author | historia <[not public]> | 2026-08-03 16:18:17 -0400 |
|---|---|---|
| committer | historia <[not public]> | 2026-08-03 16:19:25 -0400 |
| commit | f2c4a58727169a03b8102704888d1579d26b28c8 (patch) | |
| tree | a334f9cd37b3c7521c11d266d797b208dac4f3be | |
| parent | 612e429de438821ae8e099d6b54f87c11ff6c1b5 (diff) | |
| download | thehouseoficarus-f2c4a58727169a03b8102704888d1579d26b28c8.tar.gz | |
feat: multiplayer baccarat game
| -rw-r--r-- | building_guide/rooms.md | 37 | ||||
| -rw-r--r-- | data/rooms/intro/2001.yaml | 11 | ||||
| -rw-r--r-- | internal/casino/baccarat.go | 377 | ||||
| -rw-r--r-- | internal/casino/baccarat_test.go | 355 | ||||
| -rw-r--r-- | internal/casino/blackjack.go | 132 | ||||
| -rw-r--r-- | internal/casino/blackjack_test.go | 50 | ||||
| -rw-r--r-- | internal/casino/cards.go | 80 | ||||
| -rw-r--r-- | internal/casino/manager.go | 10 | ||||
| -rw-r--r-- | internal/casino/manager_test.go | 8 | ||||
| -rw-r--r-- | internal/casino/table.go | 147 | ||||
| -rw-r--r-- | internal/casino/types.go | 52 | ||||
| -rw-r--r-- | internal/game/casino.go | 143 | ||||
| -rw-r--r-- | internal/game/casino_test.go | 64 | ||||
| -rw-r--r-- | internal/game/cmd_registry.go | 12 | ||||
| -rw-r--r-- | internal/game/render_help.go | 2 |
15 files changed, 1230 insertions, 250 deletions
diff --git a/building_guide/rooms.md b/building_guide/rooms.md index 731b064..bbd7ec1 100644 --- a/building_guide/rooms.md +++ b/building_guide/rooms.md @@ -222,9 +222,9 @@ Mobs stop wandering while in combat. Dead mobs respawn at their home room. Rooms may host one or more multiplayer casino tables. Players join by typing `play <game>`; the table ID is an internal room-local identifier and does not -need to be typed. Blackjack is the multiplayer table game; slots run as -single-player machines (see below), and older rooms that declare slots under -`casino_tables` are migrated to machines automatically. +need to be typed. Blackjack and baccarat are the multiplayer table games; +slots run as single-player machines (see below), and older rooms that declare +slots under `casino_tables` are migrated to machines automatically. `max_players`, `betting_ticks`, `min_bet`, and `max_bet` use defaults when omitted. Game-specific rule configuration is nested alongside the table definition. @@ -272,7 +272,36 @@ keys may define Unicode art for sizes 5, 7, and 9. Missing art uses the generated renderer. Players can choose `option card_size 1`, `5`, `7`, or `9`; size 1 uses Unicode playing-card symbols. ASCII mode always uses generated 5x5 cards. `option card_style light` adds a white card background; `dark` is -the default style. +the default style. The same art renders baccarat hands. + +Baccarat tables play punto banco: every participant bets on the player, the +banker, or a tie, then the dealer plays out both hands by fixed drawing rules +— there are no mid-round player decisions: + +```yaml +casino_tables: + - id: baccarat + game: baccarat + max_players: 6 + betting_ticks: 20 + min_bet: 1 + max_bet: 1000 + baccarat: + decks: 8 + shuffle_at: 0.25 + tie_payout: 8 + banker_payout: 0.95 +``` + +At a baccarat table, `bet <amount> on player`, `bet <amount> on banker`, and +`bet <amount> on tie` place wagers (the words and amounts may come in any +order, and `min`/`max` work as usual). A bare `bet` repeats the previous +amount and target. Winning player bets pay 1:1; winning banker bets pay +`banker_payout`:1 (0.95 is the classic 5% commission); winning tie bets pay +`tie_payout`:1. On a tie, player and banker bets push. When the betting +window closes, both hands are dealt by the standard tableau: naturals (8 or +9) stand immediately, the player hand draws on 0-5, and the banker hand +follows the usual third-card rules. Single-player machines use `casino_machines` and are separate from multiplayer tables: diff --git a/data/rooms/intro/2001.yaml b/data/rooms/intro/2001.yaml index 6b85dbb..3a953b2 100644 --- a/data/rooms/intro/2001.yaml +++ b/data/rooms/intro/2001.yaml @@ -26,6 +26,17 @@ casino_tables: surrender: false action_ticks: 20 split_unlike_ten_values: true + - id: baccarat + game: baccarat + max_players: 6 + betting_ticks: 20 + min_bet: 1 + max_bet: 1000 + baccarat: + decks: 8 + shuffle_at: 0.25 + tie_payout: 8 + banker_payout: 0.95 color: "" description: - text: It's a test room 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"}, |
