diff options
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/casino/pai_gow.go | 262 | ||||
| -rw-r--r-- | internal/casino/pai_gow_rules.go | 79 | ||||
| -rw-r--r-- | internal/casino/pai_gow_test.go | 151 | ||||
| -rw-r--r-- | internal/game/casino.go | 48 | ||||
| -rw-r--r-- | internal/game/cmd_registry.go | 2 | ||||
| -rw-r--r-- | internal/game/pai_gow_test.go | 170 | ||||
| -rw-r--r-- | internal/game/render_pai_gow.go | 205 | ||||
| -rw-r--r-- | internal/game/render_video_poker.go | 35 |
8 files changed, 848 insertions, 104 deletions
diff --git a/internal/casino/pai_gow.go b/internal/casino/pai_gow.go index ba3daaa..b853829 100644 --- a/internal/casino/pai_gow.go +++ b/internal/casino/pai_gow.go @@ -7,28 +7,74 @@ import ( "sync" ) +// PaiGowHandView is one hand in the showdown: its label, evaluated +// strength, cards and whether it won its comparison. Winning is only +// meaningful when HasWin is true (a showdown), so Face Up's mid-round +// dealer reveal does not paint the dealer as a winner. +type PaiGowHandView struct { + Label string + Strength string + Cards []CardView + Winning bool + HasWin bool +} + +// PaiGowShowdown carries the comparison and settlement pieces the renderer +// turns into the coloured results block. Hands always run player high, +// dealer high, player low, dealer low. +type PaiGowShowdown struct { + Hands []PaiGowHandView + Result string + Fortune string + FortuneWin bool + PushAce string + PushAceWin bool + Envy int + ShowEnvy bool + Stake int + Payout int + Paid bool + Pending bool +} + type PaiGowSnapshot struct { - Config TableConfig - Phase string - Cards, High, Low, DealerHigh, DealerLow []CardView - Tiebreaker []CardView - Result string + Config TableConfig + Phase string + ShowFelt bool + Cards, DealerHigh, DealerLow []CardView + Tiebreaker []CardView + // Preview carries an arranged-but-unset (or locked) hand: the five-card + // high hand followed by the two-card low hand. PreviewHighNums and + // PreviewLowNums are the original 1-7 positions under each card. + Preview bool + PreviewHigh, PreviewLow []CardView + PreviewHighNums, PreviewLowNums []int + // DealerHighName/DealerLowName label a Face Up reveal that happens before + // the showdown. + DealerHighName, DealerLowName string + Showdown *PaiGowShowdown + Result string } func (*PaiGowSnapshot) snapshot() {} type pgPlayer struct { - name string - wallet Wallet - main, fortune Wager - pushace Wager - last int - cards []card - split pgSplit + name string + wallet Wallet + main, fortune Wager + pushace Wager + last int + cards []card + split pgSplit + // pending holds a legal arrangement the player has previewed but not yet + // committed with "set". Autoset players never populate it. + pending pgSplit + autoset bool ready, disconnected, paid bool leaveAfter bool payout int result string + showdown *PaiGowShowdown } type paiGowTable struct { *tableBase @@ -117,7 +163,18 @@ func (t *paiGowTable) hintsLocked(name string) []MenuCommand { } 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"}) + if p.pending.high != nil { + commands = append(commands, MenuCommand{Command: "set", Desc: "Lock this arrangement and reveal the dealer"}) + commands = append(commands, MenuCommand{Command: "24 / 13456", Desc: "Choose a different two-low or five-high arrangement"}) + } else { + commands = append(commands, MenuCommand{Command: "24 / 13456", Desc: "Choose two low cards or five high cards to arrange"}) + } + commands = append(commands, MenuCommand{Command: "house", Desc: "Arrange your hand the house way"}) + if p.autoset { + commands = append(commands, MenuCommand{Command: "autoset off", Desc: "Require set before the dealer reveals"}) + } else { + commands = append(commands, MenuCommand{Command: "autoset on", Desc: "Set a legal arrangement immediately"}) + } } case "settling": commands = append(commands, MenuCommand{Command: "settle", Desc: "Retry the same pending payment"}) @@ -149,13 +206,53 @@ func pgViews(cards []card) []CardView { } return out } -func (t *paiGowTable) statusLocked(name string) []Event { - s := &PaiGowSnapshot{Config: t.Config, Phase: t.phase} + +// 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 { + used := make([]bool, len(all)) + var nums []int + for _, h := range hand { + for i, c := range all { + if !used[i] && c == h { + used[i] = true + nums = append(nums, i+1) + break + } + } + } + return nums +} + +func (t *paiGowTable) statusLocked(name string, showFelt bool) []Event { + s := &PaiGowSnapshot{Config: t.Config, Phase: t.phase, ShowFelt: showFelt} if p := t.player(name); p != nil { - s.Cards = pgViews(p.cards) - s.High = pgViews(p.split.high) - s.Low = pgViews(p.split.low) + // The dealt hand only appears before the player has arranged anything; + // afterwards the board shows the arrangement or the waiting message. + if !p.ready && p.pending.high == nil { + s.Cards = pgViews(p.cards) + } + if p.showdown != nil { + s.Showdown = p.showdown + } + // A pending arrangement previews before "set"; a committed one previews + // until every seat is in and the showdown replaces it. Autoset players + // skip the preview and go straight to the set/waiting state. + split := p.split + if p.pending.high != nil { + split = p.pending + } + if split.high != nil && !(p.autoset && p.pending.high == nil) { + s.Preview = true + s.PreviewHigh = pgViews(split.high) + s.PreviewLow = pgViews(split.low) + s.PreviewHighNums = pgCardNums(split.high, p.cards) + s.PreviewLowNums = pgCardNums(split.low, p.cards) + } s.Result = p.result + if p.ready && t.phase == "setting" && p.showdown == nil { + s.Result = "Hand set — waiting for the other players." + } if t.phase == "settling" && !p.paid && p.main.Total > 0 { s.Result += "; PAYMENT PENDING (not yet credited)" } @@ -163,6 +260,12 @@ func (t *paiGowTable) statusLocked(name string) []Event { 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.dealerSplit.high) > 0 { + s.DealerHighName = pgRankName(pgEvaluateRules(t.dealerSplit.high, t.a2345High())) + } + if len(t.dealerSplit.low) > 0 { + s.DealerLowName = pgRankName(pgEvaluateRules(t.dealerSplit.low, t.a2345High())) + } if len(t.dealer) > 0 && t.Config.PaiGow.Variant == PaiGowNoPush { s.Tiebreaker = pgViews(t.tie[:]) } @@ -181,20 +284,20 @@ func (t *paiGowTable) statusLocked(name string) []Event { func (t *paiGowTable) status(name string) []Event { t.mu.Lock() defer t.mu.Unlock() - return t.statusLocked(name) + return t.statusLocked(name, false) } 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) + return t.statusLocked(name, true) } 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) + t.players = append(t.players, &pgPlayer{name: name, autoset: t.Config.PaiGow.Autoset}) + return t.statusLocked(name, true) } func (t *paiGowTable) leave(name string) []Event { t.mu.Lock() @@ -274,6 +377,8 @@ func (t *paiGowTable) bet(name, spot string, amount int, w Wallet) []Event { p.last = amount p.cards = nil p.split = pgSplit{} + p.pending = pgSplit{} + p.showdown = nil p.result = "" p.ready = false p.paid = false @@ -294,7 +399,7 @@ func (t *paiGowTable) bet(name, spot string, amount int, w Wallet) []Event { if spot == "main" && t.allBetLocked() { return append(events, t.dealLocked()...) } - return append(events, t.statusLocked(name)...) + return append(events, t.statusLocked(name, false)...) } func (t *paiGowTable) allReady() bool { found := false @@ -367,7 +472,7 @@ func (t *paiGowTable) dealLocked() []Event { p.split = pgHouseRules(p.cards, t.a2345High()) } if !autoPush { - events = append(events, t.statusLocked(p.name)...) + events = append(events, t.statusLocked(p.name, false)...) } } if t.allReady() { @@ -384,7 +489,7 @@ func (t *paiGowTable) action(name, action string) []Event { } action = strings.TrimSpace(strings.ToLower(action)) if action == "paytable" || action == "status" { - return t.statusLocked(name) + return t.statusLocked(name, false) } if t.phase == "settling" && action == "settle" { return t.payLocked() @@ -399,6 +504,27 @@ func (t *paiGowTable) action(name, action string) []Event { if action == "sitout" { return []Event{t.private(name, "Pai Gow has no sit-out; use stop to leave after the round.")} } + if action == "autoset" || strings.HasPrefix(action, "autoset ") { + if t.phase != "waiting" && t.phase != "betting" && t.phase != "setting" { + return []Event{t.private(name, "Autoset can only change before the showdown.")} + } + rest := strings.TrimSpace(strings.TrimPrefix(action, "autoset")) + switch rest { + case "", "toggle": + p.autoset = !p.autoset + case "on": + p.autoset = true + case "off": + p.autoset = false + default: + return []Event{t.private(name, "Use autoset on or autoset off.")} + } + state := "off" + if p.autoset { + state = "on" + } + return append([]Event{t.private(name, "Autoset is "+state+".")}, t.statusLocked(name, false)...) + } if strings.HasPrefix(action, "unbet") { return t.unbetLocked(p, action) } @@ -414,6 +540,20 @@ func (t *paiGowTable) action(name, action string) []Event { if p.ready { return []Event{t.private(name, "Your hand is locked; waiting for the other players.")} } + // "set" commits a previewed arrangement. Without autoset, the numeric + // choices only arrange; the player confirms before the dealer reveals. + if action == "set" || action == "set hand" { + if p.pending.high == nil { + return []Event{t.private(name, "Arrange your hand first with 24 or 13456, or house.")} + } + p.split = p.pending + p.pending = pgSplit{} + p.ready = true + if t.allReady() { + return t.prepareSettlementLocked() + } + return t.statusLocked(name, false) + } var split pgSplit if action == "house" || action == "house way" || action == "set house" { split = pgHouseRules(p.cards, t.a2345High()) @@ -427,12 +567,17 @@ func (t *paiGowTable) action(name, action string) []Event { 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 - p.ready = true - if t.allReady() { - return t.prepareSettlementLocked() + if p.autoset { + p.split = split + p.pending = pgSplit{} + p.ready = true + if t.allReady() { + return t.prepareSettlementLocked() + } + return t.statusLocked(name, false) } - return t.statusLocked(name) + p.pending = split + return t.statusLocked(name, false) } func (t *paiGowTable) prepareSettlementLocked() []Event { t.phase = "settling" @@ -442,34 +587,59 @@ func (t *paiGowTable) prepareSettlementLocked() []Event { } 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 + p.result = result + highRank := pgEvaluateRules(p.split.high, t.a2345High()) + lowRank := pgEvaluateRules(p.split.low, t.a2345High()) + dealerHighRank := pgEvaluateRules(t.dealerSplit.high, t.a2345High()) + dealerLowRank := pgEvaluateRules(t.dealerSplit.low, t.a2345High()) + highWin := pgCompare(highRank, dealerHighRank) > 0 + lowWin := pgCompare(lowRank, dealerLowRank) > 0 + sd := &PaiGowShowdown{ + Result: result, + Hands: []PaiGowHandView{ + {Label: "Your high", Strength: pgRankName(highRank), Cards: pgViews(p.split.high), Winning: highWin, HasWin: true}, + {Label: "Dealer high", Strength: pgRankName(dealerHighRank), Cards: pgViews(t.dealerSplit.high), Winning: !highWin, HasWin: true}, + {Label: "Your low", Strength: pgRankName(lowRank), Cards: pgViews(p.split.low), Winning: lowWin, HasWin: true}, + {Label: "Dealer low", Strength: pgRankName(dealerLowRank), Cards: pgViews(t.dealerSplit.low), Winning: !lowWin, HasWin: true}, + }, + } if t.Config.PaiGow.Variant == PaiGowFaceUp { rates, _ := t.Config.PaiGow.pushAceRates() 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) + p.payout += p.pushace.Total * (class.rate + 1) + sd.PushAce = fmt.Sprintf("%s (%d:1)", class.desc, class.rate) + sd.PushAceWin = true } else if p.pushace.Total > 0 { - p.result += "; Push Ace High: lost (dealer not ace-high)" + sd.PushAce = "dealer not ace-high" } } if p.fortune.Total > 0 { row, win := pgFortuneRules(p.cards, t.Config.PaiGow.Envy, t.a2345High()) - p.result += "; Fortune: " + row.Hand + sd.Fortune = row.Hand + sd.FortuneWin = win if win { p.payout += p.fortune.Total * (row.Odds + 1) } if t.Config.PaiGow.Envy && p.fortune.Total >= 5 { - envy := 0 + envy, others := 0, 0 for _, other := range t.players { if other != p && other.main.Total > 0 { + others++ row, _ := pgFortuneRules(other.cards, true, t.a2345High()) envy += row.Envy } } - p.payout += envy - p.result += fmt.Sprintf("; Envy: %d", envy) + // Envy only means something with someone else at the table. + if others > 0 { + sd.ShowEnvy = true + sd.Envy = envy + p.payout += envy + } } } + sd.Stake = p.main.Total + p.fortune.Total + p.pushace.Total + sd.Payout = p.payout + p.showdown = sd } return t.payLocked() } @@ -492,16 +662,21 @@ func (t *paiGowTable) payLocked() []Event { // returns. Freeze the round on failure; retries never redeal or pay twice. if err := p.wallet.PayCredits(p.payout, ids...); err != nil { pending = true + if p.showdown != nil { + p.showdown.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) + if p.showdown != nil { + p.showdown.Paid = true + } newlyPaid = append(newlyPaid, p.name) } if pending { for _, name := range newlyPaid { - events = append(events, t.statusLocked(name)...) + events = append(events, t.statusLocked(name, false)...) } return events } @@ -518,7 +693,7 @@ func (t *paiGowTable) payLocked() []Event { } t.players = kept for _, p := range t.players { - events = append(events, t.statusLocked(p.name)...) + events = append(events, t.statusLocked(p.name, false)...) } return events } @@ -650,7 +825,8 @@ func (t *paiGowTable) resetTableLocked() { 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, "" + p.cards, p.split, p.pending, p.ready, p.paid, p.result = nil, pgSplit{}, pgSplit{}, false, false, "" + p.showdown = nil } } @@ -710,7 +886,7 @@ func (t *paiGowTable) unbetLocked(p *pgPlayer, action string) []Event { 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)...) + return append([]Event{t.private(p.name, fmt.Sprintf("You take back %d credits.", total))}, t.statusLocked(p.name, false)...) } func (t *paiGowTable) refundAll() []Event { t.mu.Lock() diff --git a/internal/casino/pai_gow_rules.go b/internal/casino/pai_gow_rules.go index ced7c47..3a1f4b5 100644 --- a/internal/casino/pai_gow_rules.go +++ b/internal/casino/pai_gow_rules.go @@ -21,7 +21,11 @@ type PaiGowConfig struct { Variant string `yaml:"variant,omitempty"` Envy bool `yaml:"envy,omitempty"` PushAceHigh string `yaml:"push_ace_high,omitempty"` // Face Up only: 5_15_40 or 5_12_40 - ActionTicks int `yaml:"action_ticks,omitempty"` + // Autoset makes a legal arrangement lock immediately instead of waiting + // for the player's "set" confirmation. Players may toggle it per seat with + // "autoset on|off"; this field only seeds the default at join. + Autoset bool `yaml:"autoset,omitempty"` + ActionTicks int `yaml:"action_ticks,omitempty"` // A2345SecondHighest makes the wheel the second-highest straight, below // Broadway and above a six-high straight. It defaults to true; only an // explicit false demotes A2345 to the lowest straight. @@ -95,7 +99,7 @@ func paiGowRules(c TableConfig) string { if !r.A2345High() { wheel = "A2345 is the lowest straight." } - s := r.Label() + "\nSeven cards from a fresh 53-card deck. Joker is an ace unless completing a straight, flush or straight flush. " + wheel + "\nSet two cards low and five high: 24 chooses low cards 2/4; 13456 chooses the high hand. High must strictly outrank low. Copies go to the dealer.\nBoth hands win: win; both lose: loss; one wins: push." + s := r.Label() + "\nSeven cards from a fresh 53-card deck. Joker is an ace unless completing a straight, flush or straight flush. " + wheel + "\nArrange two cards low and five high: 24 chooses low cards 2/4; 13456 chooses the high hand. Your five-card hand must strictly outrank your two-card hand. The board then shows the five high cards and the two low cards, and set locks the hand and reveals the dealer; copies go to the dealer.\nBoth hands win: win; both lose: loss; one wins: push." switch r.Variant { case PaiGowClassic: s += " Wins pay 0.95:1 (profit rounded down, minimum one credit)." @@ -106,7 +110,7 @@ func paiGowRules(c TableConfig) string { case PaiGowNoPush: s += " No commission; split results use two extra cards: player must beat dealer, ties lose; Joker is ace." } - s += "\nHouse way: preserve the best high-hand category, maximize low within it; split two pair, full houses and trips-pairs when this improves low to a pair. Never foul. Use house or set <digits>; set locks your hand. Timeout/disconnect uses house way.\nBet main first, optionally bet <amount> fortune before dealing. Fortune uses all seven cards regardless of split; highest listed prize only, profit odds plus stake returned." + s += "\nHouse way: preserve the best high-hand category, maximize low within it; split two pair, full houses and trips-pairs when this improves low to a pair. Never foul. house arranges the house way and still needs set; autoset on locks any legal arrangement immediately (autoset off restores the confirmation). Timeout/disconnect uses house way.\nBet main first, optionally bet <amount> fortune before dealing. Fortune uses all seven cards regardless of split; highest listed prize only, profit odds plus stake returned." if r.Envy { s += "\nEnvy: Fortune stake of at least 5 qualifies for fixed bonuses on OTHER active players' hands, never your own or dealer; multiple bonuses add." } else { @@ -148,6 +152,7 @@ func pgCompare(a, b pgRank) int { } return 0 } + // pgNatural keeps the default-rules entry point used by tests and callers that // do not thread a table configuration. Production evaluation goes through // pgNaturalRules. @@ -258,6 +263,73 @@ func pgEvaluateRules(cards []card, a2345High bool) pgRank { return best } +// pgRankWords names card ranks for the hand-strength labels shown at the +// showdown. Index 14 is ACE so a pair of aces reads "PAIR OF ACES". +var pgRankWords = [...]string{"", "", "TWO", "THREE", "FOUR", "FIVE", "SIX", "SEVEN", "EIGHT", "NINE", "TEN", "JACK", "QUEEN", "KING", "ACE"} + +func pgRankWord(v int) string { + if v >= 2 && v <= 14 { + return pgRankWords[v] + } + return "?" +} + +func pgRankPlural(v int) string { + word := pgRankWord(v) + switch word { + case "SIX": + return "SIXES" + case "ACE": + return "ACES" + default: + return word + "S" + } +} + +func pgStraightName(v int) string { + switch v { + case 16: + return "ACE-HIGH" + case 15: + return "FIVE-HIGH" + default: + return pgRankWord(v) + "-HIGH" + } +} + +// pgRankName renders a hand strength for the showdown, for example +// "PAIR OF ACES", "TWO PAIR ACES AND KINGS" or "STRAIGHT ACE-HIGH". +func pgRankName(r pgRank) string { + switch r[0] { + case 9: + return "FIVE OF A KIND " + pgRankPlural(r[1]) + case 8: + if r[1] == 16 { + return "ROYAL FLUSH" + } + return "STRAIGHT FLUSH " + pgStraightName(r[1]) + case 7: + return "FOUR OF A KIND " + pgRankPlural(r[1]) + case 6: + return "FULL HOUSE " + pgRankPlural(r[1]) + " FULL OF " + pgRankPlural(r[2]) + case 5: + return "FLUSH " + pgRankWord(r[1]) + "-HIGH" + case 4: + return "STRAIGHT " + pgStraightName(r[1]) + case 3: + return "THREE OF A KIND " + pgRankPlural(r[1]) + case 2: + return "TWO PAIR " + pgRankPlural(r[1]) + " AND " + pgRankPlural(r[2]) + case 1: + return "PAIR OF " + pgRankPlural(r[1]) + default: + if r[1] > 0 { + return "HIGH CARD " + pgRankWord(r[1]) + } + return "HIGH CARD" + } +} + type pgSplit struct{ high, low []card } func pgSet(cards []card, digits []int) pgSplit { @@ -437,6 +509,7 @@ func pgFortuneRules(cards []card, envy, a2345High bool) (FortuneRow, bool) { } return rows[bestIndex], true } + // pgBest keeps the default-rules entry point used by tests and callers that do // not thread a table configuration. Production evaluation goes through // pgBestRules. diff --git a/internal/casino/pai_gow_test.go b/internal/casino/pai_gow_test.go index 02ea187..3936704 100644 --- a/internal/casino/pai_gow_test.go +++ b/internal/casino/pai_gow_test.go @@ -278,14 +278,17 @@ func TestPaiGowAtomicRetryAndEnvy(t *testing.T) { // collects b's fixed quad Envy, even though b didn't bet Fortune. tab.player("a").cards = pgCards("2s 4h 6c 8d Js Qh Ac") tab.player("b").cards = pgCards("Ks Kh Kc Kd 2h 3c 7h") + // Autoset is off by default, so each player previews then confirms. tab.action("a", "house") tab.action("b", "house") + tab.action("a", "set") + tab.action("b", "set") if tab.phase != "settling" || len(a.open) != 2 || len(b.open) != 0 || b.calls != 1 { t.Fatal("failed settlement wasn't held atomically") } p := tab.player("a") - if !strings.Contains(p.result, "Envy: 5") { - t.Fatal(p.result) + if p.showdown == nil || !p.showdown.ShowEnvy || p.showdown.Envy != 5 { + t.Fatalf("envy not reported: %+v", p.showdown) } cards := append([]card(nil), p.cards...) amount := p.payout @@ -312,14 +315,18 @@ func TestPaiGowRejectedFoulDoesNotLock(t *testing.T) { p := tab.player("a") p.cards = pgCards("As Ah 2c 3d 4s 7h 9c") events := tab.action("a", "12") - if p.ready || p.split.high != nil || !strings.Contains(events[0].Message, "FOUL HAND") { + if p.ready || p.split.high != nil || p.pending.high != nil || !strings.Contains(events[0].Message, "FOUL HAND") { t.Fatal(events) } tab.action("a", "34567") - if p.ready { + if p.ready || p.pending.high != nil { t.Fatal("complement foul should fail") } tab.action("a", "67") + if p.ready || p.pending.high == nil { + t.Fatal("a legal arrangement should preview, not lock") + } + tab.action("a", "set") if tab.phase != "waiting" { t.Fatal("legal set did not settle") } @@ -351,10 +358,15 @@ func TestPaiGowEnvyEligibilityAndMultipleAwards(t *testing.T) { tab.dealerSplit = pgHouse(tab.dealer) for _, name := range []string{"a", "b", "c"} { tab.action(name, "house") + tab.action(name, "set") } p := tab.player("a") main := pgMainPay(20, pgMainResult(p.split, tab.dealerSplit, tab.dealer, PaiGowClassic, tab.tie), PaiGowClassic) - if want := main + tt.stake*151 + tt.envy; p.payout != want { + wantEnvy := 0 + if tt.enabled && tt.stake >= 5 { + wantEnvy = tt.envy + } + if want := main + tt.stake*151 + wantEnvy; p.payout != want { t.Fatalf("%+v payout=%d want=%d", tt, p.payout, want) } } @@ -429,6 +441,7 @@ func TestPaiGowFaceUpAndPushAceHigh(t *testing.T) { t.Fatal("round resolved before player set") } tab.action("a", "house") + tab.action("a", "set") if tab.phase != "waiting" || len(w.open) != 0 { t.Fatal("Face Up settlement did not finish", tab.phase, w.open) } @@ -491,6 +504,134 @@ func TestPaiGowA2345Option(t *testing.T) { } } +func TestPaiGowRankNames(t *testing.T) { + cases := []struct { + cards string + name string + }{ + {"As Ah", "PAIR OF ACES"}, + {"Ks Qh", "HIGH CARD KING"}, + {"As Ah Kc Kd 2s", "TWO PAIR ACES AND KINGS"}, + {"As Kh Qc Jd 10s", "STRAIGHT ACE-HIGH"}, + {"2s 3h 4c 5d 6s", "STRAIGHT SIX-HIGH"}, + {"As 2h 3c 4d 5s", "STRAIGHT FIVE-HIGH"}, + {"10s Js Qs Ks As", "ROYAL FLUSH"}, + {"Ks Kh Kd Kc 2s", "FOUR OF A KIND KINGS"}, + {"Ks Kh Kd Qc Qs", "FULL HOUSE KINGS FULL OF QUEENS"}, + } + for _, tt := range cases { + if got := pgRankName(pgEvaluate(pgCards(tt.cards))); got != tt.name { + t.Errorf("%s = %q want %q", tt.cards, got, tt.name) + } + } +} + +func TestPaiGowManualSetConfirmsBeforeReveal(t *testing.T) { + tab := pgTestTable(false) + wa, wb := &testWallet{chips: 100}, &testWallet{chips: 100} + tab.join("a") + tab.join("b") + tab.bet("a", "main", 10, wa) + tab.bet("b", "main", 10, wb) + p := tab.player("a") + p.cards = pgCards("As Ah 2c 3d 4s 7h 9c") + events := tab.action("a", "house") + if p.ready || p.pending.high == nil { + t.Fatalf("house should preview, not lock: ready=%v pending=%+v", p.ready, p.pending) + } + snap := events[len(events)-1].Snapshot.(*PaiGowSnapshot) + if !snap.Preview || len(snap.PreviewHigh) != 5 || len(snap.PreviewLow) != 2 { + t.Fatalf("arranged preview missing: %+v", snap) + } + if len(snap.DealerHigh) != 0 { + t.Fatal("dealer revealed before set") + } + // "set" locks the arrangement but the round stays open for b. + events = tab.action("a", "set") + if !p.ready || tab.phase != "setting" { + t.Fatalf("set did not lock: ready=%v phase=%s", p.ready, tab.phase) + } + snap = events[len(events)-1].Snapshot.(*PaiGowSnapshot) + if snap.Showdown != nil { + t.Fatal("showdown before every seat set") + } + // b finishing the house way settles both. + tab.action("b", "house") + tab.action("b", "set") + if tab.phase != "waiting" || p.showdown == nil || len(p.showdown.Hands) != 4 { + t.Fatalf("round did not settle: phase=%s showdown=%+v", tab.phase, p.showdown) + } + // The showdown compares high hands first, then low hands. + labels := []string{p.showdown.Hands[0].Label, p.showdown.Hands[1].Label, p.showdown.Hands[2].Label, p.showdown.Hands[3].Label} + want := []string{"Your high", "Dealer high", "Your low", "Dealer low"} + if !reflect.DeepEqual(labels, want) { + t.Fatalf("showdown order %v want %v", labels, want) + } +} + +func TestPaiGowPreviewKeepsOriginalPositions(t *testing.T) { + tab := pgTestTable(false) + w := &testWallet{chips: 100} + tab.join("a") + tab.bet("a", "", 10, w) + p := tab.player("a") + p.cards = pgCards("As Ah 2c 3d 4s 7h 9c") + events := tab.action("a", "24") + snap := events[len(events)-1].Snapshot.(*PaiGowSnapshot) + if !snap.Preview { + t.Fatal("a legal arrangement should preview") + } + if !reflect.DeepEqual(snap.PreviewLowNums, []int{2, 4}) { + t.Fatalf("low numbers %v", snap.PreviewLowNums) + } + if !reflect.DeepEqual(snap.PreviewHighNums, []int{1, 3, 5, 6, 7}) { + t.Fatalf("high numbers %v", snap.PreviewHighNums) + } +} + +func TestPaiGowAutosetLocksWithoutPreview(t *testing.T) { + cfg := TableConfig{ID: "pg", Game: GamePaiGow, BettingTicks: 2, PaiGow: PaiGowConfig{Autoset: true, ActionTicks: 60}}.Normalize() + tab := newPaiGowTable(1, cfg, rand.New(rand.NewSource(3))).(*paiGowTable) + wa, wb := &testWallet{chips: 100}, &testWallet{chips: 100} + tab.join("a") + tab.join("b") + if !tab.player("a").autoset { + t.Fatal("table config did not seed the seat's autoset") + } + tab.bet("a", "main", 10, wa) + tab.bet("b", "main", 10, wb) + p := tab.player("a") + p.cards = pgCards("As Ah 2c 3d 4s 7h 9c") + events := tab.action("a", "house") + if !p.ready || p.pending.high != nil { + t.Fatalf("autoset did not lock: ready=%v pending=%+v", p.ready, p.pending) + } + snap := events[len(events)-1].Snapshot.(*PaiGowSnapshot) + if snap.Preview || len(snap.Cards) != 0 { + t.Fatalf("autoset re-showed the hand: preview=%v cards=%d", snap.Preview, len(snap.Cards)) + } + if !strings.Contains(snap.Result, "waiting") { + t.Fatalf("waiting note missing: %q", snap.Result) + } +} + +func TestPaiGowAutosetToggle(t *testing.T) { + tab := pgTestTable(false) + w := &testWallet{chips: 100} + tab.join("a") + if events := tab.action("a", "autoset on"); !hasMessage(events, "Autoset is on.") { + t.Fatalf("autoset on failed: %+v", events) + } + tab.bet("a", "", 10, w) + p := tab.player("a") + p.cards = pgCards("As Ah 2c 3d 4s 7h 9c") + tab.action("a", "24") + // A lone bettor settles at once, so a locked hand shows as a showdown. + if p.showdown == nil && (!p.ready || p.pending.high != nil) { + t.Fatalf("toggled autoset did not lock: ready=%v pending=%+v", p.ready, p.pending) + } +} + func TestPaiGowSoloMainDealsAfterSideBets(t *testing.T) { tab := pgTestTable(false) w := &testWallet{chips: 100} diff --git a/internal/game/casino.go b/internal/game/casino.go index 66c90a1..b15c965 100644 --- a/internal/game/casino.go +++ b/internal/game/casino.go @@ -897,6 +897,54 @@ func cardsLine(mode string, cards []casino.CardView, size int, unicode bool, sty return lines } +// renderCardRow lays cards side by side, each column padded to its own art +// or label width, and appends an optional centered label row. minWidth lets +// the caller force a floor (video poker's HELD labels need it) while Pai Gow +// passes 0. It is the shared "cards with numbers underneath" presentation. +func renderCardRow(cards []casino.CardView, labels []string, minWidth int, mode string, unicode bool, size int, style string, art *cardart.Store) []string { + if len(cards) == 0 { + return nil + } + artLines := make([][]string, len(cards)) + widths := make([]int, len(cards)) + height := 0 + for i, card := range cards { + artLines[i] = cardsLine(mode, []casino.CardView{card}, size, unicode, style, art) + height = max(height, len(artLines[i])) + widths[i] = minWidth + for _, line := range artLines[i] { + widths[i] = max(widths[i], color.VisibleLen(line)) + } + if i < len(labels) { + widths[i] = max(widths[i], color.VisibleLen(labels[i])) + } + } + lines := make([]string, 0, height+1) + for row := 0; row < height; row++ { + parts := make([]string, len(cards)) + for i := range cards { + text := "" + if row < len(artLines[i]) { + text = artLines[i][row] + } + parts[i] = rouletteCenter(text, widths[i]) + } + lines = append(lines, strings.Join(parts, " ")) + } + if len(labels) > 0 { + parts := make([]string, len(cards)) + for i := range cards { + text := "" + if i < len(labels) { + text = labels[i] + } + parts[i] = rouletteCenter(text, widths[i]) + } + lines = append(lines, strings.Join(parts, " ")) + } + return lines +} + func cardRenderSize(unicode bool, size int) int { if size == 1 { return 1 diff --git a/internal/game/cmd_registry.go b/internal/game/cmd_registry.go index d8f6b76..acac68b 100644 --- a/internal/game/cmd_registry.go +++ b/internal/game/cmd_registry.go @@ -300,7 +300,7 @@ func (g *Game) executeCommand(sess *net.Session, cmd string, args []string, rawI if p != nil && g.Casino != nil { if t, playing := g.Casino.Participation(p.Name); playing && t != nil && t.Config.Game == casino.GamePaiGow { numeric := cmd != "" && cmd[0] >= '0' && cmd[0] <= '9' - if numeric || cmd == "set" || cmd == "house" || cmd == "settle" { + if numeric || cmd == "set" || cmd == "house" || cmd == "settle" || cmd == "autoset" { g.executeTableAction(sess, strings.Join(append([]string{cmd}, args...), " ")) return } diff --git a/internal/game/pai_gow_test.go b/internal/game/pai_gow_test.go index af6b749..9b37a68 100644 --- a/internal/game/pai_gow_test.go +++ b/internal/game/pai_gow_test.go @@ -39,19 +39,179 @@ func TestPaiGowCommandsLookAndSettlement(t *testing.T) { if classifyCommand("67") != ClassInstant || classifyCommand("13456") != ClassInstant { t.Fatal("numeric classification") } - // A numeric selection either locks a valid split or gives a clear foul; - // either way it must reach Pai Gow rather than the generic unknown verb. + // A numeric selection either previews a valid arrangement or gives a clear + // foul; either way it must reach Pai Gow rather than the generic unknown + // verb, and must not lock the hand before "set". conn.data = nil g.executeCommand(sess, "67", nil, "67") text = string(conn.data) - if table.Status(sess.Player.Name)[0].Snapshot.(*casino.PaiGowSnapshot).Phase == "setting" && !strings.Contains(text, "FOUL HAND") { + snap = table.Status(sess.Player.Name)[0].Snapshot.(*casino.PaiGowSnapshot) + if snap.Phase == "setting" && !strings.Contains(text, "FOUL HAND") && !strings.Contains(text, "arranged hand") { t.Fatal(text) } g.executeCommand(sess, "house", nil, "house") + g.executeCommand(sess, "set", nil, "set") if table.Status(sess.Player.Name)[0].Snapshot.(*casino.PaiGowSnapshot).Phase != "waiting" || len(sess.Player.PendingWagers) != 0 { t.Fatal("settlement did not finish") } } +func pgTestSnap() *casino.PaiGowSnapshot { + cv := func(rank, suit string) casino.CardView { return casino.CardView{Rank: rank, Suit: suit} } + return &casino.PaiGowSnapshot{ + Config: casino.TableConfig{Game: casino.GamePaiGow, PaiGow: casino.PaiGowConfig{Envy: true}}, + Phase: "setting", + Cards: []casino.CardView{cv("A", "diamonds"), cv("K", "clubs"), cv("9", "clubs"), cv("A", "spades"), cv("Q", "spades"), cv("2", "diamonds"), cv("6", "hearts")}, + } +} + +func TestPaiGowFeltOnlyOnJoin(t *testing.T) { + s := pgTestSnap() + s.ShowFelt = true + withFelt := stripANSI(renderPaiGow(s, "none", true, 7, "dark", nil)) + if !strings.Contains(withFelt, "FORTUNE") { + t.Fatalf("join did not show the felt:\n%s", withFelt) + } + s.ShowFelt = false + without := stripANSI(renderPaiGow(s, "none", true, 7, "dark", nil)) + if strings.Contains(without, "FORTUNE") || strings.Contains(without, "COMMISSION") { + t.Fatalf("felt leaked into an action:\n%s", without) + } +} + +func TestPaiGowCardsShowNumbersUnderneath(t *testing.T) { + s := pgTestSnap() + out := stripANSI(renderPaiGow(s, "none", true, 7, "dark", nil)) + if !strings.Contains(out, "Your seven cards") { + t.Fatalf("missing dealt-hand header:\n%s", out) + } + // The old side-by-side position key must be gone; the numbers now sit + // under the cards, one per column. + if strings.Contains(out, "1=A") || strings.Contains(out, "=") { + t.Fatalf("stale position key still present:\n%s", out) + } + for _, n := range []string{"1", "2", "3", "4", "5", "6", "7"} { + if !strings.Contains(out, n) { + t.Fatalf("missing position %s:\n%s", n, out) + } + } +} + +func TestPaiGowArrangedPreviewHighThenLow(t *testing.T) { + s := pgTestSnap() + s.Preview = true + s.PreviewHigh = s.Cards[:5] + s.PreviewLow = s.Cards[5:] + s.PreviewHighNums = []int{1, 2, 3, 4, 5} + s.PreviewLowNums = []int{6, 7} + out := stripANSI(renderPaiGow(s, "none", true, 7, "dark", nil)) + if !strings.Contains(out, "HIGH") || !strings.Contains(out, "LOW") { + t.Fatalf("missing HIGH/LOW labels:\n%s", out) + } + // The five-card high hand precedes the two-card low hand, and its number + // row still keys back to the dealt positions. + hi := strings.Index(out, "HIGH") + lo := strings.Index(out, "LOW") + if hi < 0 || lo < 0 || hi > lo { + t.Fatalf("HIGH/LOW order wrong:\n%s", out) + } + if !strings.Contains(out, "arranged hand") { + t.Fatalf("missing set prompt:\n%s", out) + } +} + +func TestPaiGowShowdownOrderNamesAndColors(t *testing.T) { + cv := func(rank, suit string) casino.CardView { return casino.CardView{Rank: rank, Suit: suit} } + s := pgTestSnap() + s.Phase = "waiting" + s.Showdown = &casino.PaiGowShowdown{ + Result: "loss", + Hands: []casino.PaiGowHandView{ + {Label: "Your high", Strength: "STRAIGHT ACE-HIGH", Cards: []casino.CardView{cv("A", "diamonds"), cv("K", "clubs"), cv("Q", "clubs"), cv("J", "spades"), cv("10", "spades")}, Winning: true, HasWin: true}, + {Label: "Dealer high", Strength: "TWO PAIR ACES AND KINGS", Cards: []casino.CardView{cv("A", "clubs"), cv("A", "hearts"), cv("K", "diamonds"), cv("K", "spades"), cv("9", "clubs")}, Winning: false, HasWin: true}, + {Label: "Your low", Strength: "PAIR OF ACES", Cards: []casino.CardView{cv("A", "diamonds"), cv("2", "diamonds")}, Winning: false, HasWin: true}, + {Label: "Dealer low", Strength: "HIGH CARD KING", Cards: []casino.CardView{cv("K", "clubs"), cv("9", "hearts")}, Winning: true, HasWin: true}, + }, + Stake: 25, Payout: 50, Paid: true, + } + raw := renderPaiGow(s, "xterm256", true, 7, "dark", nil) + clean := stripANSI(raw) + high := strings.Index(clean, "Your high") + dealerHigh := strings.Index(clean, "Dealer high") + low := strings.Index(clean, "Your low") + dealerLow := strings.Index(clean, "Dealer low") + if !(high >= 0 && high < dealerHigh && dealerHigh < low && low < dealerLow) { + t.Fatalf("showdown order wrong:\n%s", clean) + } + for _, want := range []string{"STRAIGHT ACE-HIGH", "TWO PAIR ACES AND KINGS", "PAIR OF ACES", "HIGH CARD KING", "DEALER WINS THE ROUND", "Net +25 credits."} { + if !strings.Contains(clean, want) { + t.Fatalf("showdown missing %q:\n%s", want, clean) + } + } + // Winning hand strengths are bold yellow; losing ones are bold white. + if !strings.Contains(raw, "38;5;220m") { + t.Fatalf("winning strength not yellow:\n%q", raw) + } + if !strings.Contains(raw, "38;5;15m") { + t.Fatalf("losing strength not white:\n%q", raw) + } +} + +func TestPaiGowOutcomeOmitsEnvyWithoutOthers(t *testing.T) { + s := pgTestSnap() + s.Phase = "waiting" + s.Showdown = &casino.PaiGowShowdown{ + Result: "win", + Hands: []casino.PaiGowHandView{ + {Label: "Your high", Strength: "PAIR OF KINGS", Winning: true, HasWin: true}, + {Label: "Dealer high", Strength: "HIGH CARD ACE", Winning: false, HasWin: true}, + {Label: "Your low", Strength: "PAIR OF ACES", Winning: true, HasWin: true}, + {Label: "Dealer low", Strength: "HIGH CARD KING", Winning: false, HasWin: true}, + }, + Payout: 20, Paid: true, + } + out := stripANSI(renderPaiGow(s, "none", true, 7, "dark", nil)) + if strings.Contains(out, "Envy") { + t.Fatalf("envy shown without other players:\n%s", out) + } + if !strings.Contains(out, "YOU WIN THE ROUND") { + t.Fatalf("overall win missing:\n%s", out) + } +} + +func TestPaiGowSetCommandLocksPreview(t *testing.T) { + g, sess, conn := casinoCrapsGame(t, "name: Pai Gow\ncasino_tables:\n - id: fortune\n game: paigow\n min_bet: 5\n max_bet: 1000\n") + g.executePlay(sess, []string{"pai"}, "play pai") + g.executeBet(sess, []string{"20"}, "bet 20") + table, _ := g.Casino.Participation(sess.Player.Name) + // house only arranges; the hand stays unset until set. + conn.data = nil + g.executeCommand(sess, "house", nil, "house") + snap := table.Status(sess.Player.Name)[0].Snapshot.(*casino.PaiGowSnapshot) + if !snap.Preview || table.State() != "setting" { + t.Fatalf("house should preview, not settle: preview=%v phase=%s", snap.Preview, table.State()) + } + // set locks and, for a lone bettor, settles the round. + g.executeCommand(sess, "set", nil, "set") + if table.State() != "waiting" { + t.Fatalf("set did not settle the round: %s", table.State()) + } +} + +func TestPaiGowAutosetConfigSkipsPreview(t *testing.T) { + g, sess, conn := casinoCrapsGame(t, "name: Pai Gow\ncasino_tables:\n - id: fortune\n game: paigow\n min_bet: 5\n max_bet: 1000\n pai_gow:\n autoset: true\n") + g.executePlay(sess, []string{"pai"}, "play pai") + g.executeBet(sess, []string{"20"}, "bet 20") + conn.data = nil + g.executeCommand(sess, "house", nil, "house") + text := stripANSI(string(conn.data)) + if strings.Contains(text, "arranged hand") { + t.Fatalf("autoset showed the arrangement preview:\n%s", text) + } + if !strings.Contains(text, "DEALER WINS") && !strings.Contains(text, "YOU WIN") && !strings.Contains(text, "PUSH") { + t.Fatalf("autoset did not reach the showdown:\n%s", text) + } +} + func TestPaiGowFeltAndJokerAllModes(t *testing.T) { store := cardart.Load("../../data/cards.yaml") for _, size := range []int{1, 5, 7, 9} { @@ -228,8 +388,10 @@ func TestPaiGowStopMidRoundQueuesDeparture(t *testing.T) { if _, playing := g.Casino.Participation(sess.Player.Name); !playing { t.Fatal("stop mid-round removed the seat too early") } - // Finishing the hand applies the queued departure. + // Finishing the hand applies the queued departure. Autoset is off, so + // house arranges and set commits. g.executeCommand(sess, "house", nil, "house") + g.executeCommand(sess, "set", nil, "set") if _, playing := g.Casino.Participation(sess.Player.Name); playing { t.Fatal("queued stop did not leave after the round") } diff --git a/internal/game/render_pai_gow.go b/internal/game/render_pai_gow.go index d364c61..4b31373 100644 --- a/internal/game/render_pai_gow.go +++ b/internal/game/render_pai_gow.go @@ -6,6 +6,13 @@ import ( "thehouseoficarus/internal/cardart" "thehouseoficarus/internal/casino" + "thehouseoficarus/internal/color" +) + +var ( + paiGowWin = color.ColorSpec{Fg: color.NearestXterm256(80, 220, 100), Bold: true} + paiGowLoss = color.ColorSpec{Fg: color.NearestXterm256(240, 80, 80), Bold: true} + paiGowPush = color.ColorSpec{Fg: 15, Bold: true} ) func renderPaiGowFelt(cfg casino.TableConfig, mode string, unicode bool) string { @@ -55,30 +62,190 @@ func renderPaiGowFelt(cfg casino.TableConfig, mode string, unicode bool) string c.center(1, 75, 22, joker, crapsMuted) return c.render(mode, unicode) } + +// paiGowNumberLabels centers the card's original position number under it. +func paiGowNumberLabels(nums []int, mode string) []string { + labels := make([]string, len(nums)) + for i, n := range nums { + labels[i] = color.Render(mode, color.ColorSpec{Fg: 15, Bold: true}, fmt.Sprintf("%d", n)) + } + return labels +} + +// paiGowCardBlock renders one labeled run of cards: an optional centered +// header over the card art and, when nums is set, position numbers below. +func paiGowCardBlock(header string, cards []casino.CardView, nums []int, mode string, unicode bool, size int, style string, store *cardart.Store) []string { + if len(cards) == 0 { + return nil + } + var labels []string + if len(nums) > 0 { + labels = paiGowNumberLabels(nums, mode) + } + lines := renderCardRow(cards, labels, 0, mode, unicode, size, style, store) + if header == "" { + return lines + } + width := 0 + for _, line := range lines { + width = max(width, color.VisibleLen(line)) + } + return append([]string{rouletteCenter(header, width)}, lines...) +} + +// paiGowJoinBlocks sets two card blocks side by side with a gutter between +// them, so the high hand and the low hand read as separate groups. +func paiGowJoinBlocks(left, right []string, gutter string) []string { + height := max(len(left), len(right)) + leftWidth := 0 + for _, line := range left { + leftWidth = max(leftWidth, color.VisibleLen(line)) + } + lines := make([]string, 0, height) + for i := 0; i < height; i++ { + l, r := "", "" + if i < len(left) { + l = left[i] + } + if i < len(right) { + r = right[i] + } + lines = append(lines, l+strings.Repeat(" ", leftWidth-color.VisibleLen(l))+gutter+r) + } + return lines +} + +// paiGowStrength paints a hand's name yellow when it won its comparison and +// white when it did not. +func paiGowStrength(mode, strength string, winning bool) string { + spec := color.ColorSpec{Fg: 15, Bold: true} + if winning { + spec = color.ColorSpec{Fg: pokerYellow, Bold: true} + } + return color.Render(mode, spec, strength) +} + +// paiGowArranged renders the preview: the five-card high hand, a gap, then the +// two-card low hand, each with its original position numbers underneath. +func paiGowArranged(s *casino.PaiGowSnapshot, mode string, unicode bool, size int, style string, store *cardart.Store) []string { + high := paiGowCardBlock(color.Render(mode, paiGowPush, "HIGH"), s.PreviewHigh, s.PreviewHighNums, mode, unicode, size, style, store) + low := paiGowCardBlock(color.Render(mode, paiGowPush, "LOW"), s.PreviewLow, s.PreviewLowNums, mode, unicode, size, style, store) + lines := []string{"Your arranged hand — high first, then low. Type set to lock it, or choose again:"} + return append(lines, paiGowJoinBlocks(high, low, " ")...) +} + +// paiGowOutcome renders the comparison narrative and settlement summary. +func paiGowOutcome(sd *casino.PaiGowShowdown, mode string) []string { + var lines []string + if len(sd.Hands) >= 4 { + switch { + case sd.Hands[0].Winning && sd.Hands[2].Winning: + lines = append(lines, "You take both hands.") + case !sd.Hands[0].Winning && !sd.Hands[2].Winning: + lines = append(lines, "The dealer takes both hands.") + default: + high, low := "You take the high hand", "you take the low hand" + if !sd.Hands[0].Winning { + high = "The dealer takes the high hand" + } + if !sd.Hands[2].Winning { + low = "the dealer takes the low hand" + } + lines = append(lines, high+"; "+low+".") + } + } + spec, label := paiGowLoss, "DEALER WINS THE ROUND" + switch { + case strings.HasPrefix(sd.Result, "win"): + spec, label = paiGowWin, "YOU WIN THE ROUND" + case strings.HasPrefix(sd.Result, "push"): + spec, label = paiGowPush, "PUSH — YOUR WAGER IS RETURNED" + if detail := strings.Trim(strings.TrimPrefix(sd.Result, "push"), " ()"); detail != "" { + label = "PUSH (" + strings.ToUpper(detail) + ") — YOUR WAGER IS RETURNED" + } + } + lines = append(lines, color.Render(mode, spec, label)) + if sd.Fortune != "" { + if sd.FortuneWin { + lines = append(lines, "Fortune: "+color.Render(mode, paiGowWin, strings.ToUpper(sd.Fortune)+" — WIN")) + } else { + lines = append(lines, "Fortune: no win.") + } + } + if sd.PushAce != "" { + if sd.PushAceWin { + lines = append(lines, "Push Ace High: "+color.Render(mode, paiGowWin, sd.PushAce+" — WIN")) + } else { + lines = append(lines, "Push Ace High: "+sd.PushAce+".") + } + } + if sd.ShowEnvy && sd.Envy > 0 { + lines = append(lines, "Envy: "+color.Render(mode, paiGowWin, fmt.Sprintf("+%d credits", sd.Envy))) + } + switch { + case sd.Pending: + lines = append(lines, color.Render(mode, paiGowLoss, "PAYMENT PENDING — type settle to retry the same result.")) + case sd.Paid: + net := sd.Payout - sd.Stake + netSpec, netText := paiGowPush, "Net 0 credits." + if net > 0 { + netSpec, netText = paiGowWin, fmt.Sprintf("Net +%d credits.", net) + } else if net < 0 { + netSpec, netText = paiGowLoss, fmt.Sprintf("Net %d credits.", net) + } + lines = append(lines, color.Render(mode, netSpec, netText)) + } + return lines +} + +func paiGowShowdown(s *casino.PaiGowSnapshot, mode string, unicode bool, size int, style string, store *cardart.Store) []string { + sd := s.Showdown + var lines []string + for i, hand := range sd.Hands { + header := hand.Label + if hand.Strength != "" { + header += " (" + paiGowStrength(mode, hand.Strength, hand.HasWin && hand.Winning) + ")" + } + lines = append(lines, paiGowCardBlock(header, hand.Cards, nil, mode, unicode, size, style, store)...) + if i == 1 { + lines = append(lines, "") + } + } + lines = append(lines, "") + return append(lines, paiGowOutcome(sd, mode)...) +} + func renderPaiGow(s *casino.PaiGowSnapshot, mode string, unicode bool, size int, style string, store *cardart.Store) string { size = cardRenderSize(unicode, size) - lines := []string{renderPaiGowFelt(s.Config, mode, unicode)} - if len(s.Cards) > 0 { + var lines []string + if s.ShowFelt { + lines = append(lines, renderPaiGowFelt(s.Config, mode, unicode)) + } + switch { + case s.Showdown != nil: + lines = append(lines, paiGowShowdown(s, mode, unicode, size, style, store)...) + case s.Preview: + lines = append(lines, paiGowArranged(s, mode, unicode, size, style, store)...) + case len(s.Cards) > 0: lines = append(lines, "Your seven cards (positions 1-7):") - lines = append(lines, cardsLine(mode, s.Cards, size, unicode, style, store)...) - // Position labels use each card's actual rendered width; compact cards are - // variable-width, so print a separate unambiguous position-to-card key. - var positions []string - for i, c := range s.Cards { - positions = append(positions, fmt.Sprintf("%d=%s", i+1, cardSymbol(c))) - } - lines = append(lines, strings.Join(positions, " ")) - } - for _, h := range []struct { - label string - cards []casino.CardView - }{{"Your high", s.High}, {"Your low", s.Low}, {"Dealer high", s.DealerHigh}, {"Dealer low", s.DealerLow}, {"Tiebreaker: player / dealer", s.Tiebreaker}} { - if len(h.cards) > 0 { - lines = append(lines, h.label+":") - lines = append(lines, cardsLine(mode, h.cards, size, unicode, style, store)...) + nums := make([]int, len(s.Cards)) + for i := range nums { + nums[i] = i + 1 } + lines = append(lines, paiGowCardBlock("", s.Cards, nums, mode, unicode, size, style, store)...) + } + // Face Up reveals the dealer before the player's decision; the showdown + // replaces this block once it exists. + if s.Showdown == nil && len(s.DealerHigh) > 0 { + lines = append(lines, "") + lines = append(lines, paiGowCardBlock("Dealer high ("+s.DealerHighName+")", s.DealerHigh, nil, mode, unicode, size, style, store)...) + lines = append(lines, paiGowCardBlock("Dealer low ("+s.DealerLowName+")", s.DealerLow, nil, mode, unicode, size, style, store)...) + } + if len(s.Tiebreaker) > 0 { + lines = append(lines, "Tiebreaker — player / dealer:") + lines = append(lines, paiGowCardBlock("", s.Tiebreaker, nil, mode, unicode, size, style, store)...) } - if s.Result != "" { + if s.Showdown == nil && s.Result != "" { lines = append(lines, s.Result) } return strings.Join(lines, "\n") diff --git a/internal/game/render_video_poker.go b/internal/game/render_video_poker.go index b8baef8..b8262c8 100644 --- a/internal/game/render_video_poker.go +++ b/internal/game/render_video_poker.go @@ -87,43 +87,20 @@ func renderPokerCards(cards []casino.CardView, held *[5]bool, mode string, unico if len(cards) == 0 { return nil } - art := make([][]string, len(cards)) - widths := make([]int, len(cards)) - height := 0 - for i, c := range cards { - art[i] = cardsLine(mode, []casino.CardView{c}, size, unicode, style, store) - height = max(height, len(art[i])) - for _, line := range art[i] { - widths[i] = max(widths[i], color.VisibleLen(line)) - } - if held != nil { - widths[i] = max(widths[i], 6) - } - } - var lines []string - for r := 0; r < height; r++ { - parts := make([]string, len(cards)) - for i := range cards { - text := "" - if r < len(art[i]) { - text = art[i][r] - } - parts[i] = rouletteCenter(text, widths[i]) - } - lines = append(lines, strings.Join(parts, " ")) - } + var labels []string + minWidth := 0 if held != nil { - labels := make([]string, len(cards)) + labels = make([]string, len(cards)) + minWidth = 6 for i := range cards { text := fmt.Sprint(i + 1) if held[i] { text = color.Render(mode, color.ColorSpec{Fg: pokerYellow, Bg: -1, Bold: true}, text+" HELD") } - labels[i] = rouletteCenter(text, widths[i]) + labels[i] = text } - lines = append(lines, strings.Join(labels, " ")) } - return lines + return renderCardRow(cards, labels, minWidth, mode, unicode, size, style, store) } func renderVideoPoker(s *casino.VideoPokerSnapshot, mode string, unicode bool, size int, style string, store *cardart.Store) string { |
