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.go498
1 files changed, 498 insertions, 0 deletions
diff --git a/internal/casino/pai_gow.go b/internal/casino/pai_gow.go
new file mode 100644
index 0000000..21abf4b
--- /dev/null
+++ b/internal/casino/pai_gow.go
@@ -0,0 +1,498 @@
+package casino
+
+import (
+ "fmt"
+ "math/rand"
+ "strings"
+ "sync"
+)
+
+type PaiGowSnapshot struct {
+ Config TableConfig
+ Phase string
+ Cards, High, Low, DealerHigh, DealerLow []CardView
+ Tiebreaker []CardView
+ Result string
+}
+
+func (*PaiGowSnapshot) snapshot() {}
+
+type pgPlayer struct {
+ name string
+ wallet Wallet
+ main, fortune Wager
+ pushace Wager
+ last int
+ cards []card
+ split pgSplit
+ ready, disconnected, paid bool
+ payout int
+ result string
+}
+type paiGowTable struct {
+ *tableBase
+ mu sync.Mutex
+ rng *rand.Rand
+ players []*pgPlayer // stable seat/deal/settlement order
+ phase string
+ ticks int
+ dealer []card
+ dealerSplit pgSplit
+ tie [2]card
+}
+
+func newPaiGowTable(room int, c TableConfig, rng *rand.Rand) tableGame {
+ return &paiGowTable{tableBase: &tableBase{RoomID: room, Config: c}, rng: rng, phase: "waiting"}
+}
+func (t *paiGowTable) base() *tableBase { return t.tableBase }
+func (t *paiGowTable) player(name string) *pgPlayer {
+ for _, p := range t.players {
+ if p.name == name {
+ return p
+ }
+ }
+ return nil
+}
+func (t *paiGowTable) remove(name string) {
+ for i, p := range t.players {
+ if p.name == name {
+ t.players = append(t.players[:i], t.players[i+1:]...)
+ return
+ }
+ }
+}
+func (t *paiGowTable) idle() bool { t.mu.Lock(); defer t.mu.Unlock(); return len(t.players) == 0 }
+func (t *paiGowTable) hasParticipant(name string) bool {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ return t.player(name) != nil
+}
+func (t *paiGowTable) overview() string {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ return fmt.Sprintf("%s | %s | %d/%d seats", t.Config.Label(), t.phase, len(t.players), t.Config.MaxPlayers)
+}
+func (t *paiGowTable) hintsLocked(name string) []MenuCommand {
+ p := t.player(name)
+ if p == nil {
+ return nil
+ }
+ commands := []MenuCommand{}
+ 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 {
+ if p.fortune.Total == 0 {
+ commands = append(commands, MenuCommand{Command: "bet <amount> fortune", Desc: "Optional Fortune side bet before deal"})
+ }
+ 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)"})
+ }
+ commands = append(commands, MenuCommand{Command: "deal", Desc: "Mark ready; deal when all bettors are ready"})
+ }
+ case "setting":
+ if p.main.Total > 0 && !p.ready {
+ commands = append(commands, MenuCommand{Command: "24 / 13456", Desc: "Choose two low cards OR five high cards; locks the hand"}, MenuCommand{Command: "house", Desc: "Set your hand the house way"})
+ }
+ 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"})
+}
+func (t *paiGowTable) hints(name string) []MenuCommand {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ return t.hintsLocked(name)
+}
+func (t *paiGowTable) wagers(name string) []WagerRow {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ p := t.player(name)
+ 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"}}
+ 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"})
+ }
+ return rows
+}
+func pgViews(cards []card) []CardView {
+ var out []CardView
+ for _, c := range cards {
+ out = append(out, CardView{Rank: c.rank, Suit: c.suit})
+ }
+ return out
+}
+func (t *paiGowTable) statusLocked(name string) []Event {
+ s := &PaiGowSnapshot{Config: t.Config, Phase: t.phase}
+ if p := t.player(name); p != nil {
+ s.Cards = pgViews(p.cards)
+ s.High = pgViews(p.split.high)
+ s.Low = pgViews(p.split.low)
+ s.Result = p.result
+ if t.phase == "settling" && !p.paid && p.main.Total > 0 {
+ s.Result += "; PAYMENT PENDING (not yet credited)"
+ }
+ }
+ if t.phase == "settling" || t.phase == "waiting" || t.phase == "setting" && t.Config.PaiGow.Variant == PaiGowFaceUp {
+ s.DealerHigh = pgViews(t.dealerSplit.high)
+ s.DealerLow = pgViews(t.dealerSplit.low)
+ if len(t.dealer) > 0 && t.Config.PaiGow.Variant == PaiGowNoPush {
+ s.Tiebreaker = pgViews(t.tie[:])
+ }
+ }
+ e := t.private(name, t.Config.Label())
+ e.Snapshot = s
+ e.Menu = &MenuView{Heading: t.Config.Label() + " | " + t.phase, ShowBalance: true, Commands: t.hintsLocked(name)}
+ return []Event{e}
+}
+func (t *paiGowTable) status(name string) []Event {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ return t.statusLocked(name)
+}
+func (t *paiGowTable) join(name string) []Event {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ if p := t.player(name); p != nil {
+ p.disconnected = false
+ return t.statusLocked(name)
+ }
+ if len(t.players) >= t.Config.MaxPlayers {
+ return []Event{t.private(name, "This Pai Gow table is full.")}
+ }
+ t.players = append(t.players, &pgPlayer{name: name})
+ return t.statusLocked(name)
+}
+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.")}
+ }
+ t.remove(name)
+ return []Event{t.private(name, "You leave Pai Gow Poker.")}
+}
+func (t *paiGowTable) rebet(name string) (string, int) {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ if p := t.player(name); p != nil && p.last > 0 {
+ return "main", p.last
+ }
+ return "main", t.Config.MinBet
+}
+func (t *paiGowTable) bet(name, spot string, amount int, w Wallet) []Event {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ p := t.player(name)
+ if p == nil {
+ return nil
+ }
+ fail := func(s string) []Event { return []Event{t.private(name, s)} }
+ if t.phase != "waiting" && t.phase != "betting" {
+ return fail("Betting is closed.")
+ }
+ if spot == "" {
+ spot = "main"
+ }
+ if spot != "main" && spot != "fortune" && spot != "pushace" {
+ return fail("Bet on main or fortune.")
+ }
+ if spot == "pushace" && t.Config.PaiGow.Variant != PaiGowFaceUp {
+ return fail("Push Ace High is a Face Up side bet only.")
+ }
+ 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.")
+ }
+ if spot == "pushace" && amount > p.main.Total/2 {
+ return fail("Push Ace High is capped at half the main wager.")
+ }
+ 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)) + ".")
+ }
+ if w == nil {
+ return fail("Wallet unavailable.")
+ }
+ stake, err := w.Wager(amount)
+ if err != nil {
+ return fail(walletErrorMessage(err))
+ }
+ p.wallet = w
+ if spot == "main" {
+ p.main = stake
+ p.last = amount
+ p.cards = nil
+ p.split = pgSplit{}
+ 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" {
+ 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)...)
+}
+func (t *paiGowTable) allReady() bool {
+ found := false
+ for _, p := range t.players {
+ if p.main.Total > 0 {
+ found = true
+ if !p.ready {
+ return false
+ }
+ }
+ }
+ return found
+}
+func (t *paiGowTable) dealLocked() []Event {
+ deck := newShoe(1, 0, t.rng)
+ deck.cards = append(deck.cards, card{"JK", "joker"})
+ t.rng.Shuffle(len(deck.cards), func(i, j int) { deck.cards[i], deck.cards[j] = deck.cards[j], deck.cards[i] })
+ // Seven packets, including unused seats, leave the published No Push
+ // tiebreakers at cards 50 and 51. Empty packets never qualify for Envy.
+ packets := [7][]card{}
+ for i := range packets {
+ for j := 0; j < 7; j++ {
+ packets[i] = append(packets[i], deck.draw())
+ }
+ }
+ t.dealer = packets[6]
+ t.dealerSplit = pgHouse(t.dealer)
+ 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) != ""
+ var events []Event
+ for i, p := range t.players {
+ if p.main.Total == 0 {
+ continue
+ }
+ p.cards = packets[i]
+ p.ready = p.disconnected || autoPush
+ if p.ready {
+ p.split = pgHouse(p.cards)
+ }
+ if !autoPush {
+ events = append(events, t.statusLocked(p.name)...)
+ }
+ }
+ if t.allReady() {
+ events = append(events, t.prepareSettlementLocked()...)
+ }
+ return events
+}
+func (t *paiGowTable) action(name, action string) []Event {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ p := t.player(name)
+ if p == nil {
+ return nil
+ }
+ action = strings.TrimSpace(strings.ToLower(action))
+ if action == "paytable" || action == "status" {
+ return t.statusLocked(name)
+ }
+ 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() {
+ return t.dealLocked()
+ }
+ return []Event{t.private(name, "Ready. Waiting for other bettors or the betting 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.")}
+ }
+ if p.ready {
+ return []Event{t.private(name, "Your hand is locked; waiting for the other players.")}
+ }
+ var split pgSplit
+ if action == "house" || action == "house way" || action == "set house" {
+ split = pgHouse(p.cards)
+ } else {
+ digits, ok := ParsePaiGowDigits(strings.TrimPrefix(action, "set "))
+ if !ok {
+ return []Event{t.private(name, "Enter exactly two or five different numbers from 1-7 (24 or 13456), or house.")}
+ }
+ split = pgSet(p.cards, digits)
+ }
+ if !split.valid() {
+ 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
+ p.ready = true
+ if t.allReady() {
+ return t.prepareSettlementLocked()
+ }
+ return t.statusLocked(name)
+}
+func (t *paiGowTable) prepareSettlementLocked() []Event {
+ t.phase = "settling"
+ for _, p := range t.players {
+ if p.main.Total == 0 {
+ continue
+ }
+ result := pgMainResult(p.split, t.dealerSplit, t.dealer, t.Config.PaiGow.Variant, t.tie)
+ 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" {
+ 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 {
+ p.result += "; Push Ace High: lost (dealer not ace-high)"
+ }
+ }
+ if p.fortune.Total > 0 {
+ row, win := pgFortune(p.cards, t.Config.PaiGow.Envy)
+ p.result += "; Fortune: " + row.Hand
+ if win {
+ p.payout += p.fortune.Total * (row.Odds + 1)
+ }
+ if t.Config.PaiGow.Envy && p.fortune.Total >= 5 {
+ envy := 0
+ for _, other := range t.players {
+ if other != p && other.main.Total > 0 {
+ row, _ := pgFortune(other.cards, true)
+ envy += row.Envy
+ }
+ }
+ p.payout += envy
+ p.result += fmt.Sprintf("; Envy: %d", envy)
+ }
+ }
+ }
+ return t.payLocked()
+}
+func (t *paiGowTable) payLocked() []Event {
+ var events []Event
+ var newlyPaid []string
+ pending := false
+ for _, p := range t.players {
+ if p.main.Total == 0 || p.paid {
+ continue
+ }
+ ids := []string{p.main.ID}
+ if p.fortune.Total > 0 {
+ ids = append(ids, p.fortune.ID)
+ }
+ if p.pushace.Total > 0 {
+ ids = append(ids, p.pushace.ID)
+ }
+ // One durable transaction settles both wagers and Envy, including zero
+ // returns. Freeze the round on failure; retries never redeal or pay twice.
+ if err := p.wallet.PayCredits(p.payout, ids...); err != nil {
+ pending = true
+ events = append(events, t.private(p.name, payoutFailedMessage()+" Use settle to retry the same result."))
+ continue
+ }
+ p.paid = true
+ p.result += fmt.Sprintf("; total returned %d credits", p.payout)
+ newlyPaid = append(newlyPaid, p.name)
+ }
+ if pending {
+ for _, name := range newlyPaid {
+ events = append(events, t.statusLocked(name)...)
+ }
+ return events
+ }
+ t.phase = "waiting"
+ kept := t.players[:0]
+ for _, p := range t.players {
+ p.main = Wager{}
+ p.fortune = Wager{}
+ p.ready = false
+ if !p.disconnected {
+ kept = append(kept, p)
+ }
+ }
+ t.players = kept
+ for _, p := range t.players {
+ events = append(events, t.statusLocked(p.name)...)
+ }
+ return events
+}
+func (t *paiGowTable) tick() []Event {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ switch t.phase {
+ case "settling":
+ return t.payLocked()
+ case "betting", "setting":
+ t.ticks--
+ if t.ticks > 0 {
+ return nil
+ }
+ if t.phase == "betting" {
+ return t.dealLocked()
+ }
+ for _, p := range t.players {
+ if p.main.Total > 0 && !p.ready {
+ p.split = pgHouse(p.cards)
+ p.ready = true
+ }
+ }
+ return t.prepareSettlementLocked()
+ }
+ return nil
+}
+func (t *paiGowTable) markDisconnected(name string) []Event {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ p := t.player(name)
+ if p == nil {
+ return nil
+ }
+ if p.main.Total == 0 {
+ t.remove(name)
+ return nil
+ }
+ p.disconnected = true
+ p.ready = true
+ if t.phase == "setting" {
+ if p.split.high == nil {
+ p.split = pgHouse(p.cards)
+ }
+ if t.allReady() {
+ return t.prepareSettlementLocked()
+ }
+ }
+ return nil
+}
+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 {
+ continue
+ }
+ if _, _, err := p.wallet.RefundOutstanding(); err != nil {
+ events = append(events, t.private(p.name, "Shutdown refund remains pending for startup recovery."))
+ } else {
+ p.main = Wager{}
+ p.fortune = Wager{}
+ p.pushace = Wager{}
+ }
+ }
+ return events
+}