aboutsummaryrefslogtreecommitdiff
path: root/internal/casino/pai_gow.go
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-10-01 16:26:11 -0400
committerhistoria <[not public]>2026-10-01 16:26:11 -0400
commitd762f8e1360cf38b8e11e69083c2dad59ec7130e (patch)
tree4e684608e02b39b6b085e2a2ab9726bdae063240 /internal/casino/pai_gow.go
parentc94728c929045f7c1bdf0166df152d944e5f99bc (diff)
downloadthehouseoficarus-d762f8e1360cf38b8e11e69083c2dad59ec7130e.tar.gz
fix: trim extra info from games, A2345 support in pai gow, remove bet timers for single player games
Diffstat (limited to 'internal/casino/pai_gow.go')
-rw-r--r--internal/casino/pai_gow.go299
1 files changed, 258 insertions, 41 deletions
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 {