aboutsummaryrefslogtreecommitdiff
path: root/internal/casino
diff options
context:
space:
mode:
Diffstat (limited to 'internal/casino')
-rw-r--r--internal/casino/betting.go60
-rw-r--r--internal/casino/blackjack.go773
-rw-r--r--internal/casino/blackjack_test.go233
-rw-r--r--internal/casino/machine.go54
-rw-r--r--internal/casino/machine_test.go44
-rw-r--r--internal/casino/manager.go14
-rw-r--r--internal/casino/manager_test.go19
-rw-r--r--internal/casino/slots.go2
-rw-r--r--internal/casino/slots_test.go15
-rw-r--r--internal/casino/table.go115
-rw-r--r--internal/casino/types.go114
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 {