diff options
Diffstat (limited to 'internal/casino/pai_gow.go')
| -rw-r--r-- | internal/casino/pai_gow.go | 498 |
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 +} |
