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 | %d/%d seated", t.Config.Label(), len(t.players), t.Config.MaxPlayers) } func (t *paiGowTable) seats() []string { t.mu.Lock() defer t.mu.Unlock() names := make([]string, 0, len(t.players)) for _, p := range t.players { names = append(names, p.name) } return names } func (t *paiGowTable) state() string { t.mu.Lock() defer t.mu.Unlock() return t.phase } 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 ", Desc: "Main wager; opens a timed betting window"}) } else { if p.fortune.Total == 0 { commands = append(commands, MenuCommand{Command: "bet fortune", Desc: "Optional Fortune side bet before deal"}) } if t.Config.PaiGow.Variant == PaiGowFaceUp && p.pushace.Total == 0 { commands = append(commands, MenuCommand{Command: "bet 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 // 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} } 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 }