diff options
Diffstat (limited to 'internal/casino')
| -rw-r--r-- | internal/casino/betting.go | 60 | ||||
| -rw-r--r-- | internal/casino/blackjack.go | 773 | ||||
| -rw-r--r-- | internal/casino/blackjack_test.go | 233 | ||||
| -rw-r--r-- | internal/casino/machine.go | 54 | ||||
| -rw-r--r-- | internal/casino/machine_test.go | 44 | ||||
| -rw-r--r-- | internal/casino/manager.go | 14 | ||||
| -rw-r--r-- | internal/casino/manager_test.go | 19 | ||||
| -rw-r--r-- | internal/casino/slots.go | 2 | ||||
| -rw-r--r-- | internal/casino/slots_test.go | 15 | ||||
| -rw-r--r-- | internal/casino/table.go | 115 | ||||
| -rw-r--r-- | internal/casino/types.go | 114 |
11 files changed, 1346 insertions, 97 deletions
diff --git a/internal/casino/betting.go b/internal/casino/betting.go new file mode 100644 index 0000000..3ffee88 --- /dev/null +++ b/internal/casino/betting.go @@ -0,0 +1,60 @@ +package casino + +// countdown tracks tick-based deadlines for table phases (betting windows, +// insurance offers, and player action timeouts). +type countdown struct { + clock int + deadline int +} + +func (c *countdown) start(ticks int) { c.clock, c.deadline = 0, ticks } +func (c *countdown) stop() { c.clock, c.deadline = 0, 0 } +func (c *countdown) tick() { c.clock++ } +func (c *countdown) expired() bool { return c.deadline > 0 && c.clock >= c.deadline } + +// allBet reports whether at least one connected participant exists and every +// connected participant has placed a bet. Disconnected participants never +// hold a round open. +func allBet(seats []*Participant) bool { + connected := false + for _, p := range seats { + if !p.Connected { + continue + } + connected = true + if !p.HasBet { + return false + } + } + return connected +} + +// anyBet reports whether any participant, connected or not, has a wager +// committed to the current round. +func anyBet(seats []*Participant) bool { + for _, p := range seats { + if p.HasBet { + return true + } + } + return false +} + +// sitOutNames lists participants without a bet, in seat order. +func sitOutNames(seats []*Participant) []string { + var names []string + for _, p := range seats { + if !p.HasBet { + names = append(names, p.Name) + } + } + return names +} + +// clearBets resets per-round betting state on every participant. +func clearBets(seats []*Participant) { + for _, p := range seats { + p.HasBet = false + p.Wager = Wager{} + } +} diff --git a/internal/casino/blackjack.go b/internal/casino/blackjack.go new file mode 100644 index 0000000..a158a24 --- /dev/null +++ b/internal/casino/blackjack.go @@ -0,0 +1,773 @@ +package casino + +import ( + "fmt" + "math" + "math/rand" + "strings" + "sync" +) + +type blackjackPhase string + +const ( + blackjackWaiting blackjackPhase = "waiting" + blackjackBetting blackjackPhase = "betting" + blackjackInsurance blackjackPhase = "insurance" + 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 + bet int + fromSplit bool + doubled bool + stood bool + bust bool + surrendered bool +} + +func (h *blackjackHand) total() (int, bool) { + total, aces := 0, 0 + for _, card := range h.cards { + total += card.value() + if card.rank == "A" { + aces++ + } + } + for total > 21 && aces > 0 { + total -= 10 + aces-- + } + soft := aces > 0 + return total, soft +} + +func (h *blackjackHand) blackjack(rules BlackjackConfig) bool { + total, _ := h.total() + return len(h.cards) == 2 && !h.fromSplit && total == 21 || rules.CountsDoubleSplitBlackjack() && h.fromSplit && h.doubled && total == 21 +} + +type blackjackPlayer struct { + Participant + hands []*blackjackHand + insurance int + declinedInsurance bool + 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 + Rules BlackjackConfig + participants map[string]*blackjackPlayer + order []string + phase blackjackPhase + timer countdown + activePlayer int + activeHand int + dealer blackjackHand + shoe *blackjackShoe +} + +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, + participants: make(map[string]*blackjackPlayer), phase: blackjackWaiting, + shoe: newBlackjackShoe(rules.Decks, rng), + } +} + +func (t *blackjackTable) seats() []*Participant { + seats := make([]*Participant, 0, len(t.order)) + for _, name := range t.order { + if p := t.participants[name]; p != nil { + seats = append(seats, &p.Participant) + } + } + return seats +} + +func (t *blackjackTable) 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" + e.Menu = &MenuView{Kind: MenuBlackjackBet} + return e +} + +func (t *blackjackTable) join(name string) []Event { + t.mu.Lock() + defer t.mu.Unlock() + if p := t.participants[name]; p != nil { + p.Connected = true + if t.phase != blackjackWaiting { + e := t.private(name, "You resume your place.") + if t.phase != blackjackBetting { + e.Blackjack = t.snapshot(false) + } + return []Event{e} + } + return []Event{t.menuEvent(name)} + } + if len(t.participants) >= t.Config.MaxPlayers { + return []Event{t.private(name, "That blackjack table is full.")} + } + t.participants[name] = &blackjackPlayer{Participant: Participant{Name: name, Connected: true}} + t.order = append(t.order, name) + events := []Event{t.playerAction(name, fmt.Sprintf("%s sits down.", name), "You sit down.")} + events = append(events, t.menuEvent(name)) + return events +} + +func (t *blackjackTable) leave(name string) []Event { + t.mu.Lock() + defer t.mu.Unlock() + p := t.participants[name] + if p == nil { + return nil + } + if t.phase != blackjackWaiting { + return []Event{t.private(name, "You're in the middle of a blackjack round!")} + } + delete(t.participants, name) + for i, n := range t.order { + if n == name { + t.order = append(t.order[:i], t.order[i+1:]...) + break + } + } + 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 blackjack table closes.", true)) + } + return events +} + +func (t *blackjackTable) bet(name 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 t.phase != blackjackWaiting && t.phase != blackjackBetting { + return []Event{t.private(name, "That blackjack table isn't accepting bets.")} + } + 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("Blackjack 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 = true, wager, wallet, amount + events := []Event{t.playerAction(name, fmt.Sprintf("%s bets %d credits.", name, amount), fmt.Sprintf("You bet %d credits.", amount))} + if len(t.participants) > 1 && t.phase == blackjackWaiting { + t.phase = blackjackBetting + t.timer.start(t.Config.BettingTicks) + events = append(events, t.event(fmt.Sprintf("Blackjack betting is open for %d ticks.", t.Config.BettingTicks), true)) + } + if allBet(t.seats()) { + events = append(events, t.startRound()...) + } + return events +} + +func (t *blackjackTable) tick() []Event { + t.mu.Lock() + defer t.mu.Unlock() + switch t.phase { + case blackjackBetting: + t.timer.tick() + if allBet(t.seats()) { + return t.startRound() + } + 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 blackjack round.", name), true)) + } + if anyBet(t.seats()) { + events = append(events, t.startRound()...) + } else { + t.resetRound() + } + return events + case blackjackInsurance: + if t.insuranceSettled() { + return t.finishInsurance() + } + t.timer.tick() + if !t.timer.expired() { + return nil + } + return t.finishInsurance() + case blackjackPlaying: + if t.bettorCount() <= 1 { + return nil + } + t.timer.tick() + if !t.timer.expired() { + return nil + } + name := t.activeName() + t.participants[name].hands[t.activeHand].stood = true + events := []Event{t.private(name, "Your action timed out; standing.")} + events = append(events, t.finishHand()...) + return events + default: + return nil + } +} + +func (t *blackjackTable) action(name, action string) []Event { + t.mu.Lock() + defer t.mu.Unlock() + if t.phase == blackjackInsurance { + return t.insuranceAction(name, action) + } + if t.phase != blackjackPlaying || t.activeName() != name { + return []Event{t.private(name, "It's not your turn.")} + } + p := t.participants[name] + hand := p.hands[t.activeHand] + t.timer.start(t.Rules.ActionTicks) + switch action { + case "hit": + if fromSplitAces(hand) { + return []Event{t.private(name, "Split aces cannot be hit.")} + } + hand.cards = append(hand.cards, t.shoe.draw(t.Rules)) + total, _ := hand.total() + if total > 21 { + hand.bust = true + } + e := t.playerAction(name, fmt.Sprintf("%s hits and receives %s.", name, hand.cards[len(hand.cards)-1]), fmt.Sprintf("You hit and receive %s.", hand.cards[len(hand.cards)-1])) + e.Blackjack = t.snapshot(false) + events := []Event{e} + if total >= 21 { + events = append(events, t.finishHand()...) + } else { + events = append(events, t.prompt()...) + } + return events + case "stand": + hand.stood = true + return t.finishHand() + case "double": + if len(hand.cards) != 2 || hand.doubled || hand.surrendered || fromSplitAces(hand) || (hand.fromSplit && !t.Rules.AllowsDoubleAfterSplit()) { + return []Event{t.private(name, "You can only double a qualifying hand.")} + } + if _, ok := p.Wallet.Wager(hand.bet); !ok { + return []Event{t.private(name, "You don't have enough chips and credits to double that hand.")} + } + hand.bet *= 2 + hand.doubled = true + hand.stood = true + hand.cards = append(hand.cards, t.shoe.draw(t.Rules)) + if total, _ := hand.total(); total > 21 { + hand.bust = true + } + e := t.playerAction(name, fmt.Sprintf("%s doubles down.", name), "You double down.") + e.Blackjack = t.snapshot(false) + return append([]Event{e}, t.finishHand()...) + case "split": + return t.splitHand(name, p, hand) + case "surrender": + if !t.Rules.AllowsSurrender() || hand.fromSplit || len(hand.cards) != 2 { + return []Event{t.private(name, "Surrender is not available for this hand.")} + } + hand.surrendered = true + return t.finishHand() + default: + return []Event{t.private(name, "Unknown blackjack action.")} + } +} + +// insuranceAction handles commands during the insurance offer window: +// "insurance" buys cover for half the base bet; any other action declines. +func (t *blackjackTable) insuranceAction(name, action string) []Event { + p := t.participants[name] + if p == nil || !p.HasBet { + return []Event{t.private(name, "Insurance is only offered to players with a bet this round.")} + } + if p.insurance > 0 || p.declinedInsurance { + return []Event{t.private(name, "You have already decided about insurance.")} + } + var events []Event + if action == "insurance" { + amount := p.hands[0].bet / 2 + if amount <= 0 { + return []Event{t.private(name, "Your bet is too small to insure.")} + } + if _, ok := p.Wallet.Wager(amount); !ok { + return []Event{t.private(name, "You don't have enough chips and credits for insurance.")} + } + p.insurance = amount + events = append(events, t.playerAction(name, fmt.Sprintf("%s takes insurance.", name), fmt.Sprintf("You place %d credits on insurance.", amount))) + } else { + p.declinedInsurance = true + events = append(events, t.private(name, "You decline insurance.")) + } + if t.insuranceSettled() { + events = append(events, t.finishInsurance()...) + } + return events +} + +// insuranceSettled reports whether every connected bettor has taken or +// declined insurance. +func (t *blackjackTable) insuranceSettled() bool { + for _, name := range t.order { + p := t.participants[name] + if p == nil || !p.HasBet || !p.Connected { + continue + } + if p.insurance == 0 && !p.declinedInsurance { + return false + } + } + return true +} + +// finishInsurance resolves the insurance window: a dealer blackjack settles +// the round immediately, otherwise play begins. +func (t *blackjackTable) finishInsurance() []Event { + if t.dealerBlackjack() { + return t.settle() + } + t.beginPlay() + return t.finishHand() +} + +func (t *blackjackTable) splitHand(name string, p *blackjackPlayer, hand *blackjackHand) []Event { + if len(hand.cards) != 2 || len(p.hands) >= t.Rules.MaxSplitHands || !splitCompatible(hand.cards[0], hand.cards[1], t.Rules.AllowsUnlikeTenSplit()) { + return []Event{t.private(name, "That hand cannot be split.")} + } + if hand.cards[0].rank == "A" && hand.fromSplit && !t.Rules.AllowsResplitAces() { + return []Event{t.private(name, "Aces cannot be re-split at this table.")} + } + 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)) + 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.") + e.Blackjack = t.snapshot(false) + events := []Event{e} + if left.cards[0].rank == "A" { + left.stood = !(t.Rules.AllowsResplitAces() && left.cards[1].rank == "A") + right.stood = !(t.Rules.AllowsResplitAces() && right.cards[1].rank == "A") + events = append(events, t.finishHand()...) + } else { + events = append(events, t.prompt()...) + } + return events +} + +func splitCompatible(a, b blackjackCard, unlikeTens bool) bool { + if a.rank == b.rank { + return true + } + return unlikeTens && a.value() == 10 && b.value() == 10 +} + +func (t *blackjackTable) startRound() []Event { + t.dealer = blackjackHand{cards: []blackjackCard{t.shoe.draw(t.Rules), t.shoe.draw(t.Rules)}} + 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}} + if p.hands[0].bet/2 <= 0 { + // A half-bet that rounds to zero cannot be insured. + p.declinedInsurance = true + } + } + roundEvent := t.event("New blackjack hand!", true) + roundEvent.Blackjack = t.snapshot(false) + events := []Event{roundEvent} + if t.dealer.cards[1].rank == "A" { + t.phase = blackjackInsurance + t.timer.start(t.Rules.ActionTicks) + events = append(events, t.event("The dealer shows an ace. Insurance is on offer.", true)) + for _, name := range t.order { + p := t.participants[name] + if p == nil || !p.HasBet || !p.Connected || p.declinedInsurance { + continue + } + events = append(events, t.private(name, fmt.Sprintf("Insure your %d-credit bet for %d credits? Type \"insurance\" to buy, or any other action to decline.", p.hands[0].bet, p.hands[0].bet/2))) + } + if t.insuranceSettled() { + events = append(events, t.finishInsurance()...) + } + return events + } + if t.dealerBlackjack() { + events = append(events, t.settle()...) + return events + } + t.beginPlay() + return append(events, t.finishHand()...) +} + +func (t *blackjackTable) beginPlay() { + t.phase = blackjackPlaying + t.timer.start(t.Rules.ActionTicks) + t.activePlayer, t.activeHand = 0, 0 +} + +func (t *blackjackTable) prompt() []Event { + name := t.activeName() + if name == "" { + return t.settle() + } + p := t.participants[name] + h := p.hands[t.activeHand] + ranks := make([]string, len(h.cards)) + for i, card := range h.cards { + ranks[i] = card.rank + } + actions := t.availableActions(p, h) + e := t.private(name, fmt.Sprintf("Your turn (%s).\nActions: %s", strings.Join(ranks, ","), strings.Join(actions, ", "))) + e.Color = "casino_menu" + e.Menu = &MenuView{Kind: MenuBlackjackTurn, Title: fmt.Sprintf("Your turn (%s)", strings.Join(ranks, ",")), Actions: actions} + return []Event{e} +} + +func (t *blackjackTable) availableActions(p *blackjackPlayer, hand *blackjackHand) []string { + if fromSplitAces(hand) { + // Split aces receive one card each and may only be re-split. + actions := []string{"stand"} + if len(hand.cards) == 2 && len(p.hands) < t.Rules.MaxSplitHands && t.Rules.AllowsResplitAces() && + splitCompatible(hand.cards[0], hand.cards[1], t.Rules.AllowsUnlikeTenSplit()) { + actions = append(actions, "split") + } + return actions + } + actions := []string{"hit", "stand"} + if len(hand.cards) == 2 && !hand.doubled && (!hand.fromSplit || t.Rules.AllowsDoubleAfterSplit()) { + actions = append(actions, "double") + } + if len(hand.cards) == 2 && len(p.hands) < t.Rules.MaxSplitHands && splitCompatible(hand.cards[0], hand.cards[1], t.Rules.AllowsUnlikeTenSplit()) && + !(hand.cards[0].rank == "A" && hand.fromSplit && !t.Rules.AllowsResplitAces()) { + actions = append(actions, "split") + } + if t.Rules.AllowsSurrender() && !hand.fromSplit && len(hand.cards) == 2 { + actions = append(actions, "surrender") + } + return actions +} + +func fromSplitAces(hand *blackjackHand) bool { + return hand.fromSplit && len(hand.cards) > 0 && hand.cards[0].rank == "A" +} + +func (t *blackjackTable) finishHand() []Event { + t.skipCompletedHands() + if t.activeName() == "" { + return t.settle() + } + return t.prompt() +} + +func (t *blackjackTable) skipCompletedHands() { + for t.activePlayer < len(t.order) { + p := t.participants[t.order[t.activePlayer]] + if p == nil || !p.HasBet { + t.activePlayer++ + t.activeHand = 0 + continue + } + for t.activeHand < len(p.hands) { + h := p.hands[t.activeHand] + total, _ := h.total() + if h.stood || h.bust || h.surrendered || total >= 21 || h.blackjack(t.Rules) { + t.activeHand++ + continue + } + return + } + t.activePlayer++ + t.activeHand = 0 + } +} + +func (t *blackjackTable) activeName() string { + if t.activePlayer >= len(t.order) { + return "" + } + return t.order[t.activePlayer] +} + +func (t *blackjackTable) dealerBlackjack() bool { + return t.dealer.blackjack(t.Rules) +} + +func (t *blackjackTable) settle() []Event { + for { + total, soft := t.dealer.total() + if total > 17 || total == 17 && (!soft || !t.Rules.DealerHitsSoft17()) { + break + } + t.dealer.cards = append(t.dealer.cards, t.shoe.draw(t.Rules)) + } + dealerTotal, _ := t.dealer.total() + dealerEvent := t.event("Dealer's hand:", true) + dealerEvent.Blackjack = t.snapshot(true) + events := []Event{dealerEvent} + dealerBlackjack := t.dealerBlackjack() + for _, name := range t.order { + p := t.participants[name] + if p == nil || !p.HasBet { + continue + } + for _, hand := range p.hands { + result, payout := settleHand(hand, dealerTotal, dealerBlackjack, t.Rules) + if payout > 0 && p.Wallet != nil { + p.Wallet.PayCredits(payout) + } + publicResult, privateResult := blackjackResultMessages(name, result, payout) + settlement := t.playerAction(name, publicResult, privateResult) + if result == "blackjack" { + settlement.Color = "casino_jackpot_win" + } else if result == "win" { + settlement.Color = "casino_big_win" + } + events = append(events, settlement) + } + if p.insurance > 0 { + insurancePayout := 0 + if dealerBlackjack { + insurancePayout = int(math.Round(float64(p.insurance) * (1 + t.Rules.InsurancePayout))) + } + if insurancePayout > 0 { + p.Wallet.PayCredits(insurancePayout) + } + events = append(events, t.private(name, fmt.Sprintf("Insurance pays %d credits.", insurancePayout))) + } + } + 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 settleHand(hand *blackjackHand, dealerTotal int, dealerBlackjack bool, rules BlackjackConfig) (string, int) { + total, _ := hand.total() + if hand.surrendered { + return "surrender", hand.bet / 2 + } + if hand.bust { + return "bust", 0 + } + playerBlackjack := hand.blackjack(rules) + if playerBlackjack && dealerBlackjack { + return "push", hand.bet + } + if dealerBlackjack { + return "dealer blackjack", 0 + } + if playerBlackjack { + return "blackjack", int(math.Round(float64(hand.bet) * (1 + rules.BlackjackPayout))) + } + if total > 21 || dealerTotal > total && dealerTotal <= 21 { + return "loss", 0 + } + if dealerTotal == total { + return "push", hand.bet + } + if dealerTotal > 21 { + return "win", hand.bet * 2 + } + return "win", hand.bet * 2 +} + +func (t *blackjackTable) resetRound() { + t.phase = blackjackWaiting + t.timer.stop() + for _, p := range t.participants { + p.HasBet, p.Wager, p.hands, p.insurance, p.declinedInsurance = false, Wager{}, nil, 0, false + } +} + +func (t *blackjackTable) rebetAmount(name string) int { + if p := t.participants[name]; p != nil && p.lastBet > 0 { + return p.lastBet + } + return t.Config.MinBet +} + +func (t *blackjackTable) bettorCount() int { + count := 0 + for _, p := range t.participants { + if p.HasBet { + count++ + } + } + return count +} + +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}) + continue + } + snapshot.Dealer = append(snapshot.Dealer, BlackjackCardView{Rank: card.rank, Suit: card.suit}) + } + for _, name := range t.order { + p := t.participants[name] + if p == nil || !p.HasBet { + continue + } + 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}) + } + snapshot.Players = append(snapshot.Players, view) + } + } + if len(t.dealer.cards) > 1 { + snapshot.DealerShownScore = handScore(&blackjackHand{cards: []blackjackCard{t.dealer.cards[1]}}) + } + snapshot.DealerScore = handScore(&t.dealer) + snapshot.DealerRevealed = revealDealer + return snapshot +} + +func (t *blackjackTable) menu() string { + return "New hand! You can bet, bet <amount> (inc. min/max), or stop." +} + +func handScore(hand *blackjackHand) string { + total, soft := hand.total() + if !soft { + return fmt.Sprintf("%d", total) + } + return fmt.Sprintf("%d or %d", total-10, total) +} + +func blackjackResultMessages(name, result string, payout int) (string, string) { + switch result { + case "win", "blackjack": + 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!" + } +} + +func (t *blackjackTable) 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 *blackjackTable) markConnected(name string) { + t.mu.Lock() + defer t.mu.Unlock() + if p := t.participants[name]; p != nil { + p.Connected = true + } +} + +func (t *blackjackTable) hasParticipant(name string) bool { + t.mu.Lock() + defer t.mu.Unlock() + return t.participants[name] != nil +} diff --git a/internal/casino/blackjack_test.go b/internal/casino/blackjack_test.go new file mode 100644 index 0000000..e8381fb --- /dev/null +++ b/internal/casino/blackjack_test.go @@ -0,0 +1,233 @@ +package casino + +import ( + "math/rand" + "strings" + "testing" +) + +func TestBlackjackHandTreatsAceAsElevenWhenPossible(t *testing.T) { + hand := &blackjackHand{cards: []blackjackCard{{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"}) + total, soft = hand.total() + if total != 17 || soft { + t.Fatalf("total=%d soft=%v, want hard 17", total, soft) + } +} + +func TestBlackjackSettlementRules(t *testing.T) { + rules := (BlackjackConfig{}).Normalize() + natural := &blackjackHand{bet: 10, cards: []blackjackCard{{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"}}} + 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 { + 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 { + t.Fatalf("dealer bust result=%s payout=%d", result, payout) + } +} + +func TestBlackjackTableStartsAfterAllPlayersBet(t *testing.T) { + table := newBlackjackTable(1, TableConfig{ID: "blackjack", Game: GameBlackjack, MaxPlayers: 2}, newTestRand()) + 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") { + t.Fatal("round started before all players bet") + } + events := table.bet("bob", 10, wb) + if !hasMessage(events, "New blackjack hand") { + t.Fatalf("round did not start after all players bet: %+v", events) + } +} + +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"}}}}, + } + table.order = []string{"alice"} + table.phase = blackjackPlaying + if events := table.tick(); len(events) != 0 { + t.Fatalf("solo action timed out: %+v", events) + } + if table.phase != blackjackPlaying { + t.Fatalf("solo table phase changed to %s", table.phase) + } +} + +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) + } +} + +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.order = []string{"alice"} + table.activePlayer = 0 + table.activeHand = 0 + table.dealer.cards = []blackjackCard{{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) + } + if event.Menu == nil || event.Menu.Kind != MenuBlackjackTurn || event.Menu.Title != "Your turn (K,6)" || + len(event.Menu.Actions) != 3 || event.Menu.Actions[0] != "hit" || event.Menu.Actions[1] != "stand" || event.Menu.Actions[2] != "double" { + t.Fatalf("prompt menu=%+v", event.Menu) + } +} + +func TestBlackjackMenuEventCarriesStructuredMenu(t *testing.T) { + table := newBlackjackTable(1, TableConfig{ID: "blackjack", Game: GameBlackjack}, newTestRand()) + event := table.menuEvent("alice") + if event.Menu == nil || event.Menu.Kind != MenuBlackjackBet { + t.Fatalf("menu event=%+v", event.Menu) + } +} + +func TestBlackjackInsurancePaysOnDealerBlackjack(t *testing.T) { + table := newBlackjackTable(1, TableConfig{ID: "blackjack", Game: GameBlackjack}, newTestRand()) + 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"}}}}, + } + table.order = []string{"alice"} + table.dealer.cards = []blackjackCard{{rank: "K"}, {rank: "A"}} + table.phase = blackjackInsurance + events := table.action("alice", "insurance") + if w.credits != 95 { + t.Fatalf("insurance stake credits=%d, want 95", w.credits) + } + if w.paid != 15 { + t.Fatalf("insurance payout=%d, want 15 (stake + 2x)", w.paid) + } + if !hasMessage(events, "Insurance pays 15 credits.") { + t.Fatalf("missing insurance payout message: %+v", events) + } + if table.phase != blackjackWaiting { + t.Fatalf("phase=%s, want waiting after dealer blackjack settles", table.phase) + } +} + +func TestBlackjackInsuranceDeclineContinuesRound(t *testing.T) { + table := newBlackjackTable(1, TableConfig{ID: "blackjack", Game: GameBlackjack}, newTestRand()) + 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"}}}}, + } + table.order = []string{"alice"} + table.dealer.cards = []blackjackCard{{rank: "9"}, {rank: "A"}} + table.phase = blackjackInsurance + events := table.action("alice", "stand") + if !hasMessage(events, "You decline insurance.") { + t.Fatalf("missing decline message: %+v", events) + } + if table.phase != blackjackPlaying { + t.Fatalf("phase=%s, want playing after insurance declined", table.phase) + } + if !hasMessage(events, "Your turn (10,8).") { + t.Fatalf("round did not continue after insurance: %+v", events) + } +} + +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"}}} + 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"}}}}, + } + table.order = []string{"alice"} + table.dealer.cards = []blackjackCard{{rank: "10"}, {rank: "7"}} + table.phase = blackjackPlaying + hand := table.participants["alice"].hands[0] + events := table.action("alice", "double") + if !hand.bust { + t.Fatal("doubling into 26 did not set the bust flag") + } + if !hasMessage(events, "loses") { + t.Fatalf("busted double did not settle as a loss: %+v", events) + } +} + +func TestBlackjackSplitAcesOnlyAllowResplit(t *testing.T) { + resplit := true + table := newBlackjackTable(1, TableConfig{ID: "blackjack", Game: GameBlackjack, Blackjack: BlackjackConfig{ResplitAces: &resplit}}, newTestRand()) + w := &testWallet{credits: 100} + table.participants["alice"] = &blackjackPlayer{ + 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"}}}}, + } + table.order = []string{"alice"} + table.dealer.cards = []blackjackCard{{rank: "10"}, {rank: "7"}} + table.phase = blackjackPlaying + p := table.participants["alice"] + actions := table.availableActions(p, p.hands[0]) + if len(actions) != 2 || actions[0] != "stand" || actions[1] != "split" { + t.Fatalf("split ace actions=%v, want [stand split]", actions) + } + if events := table.action("alice", "hit"); !hasMessage(events, "Split aces cannot be hit.") { + t.Fatalf("hit on split aces not rejected: %+v", events) + } +} + +func TestBlackjackBettingIgnoresDisconnected(t *testing.T) { + table := newBlackjackTable(1, TableConfig{ID: "blackjack", Game: GameBlackjack, MaxPlayers: 2}, newTestRand()) + table.join("alice") + table.join("bob") + table.markDisconnected("bob") + w := &testWallet{credits: 100} + events := table.bet("alice", 10, w) + if !hasMessage(events, "New blackjack hand") { + t.Fatalf("disconnected player stalled the round: %+v", events) + } +} + +func TestBlackjackReconnectShowsTableSnapshot(t *testing.T) { + table := newBlackjackTable(1, TableConfig{ID: "blackjack", Game: GameBlackjack}, newTestRand()) + table.participants["alice"] = &blackjackPlayer{ + Participant: Participant{Name: "alice", HasBet: true, Connected: true}, + hands: []*blackjackHand{{bet: 10, cards: []blackjackCard{{rank: "10"}, {rank: "8"}}}}, + } + table.order = []string{"alice"} + table.dealer.cards = []blackjackCard{{rank: "10"}, {rank: "7"}} + table.phase = blackjackPlaying + events := table.join("alice") + if len(events) != 1 || events[0].Blackjack == nil { + t.Fatalf("reconnect during play lacked a table snapshot: %+v", events) + } +} + +func hasMessage(events []Event, fragment string) bool { + for _, event := range events { + if contains(event.Message, fragment) || contains(event.PrivateMessage, fragment) { + return true + } + } + return false +} + +func contains(value, fragment string) bool { + return strings.Contains(value, fragment) +} + +func newTestRand() *rand.Rand { return rand.New(rand.NewSource(7)) } diff --git a/internal/casino/machine.go b/internal/casino/machine.go index d9fa590..887e154 100644 --- a/internal/casino/machine.go +++ b/internal/casino/machine.go @@ -40,6 +40,7 @@ func newMachine(roomID int, cfg MachineConfig, rng *rand.Rand) *Machine { if cfg.Payout > 0 { profile = ScaleToRTP(profile, cfg.Payout) } + profile = profile.Normalize() return &Machine{RoomID: roomID, Config: cfg, phase: machineReady, profile: profile, rng: rng} } @@ -97,23 +98,14 @@ func (m *Machine) start(name string, wallet Wallet) []Event { if m.bet <= 0 { return m.private("Set a wager first with 'bet <amount>'.") } - wager := m.bet - if m.freeSpins > 0 { - m.spinBet = wager - } else { - if _, ok := wallet.Wager(wager); !ok { - return m.private("You don't have enough chips and credits for that bet.") - } - m.spinBet = wager + if _, ok := wallet.Wager(m.bet); !ok { + return m.private("You don't have enough chips and credits for that bet.") } + m.spinBet = m.bet m.player.Wallet = wallet m.spin = generateSlotSpinWithProfile(m.rng, m.spinBet, m.profile) m.reel, m.clock, m.suspense = 0, 0, false m.phase = machineSpinning - if m.freeSpins > 0 { - m.freeSpins-- - return m.playerAction(fmt.Sprintf("%s begins a free spin.", name), "You begin a free spin.") - } return m.playerAction(fmt.Sprintf("%s pulls the lever on the slots machine.", name), "You pull the lever on the slots machine.") } @@ -179,7 +171,7 @@ func (m *Machine) tick() []Event { m.reel++ message := fmt.Sprintf("Reel %d stops...", m.reel) events := m.publicSlot(message, m.reel) - if m.reel < 5 && stoppedScatters(m.spin.Grid, m.reel) >= 2 && !m.suspense { + if m.reel < 5 && stoppedScatters(m.spin.Grid, m.reel, m.profile.FreeSpinTrigger) >= 2 && !m.suspense { m.suspense = true events = append(events, m.public("Two scatters! The remaining reels slow down...")...) } @@ -232,19 +224,16 @@ func (m *Machine) finishSpin() []Event { if credits > m.spinBet { winEvents[0].Color = "casino_big_win" } - if credits >= 2*m.spinBet { - winEvents[0].Color = "casino_jackpot_win" - } events = append(events, winEvents...) } if len(m.spin.Wins) > 0 { - winEvents := m.private(formatWins(m.spin.Wins)) + winEvents := m.private(formatWins(m.spin.Wins, m.profile.Ways)) winEvents[0].Color = "casino_win" events = append(events, winEvents...) } if m.spin.ScatterCount >= 3 && m.freeSpins == 0 { - m.freeSpins = 8 - events = append(events, m.public("Three scatter symbols trigger 8 free spins!")...) + m.freeSpins = m.profile.FreeSpinCount + events = append(events, m.public(fmt.Sprintf("Three scatter symbols trigger %d free spins!", m.profile.FreeSpinCount))...) } if m.freeSpins > 0 { m.freeSpins-- @@ -269,6 +258,7 @@ func (m *Machine) leave(name string) []Event { } m.player = nil m.bet = 0 + m.payoutRemainder = 0 return []Event{{RoomID: m.RoomID, TableID: m.Config.ID, Type: EventLeft, Player: name, Message: fmt.Sprintf("%s stands up from the slots machine.", name), PrivateMessage: "You stand up from the slot machine.", PrivateTo: name, Color: "casino_action", Public: true}} } @@ -322,11 +312,11 @@ func (m *Machine) publicSlot(message string, stopped int) []Event { Message: message, Color: "casino_action", Public: true, Slot: &SlotSnapshot{Grid: m.spin.Grid, StoppedReels: stopped}}} } -func stoppedScatters(grid [3][5]string, reels int) int { +func stoppedScatters(grid [3][5]string, reels int, trigger string) int { count := 0 for row := 0; row < 3; row++ { for reel := 0; reel < reels && reel < 5; reel++ { - if grid[row][reel] == "scatter" { + if grid[row][reel] == trigger { count++ } } @@ -334,28 +324,22 @@ func stoppedScatters(grid [3][5]string, reels int) int { return count } -func formatWins(wins []SlotWin) string { - message := "Winning lines/ways (243-way evaluation):\n" +func formatWins(wins []SlotWin, ways int) string { + message := fmt.Sprintf("Winning lines/ways (%d-way evaluation):\n", ways) for _, win := range wins { - message += fmt.Sprintf(" %s x%d: %d ways x %.4gx bet / 243 = %.4f credits\n", win.Symbol, win.Length, win.Ways, win.Multiplier, win.Payout) + message += fmt.Sprintf(" %s x%d: %d ways x %.4gx bet / %d = %.4f credits\n", win.Symbol, win.Length, win.Ways, win.Multiplier, ways, win.Payout) } return message[:len(message)-1] } +const slotMenuText = "Slot machine commands:\n bet <amount> Set your wager\n bet max Set the maximum wager\n spin Spin using your current wager\n autospin Start automatic betting\n stop Leave the slot machine" + func (m *Machine) privateMenu(prefix string) []Event { - if m.phase != machineReady { - events := m.private(prefix) - events[0].Color = "casino_menu" - return events - } - menu := "Slot machine commands:\n bet <amount> Set your wager\n bet max Set the maximum wager\n spin Spin using your current wager\n autospin Start automatic betting\n stop Leave the slot machine" - if prefix == "" { - prefix = "\n" + menu - } else { - prefix += "\n\n" + menu - } events := m.private(prefix) events[0].Color = "casino_menu" + if m.phase == machineReady { + events[0].Menu = &MenuView{Kind: MenuSlotCommands, Body: slotMenuText} + } return events } diff --git a/internal/casino/machine_test.go b/internal/casino/machine_test.go new file mode 100644 index 0000000..fdfa42c --- /dev/null +++ b/internal/casino/machine_test.go @@ -0,0 +1,44 @@ +package casino + +import ( + "math/rand" + "testing" +) + +func TestMachineFreeSpinsUseProfileCount(t *testing.T) { + m := newMachine(1, MachineConfig{ID: "slots", Game: GameSlots}, rand.New(rand.NewSource(7))) + m.profile.FreeSpinCount = 5 + m.player = &Participant{Name: "alice", Connected: true, Wallet: &testWallet{credits: 100}} + m.spinBet = 10 + m.spin = &SlotSpin{ScatterCount: 3} + events := m.finishSpin() + if m.freeSpins != 4 { + t.Fatalf("freeSpins=%d, want 4 remaining after the first of 5 chained spins", m.freeSpins) + } + if m.phase != machineSpinning { + t.Fatalf("phase=%s, want spinning", m.phase) + } + if !hasMessage(events, "trigger 5 free spins") { + t.Fatalf("missing profile-count free spin message: %+v", events) + } +} + +func TestMachineMenuEventCarriesStructuredMenu(t *testing.T) { + m := newMachine(1, MachineConfig{ID: "slots", Game: GameSlots}, rand.New(rand.NewSource(7))) + m.player = &Participant{Name: "alice", Connected: true} + events := m.privateMenu("You sit down at the slot machine.") + if len(events) != 1 || events[0].Menu == nil || events[0].Menu.Kind != MenuSlotCommands || events[0].Menu.Body == "" { + t.Fatalf("slot menu event=%+v", events) + } +} + +func TestMachineLeaveResetsPayoutRemainder(t *testing.T) { + m := newMachine(1, MachineConfig{ID: "slots", Game: GameSlots}, rand.New(rand.NewSource(7))) + m.player = &Participant{Name: "alice", Connected: true} + m.bet = 10 + m.payoutRemainder = 0.5 + m.leave("alice") + if m.payoutRemainder != 0 { + t.Fatalf("payoutRemainder=%v leaked to the next player", m.payoutRemainder) + } +} diff --git a/internal/casino/manager.go b/internal/casino/manager.go index d6a3825..46ad692 100644 --- a/internal/casino/manager.go +++ b/internal/casino/manager.go @@ -165,6 +165,20 @@ func (m *Manager) Bet(roomID int, tableID, name string, amount int, wallet Walle return []Event{{RoomID: roomID, TableID: tableID, Type: EventResult, Player: name, PrivateTo: name, Message: "That game is not available here."}} } +func (m *Manager) Action(roomID int, tableID, name, action string) []Event { + if table := m.Table(roomID, tableID); table != nil { + return table.action(name, action) + } + 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 { + if table := m.Table(roomID, tableID); table != nil { + return table.rebetAmount(name) + } + return 0 +} + func (m *Manager) MarkDisconnected(name string) []Event { m.mu.Lock() tables := make([]*Table, 0, len(m.tables)) diff --git a/internal/casino/manager_test.go b/internal/casino/manager_test.go index 245133d..8d6dd3d 100644 --- a/internal/casino/manager_test.go +++ b/internal/casino/manager_test.go @@ -64,6 +64,25 @@ func TestMultiplePlayersWaitForBets(t *testing.T) { m.Bet(1, "slots", "bob", 10, wb) } +func TestTableBettingIgnoresDisconnected(t *testing.T) { + m := NewManager(1) + cfg := TableConfig{ID: "slots", Game: GameSlots, BettingTicks: 20} + m.Join(1, cfg, "alice") + m.Join(1, cfg, "bob") + m.MarkDisconnected("bob") + w := &testWallet{credits: 100} + events := m.Bet(1, "slots", "alice", 10, w) + found := false + for _, event := range events { + if event.Type == EventRoundStarted { + found = true + } + } + if !found { + t.Fatal("disconnected player stalled the table's round") + } +} + func TestMachineAutobetCountdownAndStop(t *testing.T) { m := NewManager(1) cfg := MachineConfig{ID: "slots", Game: GameSlots, SpinTicks: 1} diff --git a/internal/casino/slots.go b/internal/casino/slots.go index 9d53bb1..5c387c8 100644 --- a/internal/casino/slots.go +++ b/internal/casino/slots.go @@ -145,7 +145,7 @@ func evaluateSlot(grid [3][5]string, bet int, profile SlotProfile) (float64, []S for _, symbol := range p.Symbols { length, ways := matchingLength(grid, symbol.ID) if length >= 3 && symbol.Payout[length] > 0 { - payout := float64(bet) * symbol.Payout[length] * float64(ways) / 243.0 + payout := float64(bet) * symbol.Payout[length] * float64(ways) / float64(p.Ways) total += payout wins = append(wins, SlotWin{Symbol: symbol.ID, Length: length, Ways: ways, Multiplier: symbol.Payout[length], Payout: payout}) } diff --git a/internal/casino/slots_test.go b/internal/casino/slots_test.go index 22e39bf..47754dc 100644 --- a/internal/casino/slots_test.go +++ b/internal/casino/slots_test.go @@ -28,6 +28,21 @@ func TestScaleToRTP(t *testing.T) { } } +func TestEvaluateSlotHonorsProfileWays(t *testing.T) { + profile := SlotProfile{Rows: 3, Reels: 5, Ways: 1, + Symbols: []SlotSymbol{{ID: "x", Weight: 1, Payout: map[int]float64{5: 2}}}} + grid := [3][5]string{} + for row := range grid { + for reel := range grid[row] { + grid[row][reel] = "x" + } + } + payout, _ := evaluateSlot(grid, 10, profile) + if payout != 4860 { + t.Fatalf("payout=%v, want 4860 (bet 10 x 2 x 243 ways / 1)", payout) + } +} + func TestEvaluateSlotReportsWinningWays(t *testing.T) { profile := SlotProfile{Rows: 3, Reels: 5, Ways: 243, Symbols: []SlotSymbol{{ID: "x", Weight: 1, Payout: map[int]float64{5: 2}}}} diff --git a/internal/casino/table.go b/internal/casino/table.go index 1b9d0b5..e993cb8 100644 --- a/internal/casino/table.go +++ b/internal/casino/table.go @@ -21,22 +21,39 @@ type Table struct { participants map[string]*Participant order []string phase tablePhase - deadline int - clock int + timer countdown rng *rand.Rand + blackjack *blackjackTable } func newTable(roomID int, cfg TableConfig, rng *rand.Rand) *Table { - return &Table{ + t := &Table{ 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 { + seats := make([]*Participant, 0, len(t.order)) + for _, name := range t.order { + if p := t.participants[name]; p != nil { + seats = append(seats, p) + } + } + return seats } func (t *Table) join(name string) []Event { + if t.blackjack != nil { + return t.blackjack.join(name) + } t.mu.Lock() defer t.mu.Unlock() if p := t.participants[name]; p != nil { @@ -52,6 +69,9 @@ func (t *Table) join(name string) []Event { } func (t *Table) leave(name string) []Event { + if t.blackjack != nil { + return t.blackjack.leave(name) + } t.mu.Lock() defer t.mu.Unlock() p := t.participants[name] @@ -76,6 +96,9 @@ func (t *Table) leave(name string) []Event { } func (t *Table) bet(name string, amount int, wallet Wallet) []Event { + if t.blackjack != nil { + return t.blackjack.bet(name, amount, wallet) + } t.mu.Lock() defer t.mu.Unlock() p := t.participants[name] @@ -85,12 +108,12 @@ func (t *Table) bet(name string, amount int, wallet Wallet) []Event { if t.phase != phaseWaiting && t.phase != phaseBetting { return []Event{{RoomID: t.RoomID, TableID: t.Config.ID, Type: EventResult, Player: name, PrivateTo: name, Message: "That table isn't accepting bets."}} } - if amount < t.Config.MinBet || amount > t.Config.MaxBet { - return []Event{{RoomID: t.RoomID, TableID: t.Config.ID, Type: EventResult, Player: name, PrivateTo: name, Message: fmt.Sprintf("Bets must be between %d and %d.", t.Config.MinBet, t.Config.MaxBet)}} - } if p.HasBet { return []Event{{RoomID: t.RoomID, TableID: t.Config.ID, Type: EventResult, Player: name, PrivateTo: name, Message: "You have already bet this round."}} } + if amount < t.Config.MinBet || amount > t.Config.MaxBet { + return []Event{{RoomID: t.RoomID, TableID: t.Config.ID, Type: EventResult, Player: name, PrivateTo: name, Message: fmt.Sprintf("Bets must be between %d and %d.", t.Config.MinBet, t.Config.MaxBet)}} + } wager, ok := wallet.Wager(amount) if !ok { return []Event{{RoomID: t.RoomID, TableID: t.Config.ID, Type: EventResult, Player: name, PrivateTo: name, Message: "You don't have enough chips and credits for that bet."}} @@ -100,40 +123,39 @@ func (t *Table) bet(name string, amount int, wallet Wallet) []Event { p.Wallet = wallet if t.phase == phaseWaiting && len(t.participants) > 1 { t.phase = phaseBetting - t.clock = 0 - t.deadline = t.Config.BettingTicks + t.timer.start(t.Config.BettingTicks) } events := []Event{{RoomID: t.RoomID, TableID: t.Config.ID, Type: EventBetPlaced, Player: name, Message: fmt.Sprintf("%s bets %d credits at the %s table.", name, amount, t.Config.ID), Public: true}} - if t.phase == phaseBetting && t.clock == 0 { - events = append(events, Event{RoomID: t.RoomID, TableID: t.Config.ID, Type: EventBetting, Message: fmt.Sprintf("Betting is open at the %s table for %d ticks.", t.Config.ID, t.deadline), Public: true}) + if t.phase == phaseBetting && t.timer.clock == 0 { + events = append(events, Event{RoomID: t.RoomID, TableID: t.Config.ID, Type: EventBetting, Message: fmt.Sprintf("Betting is open at the %s table for %d ticks.", t.Config.ID, t.Config.BettingTicks), Public: true}) } - if len(t.participants) == 1 || t.allBet() { + if len(t.participants) == 1 || allBet(t.seats()) { events = append(events, t.resolveRound()...) } return events } func (t *Table) tick() []Event { + if t.blackjack != nil { + return t.blackjack.tick() + } t.mu.Lock() defer t.mu.Unlock() if t.phase != phaseBetting { return nil } - t.clock++ - if t.allBet() { + t.timer.tick() + if allBet(t.seats()) { return t.resolveRound() } - if t.clock < t.deadline { + if !t.timer.expired() { return nil } var events []Event - for _, name := range t.order { - p := t.participants[name] - if p != nil && !p.HasBet { - events = append(events, Event{RoomID: t.RoomID, TableID: t.Config.ID, Type: EventTimedOut, Player: name, Message: fmt.Sprintf("%s sits out this round.", name), Public: true}) - } + for _, name := range sitOutNames(t.seats()) { + events = append(events, Event{RoomID: t.RoomID, TableID: t.Config.ID, Type: EventTimedOut, Player: name, Message: fmt.Sprintf("%s sits out this round.", name), Public: true}) } - if t.anyBet() { + if anyBet(t.seats()) { events = append(events, t.resolveRound()...) } else { t.resetRound() @@ -164,36 +186,14 @@ func (t *Table) resolveRound() []Event { func (t *Table) resetRound() { t.phase = phaseWaiting - t.clock = 0 - t.deadline = 0 - for _, p := range t.participants { - p.HasBet = false - p.Wager = Wager{} - } -} - -func (t *Table) allBet() bool { - if len(t.participants) == 0 { - return false - } - for _, p := range t.participants { - if !p.HasBet { - return false - } - } - return true -} - -func (t *Table) anyBet() bool { - for _, p := range t.participants { - if p.HasBet { - return true - } - } - return false + t.timer.stop() + clearBets(t.seats()) } func (t *Table) markDisconnected(name string) []Event { + if t.blackjack != nil { + return t.blackjack.markDisconnected(name) + } t.mu.Lock() defer t.mu.Unlock() if p := t.participants[name]; p != nil { @@ -203,6 +203,10 @@ func (t *Table) markDisconnected(name string) []Event { } func (t *Table) markConnected(name string) { + if t.blackjack != nil { + t.blackjack.markConnected(name) + return + } t.mu.Lock() defer t.mu.Unlock() if p := t.participants[name]; p != nil { @@ -211,7 +215,24 @@ func (t *Table) markConnected(name string) { } func (t *Table) hasParticipant(name string) bool { + if t.blackjack != nil { + return t.blackjack.hasParticipant(name) + } 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 *Table) rebetAmount(name string) int { + if t.blackjack != nil { + return t.blackjack.rebetAmount(name) + } + return t.Config.MinBet +} diff --git a/internal/casino/types.go b/internal/casino/types.go index f284869..1c4326a 100644 --- a/internal/casino/types.go +++ b/internal/casino/types.go @@ -1,20 +1,73 @@ package casino -import "fmt" - type GameName string const ( - GameSlots GameName = "slots" + GameSlots GameName = "slots" + GameBlackjack GameName = "blackjack" ) +type BlackjackConfig struct { + Decks int `yaml:"decks,omitempty"` + ShuffleAt float64 `yaml:"shuffle_at,omitempty"` + BlackjackPayout float64 `yaml:"blackjack_payout,omitempty"` + DealerHitSoft17 *bool `yaml:"dealer_hit_soft_17,omitempty"` + MaxSplitHands int `yaml:"max_split_hands,omitempty"` + ResplitAces *bool `yaml:"resplit_aces,omitempty"` + DoubleAfterSplit *bool `yaml:"double_after_split,omitempty"` + DoubleSplitBlackjack *bool `yaml:"double_split_blackjack,omitempty"` + InsurancePayout float64 `yaml:"insurance_payout,omitempty"` + Surrender *bool `yaml:"surrender,omitempty"` + ActionTicks int `yaml:"action_ticks,omitempty"` + SplitUnlikeTenValues *bool `yaml:"split_unlike_ten_values,omitempty"` +} + +func ruleBool(value *bool, defaultValue bool) bool { + if value == nil { + return defaultValue + } + return *value +} + +func (c BlackjackConfig) DealerHitsSoft17() bool { return ruleBool(c.DealerHitSoft17, false) } +func (c BlackjackConfig) AllowsResplitAces() bool { return ruleBool(c.ResplitAces, false) } +func (c BlackjackConfig) AllowsDoubleAfterSplit() bool { return ruleBool(c.DoubleAfterSplit, true) } +func (c BlackjackConfig) CountsDoubleSplitBlackjack() bool { + return ruleBool(c.DoubleSplitBlackjack, true) +} +func (c BlackjackConfig) AllowsSurrender() bool { return ruleBool(c.Surrender, false) } +func (c BlackjackConfig) AllowsUnlikeTenSplit() bool { return ruleBool(c.SplitUnlikeTenValues, true) } + +func (c BlackjackConfig) Normalize() BlackjackConfig { + if c.Decks <= 0 { + c.Decks = 8 + } + if c.ShuffleAt <= 0 || c.ShuffleAt >= 1 { + c.ShuffleAt = 0.25 + } + if c.BlackjackPayout <= 0 { + c.BlackjackPayout = 1.5 + } + if c.MaxSplitHands <= 0 { + c.MaxSplitHands = 4 + } + if c.InsurancePayout <= 0 { + c.InsurancePayout = 2 + } + if c.ActionTicks <= 0 { + c.ActionTicks = 20 + } + return c +} + type TableConfig struct { - ID string `yaml:"id"` - Game GameName `yaml:"game"` - MaxPlayers int `yaml:"max_players,omitempty"` - BettingTicks int `yaml:"betting_ticks,omitempty"` - MinBet int `yaml:"min_bet,omitempty"` - MaxBet int `yaml:"max_bet,omitempty"` + ID string `yaml:"id"` + Game GameName `yaml:"game"` + MaxPlayers int `yaml:"max_players,omitempty"` + BettingTicks int `yaml:"betting_ticks,omitempty"` + MinBet int `yaml:"min_bet,omitempty"` + MaxBet int `yaml:"max_bet,omitempty"` + Blackjack BlackjackConfig `yaml:"blackjack,omitempty"` } type MachineConfig struct { @@ -87,6 +140,24 @@ const ( EventClosed EventType = "closed" ) +type MenuKind string + +const ( + MenuSlotCommands MenuKind = "slot_commands" + MenuBlackjackBet MenuKind = "blackjack_bet" + MenuBlackjackTurn MenuKind = "blackjack_turn" +) + +// MenuView carries a structured menu alongside its plain-text Message so the +// presentation layer can enrich it (balances, command colors) without parsing +// message prefixes. +type MenuView struct { + Kind MenuKind + Body string // command list (MenuSlotCommands) + Title string // prompt heading (MenuBlackjackTurn) + Actions []string // available commands (MenuBlackjackTurn) +} + type Event struct { RoomID int TableID string @@ -98,6 +169,8 @@ type Event struct { Color string Public bool Slot *SlotSnapshot + Blackjack *BlackjackSnapshot + Menu *MenuView } type SlotSnapshot struct { @@ -105,11 +178,24 @@ type SlotSnapshot struct { StoppedReels int } -func (e Event) String() string { - if e.Message != "" { - return e.Message - } - return fmt.Sprintf("%s at %s", e.Type, e.TableID) +type BlackjackCardView struct { + Rank string + Suit string + Hidden bool +} + +type BlackjackPlayerView struct { + Name string + Cards []BlackjackCardView + Score string +} + +type BlackjackSnapshot struct { + Dealer []BlackjackCardView + DealerShownScore string + DealerScore string + DealerRevealed bool + Players []BlackjackPlayerView } type Participant struct { |
