aboutsummaryrefslogtreecommitdiff
path: root/internal/casino/pai_gow.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/casino/pai_gow.go')
-rw-r--r--internal/casino/pai_gow.go141
1 files changed, 83 insertions, 58 deletions
diff --git a/internal/casino/pai_gow.go b/internal/casino/pai_gow.go
index b853829..44893c1 100644
--- a/internal/casino/pai_gow.go
+++ b/internal/casino/pai_gow.go
@@ -3,6 +3,7 @@ package casino
import (
"fmt"
"math/rand"
+ "sort"
"strings"
"sync"
)
@@ -147,19 +148,14 @@ func (t *paiGowTable) hintsLocked(name string) []MenuCommand {
// 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; place before your main bet"})
- } else {
- commands = append(commands, MenuCommand{Command: "unbet fortune", Desc: "Take back your Fortune side bet"})
}
- 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"})
- }
+ if faceUp && p.pushace.Total == 0 {
+ commands = append(commands, MenuCommand{Command: "bet <amount> pushace", Desc: "Optional Push Ace High side bet; place before your main bet"})
}
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"})
+ }
+ if p.main.Total > 0 || p.fortune.Total > 0 || p.pushace.Total > 0 {
+ commands = append(commands, MenuCommand{Command: "unbet", Desc: "Take back all your wagers before the deal"})
}
case "setting":
if p.main.Total > 0 && !p.ready {
@@ -179,7 +175,7 @@ func (t *paiGowTable) hintsLocked(name string) []MenuCommand {
case "settling":
commands = append(commands, MenuCommand{Command: "settle", Desc: "Retry the same pending payment"})
}
- return append(commands, MenuCommand{Command: "table rules", Desc: "Rules and Fortune felt"}, MenuCommand{Command: "rules", Desc: "Learn Pai Gow"}, MenuCommand{Command: "stop", Desc: "Leave after finishing your wager"})
+ return append(commands, MenuCommand{Command: "table", Desc: "View the felt"}, MenuCommand{Command: "rules", Desc: "Learn Pai Gow"}, MenuCommand{Command: "stop", Desc: "Leave after finishing your wager"})
}
func (t *paiGowTable) hints(name string) []MenuCommand {
t.mu.Lock()
@@ -193,7 +189,7 @@ func (t *paiGowTable) wagers(name string) []WagerRow {
if p == nil || t.phase == "setting" || t.phase == "settling" {
return nil
}
- rows := []WagerRow{{Bet: "main", Payout: "See table rules", Win: "Beat both dealer hands"}, {Bet: "fortune", Payout: "See Fortune felt", Win: "Best seven-card bonus; independent of main result"}}
+ rows := []WagerRow{{Bet: "main", Payout: "See the felt", Win: "Beat both dealer hands"}, {Bet: "fortune", Payout: "See Fortune felt", Win: "Best seven-card bonus; independent of main result"}}
if t.Config.PaiGow.Variant == PaiGowFaceUp {
rows = append(rows, WagerRow{Bet: "pushace", Payout: "40/15/5:1", Win: "Dealer ace-high pai gow; both 40:1"})
}
@@ -207,6 +203,25 @@ func pgViews(cards []card) []CardView {
return out
}
+// pgSortHand orders a freshly dealt hand small to large so the numbered
+// positions read naturally: by rank first, then by suit. The Joker sorts last.
+func pgSortHand(cards []card) {
+ sort.SliceStable(cards, func(i, j int) bool {
+ ri, rj := pgSortRank(cards[i]), pgSortRank(cards[j])
+ if ri != rj {
+ return ri < rj
+ }
+ return cards[i].suit < cards[j].suit
+ })
+}
+
+func pgSortRank(c card) int {
+ if c.rank == "JK" {
+ return 15
+ }
+ return pokerRank(c)
+}
+
// pgCardNums maps each card in hand to its 1-based position in all. Dealt
// cards are unique, so the match is unambiguous and preserves hand order.
func pgCardNums(hand, all []card) []int {
@@ -359,7 +374,7 @@ func (t *paiGowTable) bet(name, spot string, amount int, w Wallet) []Event {
return fail("Place side bets before your 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.")
+ return fail("Push Ace High is capped at half the main wager; take it back with unbet 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)) + ".")
@@ -372,16 +387,19 @@ func (t *paiGowTable) bet(name, spot string, amount int, w Wallet) []Event {
return fail(walletErrorMessage(err))
}
p.wallet = w
+ // A fresh wager starts a new round in the seat's view. Without this a side
+ // bet placed before the next main wager would leave the previous round's
+ // cards and showdown on screen.
+ p.cards = nil
+ p.split = pgSplit{}
+ p.pending = pgSplit{}
+ p.showdown = nil
+ p.result = ""
+ p.ready = false
+ p.paid = false
if spot == "main" {
p.main = stake
p.last = amount
- p.cards = nil
- p.split = pgSplit{}
- p.pending = pgSplit{}
- p.showdown = nil
- p.result = ""
- p.ready = false
- p.paid = false
} else if spot == "pushace" {
p.pushace = stake
} else {
@@ -467,6 +485,7 @@ func (t *paiGowTable) dealLocked() []Event {
continue
}
p.cards = packets[i]
+ pgSortHand(p.cards)
p.ready = p.disconnected || autoPush
if p.ready {
p.split = pgHouseRules(p.cards, t.a2345High())
@@ -526,7 +545,7 @@ func (t *paiGowTable) action(name, action string) []Event {
return append([]Event{t.private(name, "Autoset is "+state+".")}, t.statusLocked(name, false)...)
}
if strings.HasPrefix(action, "unbet") {
- return t.unbetLocked(p, action)
+ return t.unbetLocked(p)
}
if action == "deal" && (t.phase == "waiting" || t.phase == "betting") && p.main.Total > 0 {
if t.allBetLocked() {
@@ -707,23 +726,35 @@ func (t *paiGowTable) tick() []Event {
if t.allBetLocked() {
return t.dealLocked()
}
+ // A lone player has no time limit; wait for their bet instead.
+ if t.activePlayersLocked() <= 1 {
+ return nil
+ }
t.ticks--
if t.ticks > 0 {
return nil
}
return t.finishBettingLocked()
case "setting":
+ // A lone player has no time limit; wait for them to set.
+ if t.activePlayersLocked() <= 1 {
+ return nil
+ }
t.ticks--
if t.ticks > 0 {
return nil
}
+ var events []Event
for _, p := range t.players {
if p.main.Total > 0 && !p.ready {
p.split = pgHouseRules(p.cards, t.a2345High())
p.ready = true
+ e := t.private(p.name, "Your hand timed out and was set the house way.")
+ e.Color = "casino_warning"
+ events = append(events, e)
}
}
- return t.prepareSettlementLocked()
+ return append(events, t.prepareSettlementLocked()...)
}
return nil
}
@@ -776,17 +807,23 @@ func (t *paiGowTable) afterSeatChangeLocked() []Event {
}
// 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.
+// seats that never placed a main wager are returned, every non-bettor is told
+// the window closed (no silent timer), 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
+ deals := t.anyMainLocked()
for _, p := range t.players {
if p.main.Total > 0 {
continue
}
- events = append(events, t.refundSideBetsLocked(p)...)
+ if side := t.refundSideBetsLocked(p); len(side) > 0 {
+ events = append(events, side...)
+ } else if deals {
+ events = append(events, t.private(p.name, "The betting window closed; you did not place a main wager and sit out this round."))
+ }
}
- if !t.anyMainLocked() {
+ if !deals {
t.resetTableLocked()
return events
}
@@ -830,17 +867,23 @@ func (t *paiGowTable) resetTableLocked() {
}
}
-// 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.")}
+// activePlayersLocked counts the connected players with a live main wager.
+// A lone bettor never runs on a clock, so every timeout path checks this
+// before decrementing. A seated spectator does not start a timer for them.
+func (t *paiGowTable) activePlayersLocked() int {
+ n := 0
+ for _, p := range t.players {
+ if !p.disconnected && p.main.Total > 0 {
+ n++
+ }
}
+ return n
+}
+
+// unbetLocked takes back every wager before the deal. Refunds go through
+// PayCredits with the original stake handles so the ledger entries resolve in
+// the same save; wagers are only cleared on success.
+func (t *paiGowTable) unbetLocked(p *pgPlayer) []Event {
if t.phase != "waiting" && t.phase != "betting" {
return []Event{t.private(p.name, "You can only take back bets before the cards are dealt.")}
}
@@ -852,18 +895,9 @@ func (t *paiGowTable) unbetLocked(p *pgPlayer, action string) []Event {
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)
- }
+ take(&p.main)
+ take(&p.fortune)
+ take(&p.pushace)
if total == 0 {
return []Event{t.private(p.name, "Nothing to take back.")}
}
@@ -872,16 +906,7 @@ func (t *paiGowTable) unbetLocked(p *pgPlayer, action string) []Event {
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{}
- }
+ p.main, p.fortune, p.pushace = Wager{}, Wager{}, Wager{}
if !t.anyMainLocked() && t.phase == "betting" {
t.phase = "waiting"
t.ticks = 0