aboutsummaryrefslogtreecommitdiff
path: root/internal/casino
diff options
context:
space:
mode:
Diffstat (limited to 'internal/casino')
-rw-r--r--internal/casino/baccarat_test.go4
-rw-r--r--internal/casino/pai_gow.go299
-rw-r--r--internal/casino/pai_gow_rules.go96
-rw-r--r--internal/casino/pai_gow_test.go163
-rw-r--r--internal/casino/roulette.go7
-rw-r--r--internal/casino/table.go11
-rw-r--r--internal/casino/table_ux.go8
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 {