diff options
Diffstat (limited to 'internal/casino')
| -rw-r--r-- | internal/casino/baccarat_test.go | 4 | ||||
| -rw-r--r-- | internal/casino/pai_gow.go | 299 | ||||
| -rw-r--r-- | internal/casino/pai_gow_rules.go | 96 | ||||
| -rw-r--r-- | internal/casino/pai_gow_test.go | 163 | ||||
| -rw-r--r-- | internal/casino/roulette.go | 7 | ||||
| -rw-r--r-- | internal/casino/table.go | 11 | ||||
| -rw-r--r-- | internal/casino/table_ux.go | 8 |
7 files changed, 480 insertions, 108 deletions
diff --git a/internal/casino/baccarat_test.go b/internal/casino/baccarat_test.go index b767c74..c4ec4aa 100644 --- a/internal/casino/baccarat_test.go +++ b/internal/casino/baccarat_test.go @@ -379,8 +379,8 @@ func TestBaccaratMenuAndActions(t *testing.T) { menu = event.Menu } } - if menu == nil || !menu.ShowBalance || !strings.Contains(menu.Heading, "Baccarat / Commission | Limits") { - t.Fatalf("join menu=%+v, want a baccarat betting menu", menu) + if menu == nil || !menu.ShowBalance || menu.Heading != "" { + t.Fatalf("join menu=%+v, want a baccarat betting menu with no game-name heading", menu) } if len(menu.Commands) != 6 || menu.Commands[0].Command != "bet 1 banker" || menu.Commands[2].Command != "stop" || menu.Commands[4].Command != "rules" || menu.Commands[5].Command != "list" { t.Fatalf("baccarat menu commands=%+v", menu.Commands) diff --git a/internal/casino/pai_gow.go b/internal/casino/pai_gow.go index 17d8971..ba3daaa 100644 --- a/internal/casino/pai_gow.go +++ b/internal/casino/pai_gow.go @@ -26,6 +26,7 @@ type pgPlayer struct { cards []card split pgSplit ready, disconnected, paid bool + leaveAfter bool payout int result string } @@ -92,18 +93,27 @@ func (t *paiGowTable) hintsLocked(name string) []MenuCommand { return nil } commands := []MenuCommand{} + faceUp := t.Config.PaiGow.Variant == PaiGowFaceUp switch t.phase { case "waiting", "betting": if p.main.Total == 0 { - commands = append(commands, MenuCommand{Command: "bet <amount>", Desc: "Main wager; opens a timed betting window"}) - } else { + // Side bets are placed before the main wager: the main bet is what + // deals, so there is no later chance to add them. if p.fortune.Total == 0 { - commands = append(commands, MenuCommand{Command: "bet <amount> fortune", Desc: "Optional Fortune side bet before deal"}) + commands = append(commands, MenuCommand{Command: "bet <amount> fortune", Desc: "Optional Fortune side bet; place before your main bet"}) + } else { + commands = append(commands, MenuCommand{Command: "unbet fortune", Desc: "Take back your Fortune side bet"}) } - if t.Config.PaiGow.Variant == PaiGowFaceUp && p.pushace.Total == 0 { - commands = append(commands, MenuCommand{Command: "bet <amount> pushace", Desc: "Optional Push Ace High side bet (max half the main wager)"}) + if faceUp { + if p.pushace.Total == 0 { + commands = append(commands, MenuCommand{Command: "bet <amount> pushace", Desc: "Optional Push Ace High side bet; place before your main bet"}) + } else { + commands = append(commands, MenuCommand{Command: "unbet pushace", Desc: "Take back your Push Ace High side bet"}) + } } - commands = append(commands, MenuCommand{Command: "deal", Desc: "Mark ready; deal when all bettors are ready"}) + commands = append(commands, MenuCommand{Command: "bet <amount>", Desc: "Main wager; deals once every bettor is in"}) + } else { + commands = append(commands, MenuCommand{Command: "unbet main", Desc: "Take back your main wager before the deal"}) } case "setting": if p.main.Total > 0 && !p.ready { @@ -157,14 +167,14 @@ func (t *paiGowTable) statusLocked(name string) []Event { s.Tiebreaker = pgViews(t.tie[:]) } } - e := t.private(name, t.Config.Label()) + e := t.private(name, "") e.Snapshot = s // Context-aware hints are attached as a live menu; the shared board // renderer places them below the felt. Stand-ins (for example a watcher) // get no menu, so they cannot be told how to bet a game they have not joined. if commands := t.hintsLocked(name); len(commands) > 0 { e.Color = "casino_menu" - e.Menu = &MenuView{Heading: t.Config.Label() + " | " + t.phase, ShowBalance: true, Commands: commands} + e.Menu = &MenuView{ShowBalance: true, Commands: commands} } return []Event{e} } @@ -189,11 +199,24 @@ func (t *paiGowTable) join(name string) []Event { func (t *paiGowTable) leave(name string) []Event { t.mu.Lock() defer t.mu.Unlock() - if p := t.player(name); p != nil && p.main.Total > 0 { - return []Event{t.private(name, "Finish your Pai Gow round before leaving; house sets your hand.")} + p := t.player(name) + if p == nil { + return nil } + // Cards in flight must finish; before the deal the wagers are returned. + if t.phase == "setting" || t.phase == "settling" { + return []Event{t.private(name, "You're in the middle of a Pai Gow round!")} + } + if p.wallet != nil && (p.main.Total > 0 || p.fortune.Total > 0 || p.pushace.Total > 0) { + if _, _, err := p.wallet.RefundOutstanding(); err != nil { + return []Event{t.private(name, "Your stake could not be returned just now; it stays pending for the next startup.")} + } + } + p.main, p.fortune, p.pushace = Wager{}, Wager{}, Wager{} t.remove(name) - return []Event{t.private(name, "You leave Pai Gow Poker.")} + events := []Event{t.private(name, "You leave Pai Gow Poker.")} + events = append(events, t.afterSeatChangeLocked()...) + return events } func (t *paiGowTable) rebet(name string) (string, int) { t.mu.Lock() @@ -226,11 +249,14 @@ func (t *paiGowTable) bet(name, spot string, amount int, w Wallet) []Event { if spot == "main" && p.main.Total > 0 || spot == "fortune" && p.fortune.Total > 0 || spot == "pushace" && p.pushace.Total > 0 { return fail("That wager is already committed.") } - if spot != "main" && (p.main.Total == 0 || p.ready) { - return fail("Place a main bet first, then side bets before marking ready with deal.") + // Side bets settle into the round before the main wager, which is what + // actually deals. Once the main bet is down the deal can fire at any + // moment, so a later side bet would race the cards. + if spot != "main" && p.main.Total > 0 { + return fail("Place side bets before your main wager.") } - if spot == "pushace" && amount > p.main.Total/2 { - return fail("Push Ace High is capped at half the main wager.") + if spot == "main" && p.pushace.Total > amount/2 { + return fail("Push Ace High is capped at half the main wager; take it back with unbet pushace first.") } if amount < t.Config.MinBet || amount > t.Config.MaxBet || amount > pgMaxWager { return fail("Wager must be " + betRangeText(t.Config.MinBet, min(t.Config.MaxBet, pgMaxWager)) + ".") @@ -251,20 +277,24 @@ func (t *paiGowTable) bet(name, spot string, amount int, w Wallet) []Event { p.result = "" p.ready = false p.paid = false - p.fortune = Wager{} - p.pushace = Wager{} } else if spot == "pushace" { p.pushace = stake } else { p.fortune = stake } - if t.phase == "waiting" { + if spot == "main" && t.phase == "waiting" { t.phase = "betting" t.ticks = t.Config.BettingTicks t.dealer = nil t.dealerSplit = pgSplit{} } - return append(fail(fmt.Sprintf("Committed %d on %s. Betting closes in %d ticks; Fortune is optional, then deal.", amount, spot, t.ticks)), t.statusLocked(name)...) + events := []Event{t.private(name, fmt.Sprintf("Committed %d on %s.", amount, spot))} + // The main wager is the commitment point: when every active player has + // one, the cards come out immediately rather than waiting on the timer. + if spot == "main" && t.allBetLocked() { + return append(events, t.dealLocked()...) + } + return append(events, t.statusLocked(name)...) } func (t *paiGowTable) allReady() bool { found := false @@ -278,6 +308,34 @@ func (t *paiGowTable) allReady() bool { } return found } + +// allBetLocked reports whether at least one active player has a main wager +// and every active (connected) player has one. This is the point at which the +// cards can be dealt without waiting for the betting timer. +func (t *paiGowTable) allBetLocked() bool { + found := false + for _, p := range t.players { + if p.disconnected { + continue + } + found = true + if p.main.Total == 0 { + return false + } + } + return found +} + +func (t *paiGowTable) anyMainLocked() bool { + for _, p := range t.players { + if p.main.Total > 0 { + return true + } + } + return false +} + +func (t *paiGowTable) a2345High() bool { return t.Config.PaiGow.A2345High() } func (t *paiGowTable) dealLocked() []Event { deck := newShoe(1, 0, t.rng) deck.cards = append(deck.cards, card{"JK", "joker"}) @@ -291,13 +349,13 @@ func (t *paiGowTable) dealLocked() []Event { } } t.dealer = packets[6] - t.dealerSplit = pgHouse(t.dealer) + t.dealerSplit = pgHouseRules(t.dealer, t.a2345High()) t.tie = [2]card{deck.draw(), deck.draw()} t.phase = "setting" t.ticks = t.Config.PaiGow.ActionTicks // Face Up ace-high settles immediately: there is no decision to make. // Still assign every player's seven cards for independent Fortune/Envy. - autoPush := t.Config.PaiGow.Variant == PaiGowFaceUp && pgDealerPush(t.dealer, PaiGowFaceUp) != "" + autoPush := t.Config.PaiGow.Variant == PaiGowFaceUp && pgDealerPushRules(t.dealer, PaiGowFaceUp, t.a2345High()) != "" var events []Event for i, p := range t.players { if p.main.Total == 0 { @@ -306,7 +364,7 @@ func (t *paiGowTable) dealLocked() []Event { p.cards = packets[i] p.ready = p.disconnected || autoPush if p.ready { - p.split = pgHouse(p.cards) + p.split = pgHouseRules(p.cards, t.a2345High()) } if !autoPush { events = append(events, t.statusLocked(p.name)...) @@ -331,12 +389,24 @@ func (t *paiGowTable) action(name, action string) []Event { if t.phase == "settling" && action == "settle" { return t.payLocked() } - if action == "deal" && t.phase == "betting" && p.main.Total > 0 { - p.ready = true - if t.allReady() { + if action == "leave_after" { + if t.phase == "setting" || t.phase == "settling" { + p.leaveAfter = true + return []Event{t.private(name, "You'll leave after this round. Your current hand still needs your actions.")} + } + return []Event{t.private(name, "You're not in a round.")} + } + if action == "sitout" { + return []Event{t.private(name, "Pai Gow has no sit-out; use stop to leave after the round.")} + } + if strings.HasPrefix(action, "unbet") { + return t.unbetLocked(p, action) + } + if action == "deal" && (t.phase == "waiting" || t.phase == "betting") && p.main.Total > 0 { + if t.allBetLocked() { return t.dealLocked() } - return []Event{t.private(name, "Ready. Waiting for other bettors or the betting timer.")} + return []Event{t.private(name, "Waiting for the other bettors; the window closes on the timer.")} } if t.phase != "setting" || p.main.Total == 0 { return []Event{t.private(name, "Place your bets, then deal; set cards only after they are dealt.")} @@ -346,7 +416,7 @@ func (t *paiGowTable) action(name, action string) []Event { } var split pgSplit if action == "house" || action == "house way" || action == "set house" { - split = pgHouse(p.cards) + split = pgHouseRules(p.cards, t.a2345High()) } else { digits, ok := ParsePaiGowDigits(strings.TrimPrefix(action, "set ")) if !ok { @@ -354,7 +424,7 @@ func (t *paiGowTable) action(name, action string) []Event { } split = pgSet(p.cards, digits) } - if !split.valid() { + if !split.validRules(t.a2345High()) { return []Event{t.private(name, "FOUL HAND: your five-card high hand must be higher than your two-card low hand. Nothing was set; choose again or use house.")} } p.split = split @@ -370,12 +440,12 @@ func (t *paiGowTable) prepareSettlementLocked() []Event { if p.main.Total == 0 { continue } - result := pgMainResult(p.split, t.dealerSplit, t.dealer, t.Config.PaiGow.Variant, t.tie) + result := pgMainResultRules(p.split, t.dealerSplit, t.dealer, t.Config.PaiGow.Variant, t.tie, t.a2345High()) p.payout = pgMainPay(p.main.Total, result, t.Config.PaiGow.Variant) p.result = "Main: " + result if t.Config.PaiGow.Variant == PaiGowFaceUp { rates, _ := t.Config.PaiGow.pushAceRates() - if class := pgPushAceClass(t.dealer, p.cards, rates); class.klass != "none" { + if class := pgPushAceClassRules(t.dealer, p.cards, rates, t.a2345High()); class.klass != "none" { p.payout = p.pushace.Total * (class.rate + 1) p.result += fmt.Sprintf("; Push Ace High: %s (%d:1)", class.desc, class.rate) } else if p.pushace.Total > 0 { @@ -383,7 +453,7 @@ func (t *paiGowTable) prepareSettlementLocked() []Event { } } if p.fortune.Total > 0 { - row, win := pgFortune(p.cards, t.Config.PaiGow.Envy) + row, win := pgFortuneRules(p.cards, t.Config.PaiGow.Envy, t.a2345High()) p.result += "; Fortune: " + row.Hand if win { p.payout += p.fortune.Total * (row.Odds + 1) @@ -392,7 +462,7 @@ func (t *paiGowTable) prepareSettlementLocked() []Event { envy := 0 for _, other := range t.players { if other != p && other.main.Total > 0 { - row, _ := pgFortune(other.cards, true) + row, _ := pgFortuneRules(other.cards, true, t.a2345High()) envy += row.Envy } } @@ -440,8 +510,9 @@ func (t *paiGowTable) payLocked() []Event { for _, p := range t.players { p.main = Wager{} p.fortune = Wager{} + p.pushace = Wager{} p.ready = false - if !p.disconnected { + if !p.disconnected && !p.leaveAfter { kept = append(kept, p) } } @@ -457,17 +528,23 @@ func (t *paiGowTable) tick() []Event { switch t.phase { case "settling": return t.payLocked() - case "betting", "setting": + case "betting": + if t.allBetLocked() { + return t.dealLocked() + } t.ticks-- if t.ticks > 0 { return nil } - if t.phase == "betting" { - return t.dealLocked() + return t.finishBettingLocked() + case "setting": + t.ticks-- + if t.ticks > 0 { + return nil } for _, p := range t.players { if p.main.Total > 0 && !p.ready { - p.split = pgHouse(p.cards) + p.split = pgHouseRules(p.cards, t.a2345High()) p.ready = true } } @@ -482,15 +559,23 @@ func (t *paiGowTable) markDisconnected(name string) []Event { if p == nil { return nil } - if p.main.Total == 0 { + // Before the deal nothing is revealed, so a disconnect returns the + // wagers and frees the seat; the remaining bettors may then be dealt. + if t.phase == "waiting" || t.phase == "betting" { + if p.wallet != nil { + // Nothing has been dealt, so the stake is refunded; a failed refund + // leaves it pending for startup recovery, but the seat is still freed. + _, _, _ = p.wallet.RefundOutstanding() + } + p.main, p.fortune, p.pushace = Wager{}, Wager{}, Wager{} t.remove(name) - return nil + return t.afterSeatChangeLocked() } p.disconnected = true p.ready = true if t.phase == "setting" { if p.split.high == nil { - p.split = pgHouse(p.cards) + p.split = pgHouseRules(p.cards, t.a2345High()) } if t.allReady() { return t.prepareSettlementLocked() @@ -498,12 +583,144 @@ func (t *paiGowTable) markDisconnected(name string) []Event { } return nil } + +// afterSeatChangeLocked re-evaluates the betting window after a seat leaves +// or disconnects: the cards deal when every remaining active player has bet, +// and a table with no main wager returns to waiting. +func (t *paiGowTable) afterSeatChangeLocked() []Event { + if t.phase != "betting" { + return nil + } + if t.allBetLocked() { + return t.dealLocked() + } + if !t.anyMainLocked() { + return t.finishBettingLocked() + } + return nil +} + +// finishBettingLocked closes the betting window on the timer: side bets on +// seats that never placed a main wager are returned, and the round deals when +// at least one main wager is live. With no main wager the table resets. +func (t *paiGowTable) finishBettingLocked() []Event { + var events []Event + for _, p := range t.players { + if p.main.Total > 0 { + continue + } + events = append(events, t.refundSideBetsLocked(p)...) + } + if !t.anyMainLocked() { + t.resetTableLocked() + return events + } + return append(events, t.dealLocked()...) +} + +func (t *paiGowTable) refundSideBetsLocked(p *pgPlayer) []Event { + total := p.fortune.Total + p.pushace.Total + if total == 0 { + return nil + } + var ids []string + if p.fortune.Total > 0 { + ids = append(ids, p.fortune.ID) + } + if p.pushace.Total > 0 { + ids = append(ids, p.pushace.ID) + } + e := t.private(p.name, fmt.Sprintf("Your %d in side bets is returned; no main wager was placed.", total)) + if p.wallet != nil { + if err := p.wallet.PayCredits(total, ids...); err != nil { + e.Message = payoutFailedMessage() + } + } + p.fortune, p.pushace = Wager{}, Wager{} + return []Event{e} +} + +// resetTableLocked clears a round that closed without a main wager and +// returns the table to waiting. Callers have already refunded any side bets. +func (t *paiGowTable) resetTableLocked() { + t.phase = "waiting" + t.ticks = 0 + t.dealer = nil + t.dealerSplit = pgSplit{} + t.tie = [2]card{} + for _, p := range t.players { + p.main, p.fortune, p.pushace = Wager{}, Wager{}, Wager{} + p.cards, p.split, p.ready, p.paid, p.result = nil, pgSplit{}, false, false, "" + } +} + +// unbetLocked takes back one or all of a player's wagers before the deal. +// Refunds go through PayCredits with the original stake handle so the ledger +// entry resolves in the same save; wagers are only cleared on success. +func (t *paiGowTable) unbetLocked(p *pgPlayer, action string) []Event { + target := strings.TrimSpace(strings.TrimPrefix(action, "unbet")) + if target == "" { + target = "all" + } + if target != "all" && target != "main" && target != "fortune" && target != "pushace" { + return []Event{t.private(p.name, "Unbet what? Use unbet main, unbet fortune, unbet pushace, or unbet all.")} + } + if t.phase != "waiting" && t.phase != "betting" { + return []Event{t.private(p.name, "You can only take back bets before the cards are dealt.")} + } + total, ids := 0, []string{} + take := func(w *Wager) { + if w.Total == 0 { + return + } + total += w.Total + ids = append(ids, w.ID) + } + switch target { + case "main": + take(&p.main) + case "fortune": + take(&p.fortune) + case "pushace": + take(&p.pushace) + default: + take(&p.main) + take(&p.fortune) + take(&p.pushace) + } + if total == 0 { + return []Event{t.private(p.name, "Nothing to take back.")} + } + if p.wallet != nil { + if err := p.wallet.PayCredits(total, ids...); err != nil { + return []Event{t.private(p.name, payoutFailedMessage())} + } + } + switch target { + case "main": + p.main = Wager{} + case "fortune": + p.fortune = Wager{} + case "pushace": + p.pushace = Wager{} + default: + p.main, p.fortune, p.pushace = Wager{}, Wager{}, Wager{} + } + if !t.anyMainLocked() && t.phase == "betting" { + t.phase = "waiting" + t.ticks = 0 + } + return append([]Event{t.private(p.name, fmt.Sprintf("You take back %d credits.", total))}, t.statusLocked(p.name)...) +} func (t *paiGowTable) refundAll() []Event { t.mu.Lock() defer t.mu.Unlock() var events []Event for _, p := range t.players { - if p.wallet == nil || p.main.Total == 0 || p.paid { + if p.wallet == nil || p.paid { + continue + } + if p.main.Total == 0 && p.fortune.Total == 0 && p.pushace.Total == 0 { continue } if _, _, err := p.wallet.RefundOutstanding(); err != nil { diff --git a/internal/casino/pai_gow_rules.go b/internal/casino/pai_gow_rules.go index 8a9842c..ced7c47 100644 --- a/internal/casino/pai_gow_rules.go +++ b/internal/casino/pai_gow_rules.go @@ -22,8 +22,14 @@ type PaiGowConfig struct { Envy bool `yaml:"envy,omitempty"` PushAceHigh string `yaml:"push_ace_high,omitempty"` // Face Up only: 5_15_40 or 5_12_40 ActionTicks int `yaml:"action_ticks,omitempty"` + // A2345SecondHighest makes the wheel the second-highest straight, below + // Broadway and above a six-high straight. It defaults to true; only an + // explicit false demotes A2345 to the lowest straight. + A2345SecondHighest *bool `yaml:"a2345_second_highest,omitempty"` } +func (c PaiGowConfig) A2345High() bool { return ruleBool(c.A2345SecondHighest, true) } + func (c PaiGowConfig) Normalize() PaiGowConfig { if c.Variant == "" { c.Variant = PaiGowClassic @@ -85,7 +91,11 @@ func (c PaiGowConfig) Label() string { } func paiGowRules(c TableConfig) string { r := c.PaiGow.Normalize() - s := r.Label() + "\nSeven cards from a fresh 53-card deck. Joker is an ace unless completing a straight, flush or straight flush. A2345 is the second-highest straight.\nSet two cards low and five high: 24 chooses low cards 2/4; 13456 chooses the high hand. High must strictly outrank low. Copies go to the dealer.\nBoth hands win: win; both lose: loss; one wins: push." + wheel := "A2345 is the second-highest straight." + if !r.A2345High() { + wheel = "A2345 is the lowest straight." + } + s := r.Label() + "\nSeven cards from a fresh 53-card deck. Joker is an ace unless completing a straight, flush or straight flush. " + wheel + "\nSet two cards low and five high: 24 chooses low cards 2/4; 13456 chooses the high hand. High must strictly outrank low. Copies go to the dealer.\nBoth hands win: win; both lose: loss; one wins: push." switch r.Variant { case PaiGowClassic: s += " Wins pay 0.95:1 (profit rounded down, minimum one credit)." @@ -138,7 +148,14 @@ func pgCompare(a, b pgRank) int { } return 0 } -func pgNatural(cards []card) pgRank { +// pgNatural keeps the default-rules entry point used by tests and callers that +// do not thread a table configuration. Production evaluation goes through +// pgNaturalRules. +// +//nolint:unused // default-rules entry point +func pgNatural(cards []card) pgRank { return pgNaturalRules(cards, true) } + +func pgNaturalRules(cards []card, a2345High bool) pgRank { counts := [15]int{} flush := len(cards) == 5 for _, c := range cards { @@ -162,7 +179,11 @@ func pgNatural(cards []card) pgRank { straight = sorted[4] } if sorted[0] == 2 && sorted[1] == 3 && sorted[2] == 4 && sorted[3] == 5 && sorted[4] == 14 { - straight = 15 + if a2345High { + straight = 15 + } else { + straight = 5 + } } if straight == 14 { straight = 16 @@ -196,7 +217,9 @@ func pgNatural(cards []card) pgRank { copy(out[1:], ranks) return out } -func pgEvaluate(cards []card) pgRank { +func pgEvaluate(cards []card) pgRank { return pgEvaluateRules(cards, true) } + +func pgEvaluateRules(cards []card, a2345High bool) pgRank { j := -1 for i, c := range cards { if c.rank == "JK" { @@ -205,11 +228,11 @@ func pgEvaluate(cards []card) pgRank { } } if j < 0 { - return pgNatural(cards) + return pgNaturalRules(cards, a2345High) } copyCards := append([]card(nil), cards...) copyCards[j] = card{"A", "joker"} - best := pgNatural(copyCards) + best := pgNaturalRules(copyCards, a2345High) if len(cards) != 5 { return best } @@ -226,7 +249,7 @@ func pgEvaluate(cards []card) pgRank { continue } copyCards[j] = replacement - v := pgNatural(copyCards) + v := pgNaturalRules(copyCards, a2345High) if (v[0] == 4 || v[0] == 5 || v[0] == 8) && pgCompare(v, best) > 0 { best = v } @@ -252,28 +275,32 @@ func pgSet(cards []card, digits []int) pgSplit { } return s } -func (s pgSplit) valid() bool { - return len(s.high) == 5 && len(s.low) == 2 && pgCompare(pgEvaluate(s.high), pgEvaluate(s.low)) > 0 +func (s pgSplit) valid() bool { return s.validRules(true) } + +func (s pgSplit) validRules(a2345High bool) bool { + return len(s.high) == 5 && len(s.low) == 2 && pgCompare(pgEvaluateRules(s.high, a2345High), pgEvaluateRules(s.low, a2345High)) > 0 } -func pgHouse(cards []card) pgSplit { +func pgHouse(cards []card) pgSplit { return pgHouseRules(cards, true) } + +func pgHouseRules(cards []card, a2345High bool) pgSplit { var options []pgSplit best := pgRank{} for i := 0; i < 7; i++ { for j := i + 1; j < 7; j++ { s := pgSet(cards, []int{i, j}) - if !s.valid() { + if !s.validRules(a2345High) { continue } options = append(options, s) - if pgCompare(pgEvaluate(s.high), best) > 0 { - best = pgEvaluate(s.high) + if pgCompare(pgEvaluateRules(s.high, a2345High), best) > 0 { + best = pgEvaluateRules(s.high, a2345High) } } } var chosen pgSplit var low, high pgRank for _, s := range options { - h, l := pgEvaluate(s.high), pgEvaluate(s.low) + h, l := pgEvaluateRules(s.high, a2345High), pgEvaluateRules(s.low, a2345High) eligible := h[0] == best[0] // Split two pair/full house only for a paired front with at least a pair behind. if (best[0] == 2 || best[0] == 6) && l[0] == 1 && h[0] >= 1 { @@ -340,7 +367,9 @@ func pgSevenFlush(cards []card) bool { } return false } -func pgFortune(cards []card, envy bool) (FortuneRow, bool) { +func pgFortune(cards []card, envy bool) (FortuneRow, bool) { return pgFortuneRules(cards, envy, true) } + +func pgFortuneRules(cards []card, envy, a2345High bool) (FortuneRow, bool) { rows := FortunePayTable(envy) joker := false counts := [15]int{} @@ -361,7 +390,7 @@ func pgFortune(cards []card, envy bool) (FortuneRow, bool) { for i := 0; i < 7; i++ { for j := i + 1; j < 7; j++ { s := pgSet(cards, []int{i, j}) - v := pgEvaluate(s.high) + v := pgEvaluateRules(s.high, a2345High) index := len(rows) switch v[0] { case 9: @@ -408,11 +437,18 @@ func pgFortune(cards []card, envy bool) (FortuneRow, bool) { } return rows[bestIndex], true } -func pgBest(cards []card) pgRank { +// pgBest keeps the default-rules entry point used by tests and callers that do +// not thread a table configuration. Production evaluation goes through +// pgBestRules. +// +//nolint:unused // default-rules entry point +func pgBest(cards []card) pgRank { return pgBestRules(cards, true) } + +func pgBestRules(cards []card, a2345High bool) pgRank { var best pgRank for i := 0; i < 7; i++ { for j := i + 1; j < 7; j++ { - v := pgEvaluate(pgSet(cards, []int{i, j}).high) + v := pgEvaluateRules(pgSet(cards, []int{i, j}).high, a2345High) if pgCompare(v, best) > 0 { best = v } @@ -424,10 +460,14 @@ func pgBest(cards []card) pgRank { // Automatic pushes use the best of all seven dealer cards, not just the // chosen high hand. Pair/straight/flush hands headed by an ace do not qualify. func pgDealerPush(cards []card, variant string) string { + return pgDealerPushRules(cards, variant, true) +} + +func pgDealerPushRules(cards []card, variant string, a2345High bool) string { if variant != PaiGowEZ && variant != PaiGowFaceUp { return "" } - v := pgBest(cards) + v := pgBestRules(cards, a2345High) if v[0] == 0 { if variant == PaiGowEZ && v[1] == 12 { return "push (dealer queen-high)" @@ -439,11 +479,15 @@ func pgDealerPush(cards []card, variant string) string { return "" } func pgMainResult(player, dealer pgSplit, dealerCards []card, variant string, tie [2]card) string { - if push := pgDealerPush(dealerCards, variant); push != "" { + return pgMainResultRules(player, dealer, dealerCards, variant, tie, true) +} + +func pgMainResultRules(player, dealer pgSplit, dealerCards []card, variant string, tie [2]card, a2345High bool) string { + if push := pgDealerPushRules(dealerCards, variant, a2345High); push != "" { return push } - high := pgCompare(pgEvaluate(player.high), pgEvaluate(dealer.high)) > 0 - low := pgCompare(pgEvaluate(player.low), pgEvaluate(dealer.low)) > 0 + high := pgCompare(pgEvaluateRules(player.high, a2345High), pgEvaluateRules(dealer.high, a2345High)) > 0 + low := pgCompare(pgEvaluateRules(player.low, a2345High), pgEvaluateRules(dealer.low, a2345High)) > 0 if high && low { return "win" } @@ -475,7 +519,11 @@ type pgPushAce struct { } func pgPushAceClass(dealer, player []card, rates [3]int) pgPushAce { - if pgBest(dealer)[0] != 0 || pgBest(dealer)[1] != 14 { + return pgPushAceClassRules(dealer, player, rates, true) +} + +func pgPushAceClassRules(dealer, player []card, rates [3]int, a2345High bool) pgPushAce { + if pgBestRules(dealer, a2345High)[0] != 0 || pgBestRules(dealer, a2345High)[1] != 14 { return pgPushAce{klass: "none"} } for _, c := range dealer { @@ -483,7 +531,7 @@ func pgPushAceClass(dealer, player []card, rates [3]int) pgPushAce { return pgPushAce{klass: "joker", rate: rates[1], desc: "dealer ace-high with Joker"} } } - if pgBest(player)[0] == 0 && pgBest(player)[1] == 14 { + if pgBestRules(player, a2345High)[0] == 0 && pgBestRules(player, a2345High)[1] == 14 { return pgPushAce{klass: "both", rate: rates[2], desc: "both ace-high"} } return pgPushAce{klass: "plain", rate: rates[0], desc: "dealer ace-high without Joker"} diff --git a/internal/casino/pai_gow_test.go b/internal/casino/pai_gow_test.go index f258b04..02ea187 100644 --- a/internal/casino/pai_gow_test.go +++ b/internal/casino/pai_gow_test.go @@ -181,17 +181,19 @@ func TestPaiGowLifecyclePrivacyAndUniqueDeck(t *testing.T) { wallets := map[string]*testWallet{} for _, name := range []string{"a", "b", "c", "d", "e", "f"} { tab.join(name) - w := &testWallet{chips: 100} - wallets[name] = w - tab.bet(name, "main", 10, w) + wallets[name] = &testWallet{chips: 100} + } + var events []Event + for _, name := range []string{"a", "b", "c", "d", "e", "f"} { + w := wallets[name] + // Side bets settle before the main wager; the last main bet deals. tab.bet(name, "fortune", 5, w) + events = tab.bet(name, "main", 10, w) } tab.join("overflow") if tab.hasParticipant("overflow") { t.Fatal("seventh seat") } - tab.tick() - events := tab.tick() if tab.phase != "setting" { t.Fatal(tab.phase) } @@ -214,6 +216,9 @@ func TestPaiGowLifecyclePrivacyAndUniqueDeck(t *testing.T) { t.Fatal(len(seen)) } for _, e := range events { + if e.Snapshot == nil { + continue + } if e.Public || e.PrivateTo == "" { t.Fatal("private cards leaked") } @@ -265,11 +270,10 @@ func TestPaiGowAtomicRetryAndEnvy(t *testing.T) { b := &pgFailWallet{testWallet: testWallet{chips: 100}} tab.join("a") tab.join("b") - tab.bet("a", "main", 20, a) tab.bet("a", "fortune", 5, a) + tab.bet("a", "main", 20, a) + // b's main wager completes the table and deals immediately. tab.bet("b", "main", 20, b) - tab.action("a", "deal") - tab.action("b", "deal") // Script the dealt cards before either player sets. a loses Fortune but // collects b's fixed quad Envy, even though b didn't bet Fortune. tab.player("a").cards = pgCards("2s 4h 6c 8d Js Qh Ac") @@ -303,8 +307,8 @@ func TestPaiGowRejectedFoulDoesNotLock(t *testing.T) { tab := pgTestTable(false) w := &testWallet{chips: 100} tab.join("a") + // A lone main wager deals immediately, so the cards are already out. tab.bet("a", "", 10, w) - tab.action("a", "deal") p := tab.player("a") p.cards = pgCards("As Ah 2c 3d 4s 7h 9c") events := tab.action("a", "12") @@ -326,16 +330,19 @@ func TestPaiGowEnvyEligibilityAndMultipleAwards(t *testing.T) { stake, envy int }{{true, 5, 25}, {true, 4, 0}, {false, 5, 0}} { tab := pgTestTable(tt.enabled) + wallets := map[string]*testWallet{} for _, name := range []string{"a", "b", "c"} { tab.join(name) - w := &testWallet{chips: 100} - tab.bet(name, "main", 20, w) + wallets[name] = &testWallet{chips: 100} + } + for _, name := range []string{"a", "b", "c"} { + w := wallets[name] if name == "a" { tab.bet(name, "fortune", tt.stake, w) } + tab.bet(name, "main", 20, w) } - tab.tick() - tab.tick() + // The last main wager deals; script the hands before setting. // Own royal and dealer seven-card straight flush must NOT earn Envy. tab.player("a").cards = pgCards("10s Js Qs Ks As 2h 3d") tab.player("b").cards = pgCards("Ks Kh Kc Kd 2h 3c 7h") @@ -401,16 +408,17 @@ func TestPaiGowFaceUpAndPushAceHigh(t *testing.T) { w := &testWallet{chips: 500} tab.join("a") tab.bet("a", "pushace", 10, w) - if len(w.open) > 0 { - t.Fatal("pushace before main") + if len(w.open) != 1 { + t.Fatal("pushace before main should be accepted") } - tab.bet("a", "main", 20, w) - if events := tab.bet("a", "pushace", 11, w); len(w.open) != 1 || !strings.Contains(events[0].Message, "half the main wager") { + tab.bet("a", "fortune", 5, w) + // The cap is checked when the main wager lands. + if events := tab.bet("a", "main", 10, w); len(events) == 0 || !strings.Contains(events[0].Message, "half the main wager") { t.Fatal("pushace cap not enforced", events, w.open) } - tab.bet("a", "pushace", 10, w) - tab.bet("a", "fortune", 5, w) - tab.action("a", "deal") + tab.bet("a", "main", 20, w) + pushaceStake := tab.player("a").pushace.Total + // A lone main wager deals immediately; the dealer is revealed in setting. snap := tab.status("a")[0].Snapshot.(*PaiGowSnapshot) // The dealer acts first and the reveal happens in the setting phase. if len(snap.DealerHigh) != 5 || len(snap.DealerLow) != 2 { @@ -432,7 +440,7 @@ func TestPaiGowFaceUpAndPushAceHigh(t *testing.T) { expect += 5 * (row.Odds + 1) // the Fortune stake, before settlement resets it } if class := pgPushAceClass(tab.dealer, p.cards, [3]int{5, 15, 40}); class.klass != "none" { - expect += p.pushace.Total * (class.rate + 1) + expect += pushaceStake * (class.rate + 1) } if p.payout != expect { t.Fatalf("payout %d want %d (dealer %v, result %s)", p.payout, expect, tab.dealer, p.result) @@ -452,13 +460,120 @@ func TestPaiGowConfigurationAndRefund(t *testing.T) { w := &testWallet{chips: 100} tab.join("a") tab.bet("a", "fortune", 5, w) - if len(w.open) > 0 { - t.Fatal("side before main") + if len(w.open) != 1 { + t.Fatal("side before main should be accepted") } tab.bet("a", "main", 10, w) - tab.bet("a", "fortune", 5, w) tab.refundAll() if w.chips != 100 || len(w.open) > 0 { t.Fatal("refund failed") } } + +func TestPaiGowA2345Option(t *testing.T) { + if !(PaiGowConfig{}).A2345High() { + t.Fatal("A2345 must default to the second-highest straight") + } + wheel := pgCards("As 2h 3c 4d 5s") + if got := pgEvaluate(wheel); got != (pgRank{4, 15}) { + t.Fatalf("default wheel rank %v", got) + } + if got := pgEvaluateRules(wheel, false); got != (pgRank{4, 5}) { + t.Fatalf("demoted wheel rank %v", got) + } + six := pgCards("2h 3c 4d 5s 6h") + if pgCompare(pgEvaluateRules(wheel, false), pgEvaluateRules(six, false)) >= 0 { + t.Fatal("a demoted wheel must be the lowest straight") + } + jokerWheel := pgCards("Ah 2c 3d 4s JK") + if got := pgEvaluateRules(jokerWheel, false); got[0] != 4 || got[1] != 5 { + t.Fatalf("demoted Joker wheel rank %v", got) + } +} + +func TestPaiGowSoloMainDealsAfterSideBets(t *testing.T) { + tab := pgTestTable(false) + w := &testWallet{chips: 100} + tab.join("a") + // Side bets come first; they are accepted before any main wager. + tab.bet("a", "fortune", 5, w) + if len(w.open) != 1 { + t.Fatalf("fortune before main rejected: %v", w.open) + } + if tab.phase != "waiting" { + t.Fatalf("a side bet alone opened the betting window: %s", tab.phase) + } + // The main wager deals a lone table immediately. + tab.bet("a", "main", 10, w) + if tab.phase != "setting" { + t.Fatalf("solo main did not deal: %s", tab.phase) + } + if events := tab.bet("a", "fortune", 5, w); len(events) == 0 || !hasMessage(events, "Betting is closed") { + t.Fatalf("bet accepted after the deal: %+v", events) + } +} + +func TestPaiGowWindowDealsWhenEveryBettorIsIn(t *testing.T) { + tab := pgTestTable(false) + wa, wb := &testWallet{chips: 100}, &testWallet{chips: 100} + tab.join("a") + tab.join("b") + tab.bet("a", "main", 10, wa) + if tab.phase != "betting" { + t.Fatalf("window did not open for a second seat: %s", tab.phase) + } + tab.bet("b", "main", 10, wb) + if tab.phase != "setting" { + t.Fatalf("all-bet table did not deal: %s", tab.phase) + } +} + +func TestPaiGowUnbetAndStopRefundBeforeDeal(t *testing.T) { + tab := pgTestTable(false) + wa := &testWallet{chips: 100} + tab.join("a") + tab.join("b") + tab.bet("a", "main", 10, wa) + if events := tab.action("a", "unbet main"); !hasMessage(events, "take back") { + t.Fatalf("unbet main failed: %+v", events) + } + if wa.chips+wa.paid != 100 || len(wa.open) != 0 || tab.phase != "waiting" || tab.player("a").main.Total != 0 { + t.Fatalf("unbet main left state: chips=%d paid=%d phase=%s", wa.chips, wa.paid, tab.phase) + } + // A side bet placed and then abandoned is returned when the seat leaves. + tab.bet("a", "fortune", 5, wa) + if wa.chips+wa.paid != 95 { + t.Fatalf("fortune not charged: chips=%d paid=%d", wa.chips, wa.paid) + } + if events := tab.leave("a"); !hasMessage(events, "You leave Pai Gow Poker.") { + t.Fatalf("leave during betting: %+v", events) + } + if wa.chips+wa.paid != 100 || len(wa.open) != 0 || tab.hasParticipant("a") { + t.Fatalf("stop did not refund/remove: chips=%d paid=%d seated=%v", wa.chips, wa.paid, tab.hasParticipant("a")) + } +} + +func TestPaiGowPushAceCapAtMainWager(t *testing.T) { + cfg := TableConfig{ID: "pg", Game: GamePaiGow, PaiGow: PaiGowConfig{Variant: PaiGowFaceUp, PushAceHigh: "5_15_40"}}.Normalize() + tab := newPaiGowTable(1, cfg, rand.New(rand.NewSource(9))).(*paiGowTable) + w := &testWallet{chips: 100} + tab.join("a") + tab.bet("a", "pushace", 10, w) + if events := tab.bet("a", "main", 10, w); len(events) == 0 || !hasMessage(events, "half the main wager") { + t.Fatalf("pushace cap not checked when the main lands: %+v", events) + } + if w.chips != 90 || tab.player("a").main.Total != 0 { + t.Fatalf("rejected main changed state: chips=%d main=%d", w.chips, tab.player("a").main.Total) + } +} + +func TestPaiGowRefundAllReturnsSideBetsWithoutMain(t *testing.T) { + tab := pgTestTable(false) + w := &testWallet{chips: 100} + tab.join("a") + tab.bet("a", "fortune", 5, w) + tab.refundAll() + if w.chips != 100 || len(w.open) != 0 { + t.Fatalf("side-only refund failed: chips=%d open=%d", w.chips, len(w.open)) + } +} diff --git a/internal/casino/roulette.go b/internal/casino/roulette.go index 1a9f936..761af47 100644 --- a/internal/casino/roulette.go +++ b/internal/casino/roulette.go @@ -708,14 +708,7 @@ func (t *rouletteTable) rebet(name string) (string, int) { func (t *rouletteTable) status(name string) []Event { t.mu.Lock() defer t.mu.Unlock() - message := t.overviewLocked() - if text := t.seatStatusLocked(name); text != "" { - message += "\n" + text - } e := t.menuEvent(name) - e.Message = message - e.Menu.Heading = message - e.Menu.ShowBalance = false events := []Event{e} if t.phase == phaseWaiting && t.lastResult != nil { last := t.private(name, "Last spin:") diff --git a/internal/casino/table.go b/internal/casino/table.go index 88ffc62..b1beab9 100644 --- a/internal/casino/table.go +++ b/internal/casino/table.go @@ -1,7 +1,6 @@ package casino import ( - "fmt" "math/rand" ) @@ -61,13 +60,13 @@ func (b *tableBase) playerAction(name, publicMessage, privateMessage string) Eve return e } -// betMenuEvent builds the shared "place your bet" menu heading for a table -// game; only the command list differs per game. +// betMenuEvent builds the shared "place your bet" menu event for a table +// game; only the command list differs per game. The felt already names the +// game and its limits, so the menu carries just the balance and commands. func (b *tableBase) betMenuEvent(name string, commands []MenuCommand) Event { - heading := fmt.Sprintf("%s | Limits %s", b.Config.Label(), betRangeText(b.Config.MinBet, b.Config.MaxBet)) - e := b.private(name, heading) + e := b.private(name, "") e.Color = "casino_menu" - e.Menu = &MenuView{Heading: heading, ShowBalance: true, Commands: commands} + e.Menu = &MenuView{ShowBalance: true, Commands: commands} return e } diff --git a/internal/casino/table_ux.go b/internal/casino/table_ux.go index d0211f3..e255825 100644 --- a/internal/casino/table_ux.go +++ b/internal/casino/table_ux.go @@ -191,9 +191,9 @@ func (c *tableCore[P]) seatStatusLocked(name string) string { func (t *blackjackTable) status(name string) []Event { t.mu.Lock() defer t.mu.Unlock() - e := t.private(name, t.overviewLocked()) + e := t.private(name, "") if text := t.seatStatusLocked(name); text != "" { - e.Message += "\n" + text + e.Message = text } events := []Event{e} switch t.phase { @@ -220,9 +220,9 @@ func (t *blackjackTable) status(name string) []Event { func (t *baccaratTable) status(name string) []Event { t.mu.Lock() defer t.mu.Unlock() - e := t.private(name, t.overviewLocked()) + e := t.private(name, "") if text := t.seatStatusLocked(name); text != "" { - e.Message += "\n" + text + e.Message = text } events := []Event{e} if p := t.players.get(name); p != nil && !p.HasBet { |
