package game import ( "strings" "testing" "unicode/utf8" "thehouseoficarus/internal/cardart" "thehouseoficarus/internal/casino" "thehouseoficarus/internal/world" ) func TestPaiGowCommandsLookAndSettlement(t *testing.T) { g, sess, conn := casinoCrapsGame(t, "name: Pai Gow\ncasino_tables:\n - id: fortune\n game: paigow\n pai_gow:\n envy: true\n") g.executePlay(sess, []string{"pai"}, "play pai") table, playing := g.Casino.Participation(sess.Player.Name) if !playing || table.Config.Game != casino.GamePaiGow { t.Fatal("alias failed") } conn.data = nil g.executeTableStatus(sess, nil, "table") text := stripANSI(string(conn.data)) for _, want := range []string{"FORTUNE", "ENVY", "750:1", "Three pair"} { if !strings.Contains(text, want) { t.Fatalf("missing %s: %s", want, text) } } g.executeBet(sess, []string{"5", "fortune"}, "bet 5 fortune") g.executeBet(sess, []string{"20"}, "bet 20") snap := table.Status(sess.Player.Name)[0].Snapshot.(*casino.PaiGowSnapshot) if len(snap.Cards) != 7 || len(snap.DealerHigh) != 0 || len(sess.Player.PendingWagers) != 2 { t.Fatal("deal/side bet") } conn.data = nil g.executeCommand(sess, "22", nil, "22") if text = string(conn.data); !strings.Contains(text, "different numbers") { t.Fatal(text) } if classifyCommand("67") != ClassInstant || classifyCommand("13456") != ClassInstant { t.Fatal("numeric classification") } // 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) 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) } } // The hand labels are left-justified normal text, never centered over the // card art. for _, line := range strings.Split(clean, "\n") { if strings.Contains(line, "Your high") || strings.Contains(line, "Dealer high") { if strings.HasPrefix(line, " ") { t.Fatalf("showdown label is indented, not left justified: %q", line) } } } // 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} { if len(store.Art("JK", size)) != size { t.Fatalf("missing JK art at %d", size) } for _, unicode := range []bool{false, true} { for _, style := range []string{"dark", "light"} { for _, art := range []*cardart.Store{nil, store} { lines := jokerCardArt(size, unicode, style, art) if len(lines) != size { t.Fatal(size, lines) } for _, line := range lines { if size > 1 && utf8.RuneCountInString(line) != size { t.Fatalf("%d width: %q", size, line) } if !unicode { for _, r := range line { if r > 127 { t.Fatalf("ASCII Joker has Unicode: %q", line) } } } } for _, mode := range []string{"none", "xterm256", "truecolor"} { out := cardsLine(mode, []casino.CardView{{Rank: "JK", Suit: "joker"}}, cardRenderSize(unicode, size), unicode, style, art) if !strings.Contains(strings.Join(out, "\n"), "J") { t.Fatal(out) } } } } } } for _, envy := range []bool{false, true} { for _, unicode := range []bool{false, true} { cfg := casino.TableConfig{Game: casino.GamePaiGow, PaiGow: casino.PaiGowConfig{Envy: envy}} felt := renderPaiGowFelt(cfg, "none", unicode) lines := strings.Split(strings.TrimRight(felt, "\n"), "\n") width := utf8.RuneCountInString(lines[0]) for _, line := range lines { if utf8.RuneCountInString(line) != width { t.Fatal("jagged felt", line) } } if !strings.Contains(felt, "Three pair") || !strings.Contains(felt, "1000:1") { t.Fatal(felt) } // The felt shows only the limit and commission; the per-state command // hints come from the live menu, not baked-in felt text. for _, want := range []string{"LIMITS", "ODDS", "COMMISSION", "Copies lose"} { if !strings.Contains(felt, want) { t.Fatalf("missing %q: %s", want, felt) } } for _, gone := range []string{"PROFIT ODDS", "FIVE HIGH / TWO LOW", "MAIN WIN", "24 sets cards", "Bet main, optional"} { if strings.Contains(felt, gone) { t.Fatalf("stale felt text %q: %s", gone, felt) } } if !envy && (!strings.Contains(felt, "ENVY OFF") || !strings.Contains(felt, "30:1")) { t.Fatal(felt) } if cfg.PaiGow.Envy && (strings.Contains(felt, "ENVY OFF") || !strings.Contains(felt, "ENVY") || !strings.Contains(felt, "Envy pays when other players hit quads or better.")) { t.Fatal(felt) } } } cfg := casino.TableConfig{MinBet: 5, MaxBet: 1000, Game: casino.GamePaiGow, PaiGow: casino.PaiGowConfig{Variant: casino.PaiGowFaceUp, PushAceHigh: "5_15_40"}} felt := renderPaiGowFelt(cfg, "none", true) if !strings.Contains(felt, "DEALER FACE UP") || !strings.Contains(felt, "PUSH ACE HIGH 5/15/40:1") { t.Fatal(felt) } } func TestPaiGowContextHintsFollowState(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 envy: true\n") g.executePlay(sess, []string{"pai"}, "play pai") // A fresh bettor is told about the side bet and the main wager, plus the // shared tail. conn.data = nil g.executeTableStatus(sess, nil, "table") text := stripANSI(string(conn.data)) for _, want := range []string{"bet fortune", "bet ", "table", "rules", "stop"} { if !strings.Contains(text, want) { t.Fatalf("missing %q at a fresh table: %s", want, text) } } // A lone main wager deals immediately; the live menu moves to setting. conn.data = nil g.executeBet(sess, []string{"20"}, "bet 20") text = stripANSI(string(conn.data)) for _, want := range []string{"24 / 13456", "house"} { if !strings.Contains(text, want) { t.Fatalf("missing %q after the main wager: %s", want, text) } } } func TestVestaPaiGowRooms(t *testing.T) { _, rooms, _ := loadVesta(t) for id, variant := range map[int]string{6195: casino.PaiGowClassic, 6196: casino.PaiGowEZ, 6197: casino.PaiGowNoPush, 6198: casino.PaiGowFaceUp} { r, ok := rooms[id] if !ok || len(r.CasinoTables) != 1 { t.Fatalf("missing Pai Gow room %d", id) } cfg := r.CasinoTables[0] if cfg.Validate() != nil || cfg.Game != casino.GamePaiGow || cfg.PaiGow.Variant != variant || cfg.PaiGow.Envy != (id != 6197) { t.Fatalf("bad Pai Gow configuration: %+v", cfg) } } if rooms[6192].Exits[world.East].Room != 6195 || rooms[6195].Exits[world.West].Room != 6192 || rooms[6195].Exits[world.North].Room != 6196 || rooms[6196].Exits[world.South].Room != 6195 || rooms[6195].Exits[world.South].Room != 6197 || rooms[6197].Exits[world.North].Room != 6195 || rooms[6196].Exits[world.North].Room != 6198 || rooms[6198].Exits[world.South].Room != 6196 { t.Fatal("broken Pai Gow navigation") } } func TestJokerEmptySuitDoesNotPanic(t *testing.T) { for _, size := range []int{5, 7, 9} { if len(cardArtLines(casino.CardView{Rank: "JK"}, size, true, "dark", nil)) != size { t.Fatal(size) } } } func pgBoolPtr(b bool) *bool { return &b } func TestPaiGowFeltEndsAtBottomBorderAndNotesA2345(t *testing.T) { on := renderPaiGowFelt(casino.TableConfig{Game: casino.GamePaiGow}, "none", false) if !strings.Contains(on, "A2345 is second-highest straight.") { t.Fatalf("enabled felt is missing the A2345 rule:\n%s", on) } lines := strings.Split(strings.TrimRight(on, "\n"), "\n") // The bottom border is the final row and the joker note sits directly // above it: the old blank interior row is gone. if last := lines[len(lines)-1]; !strings.HasPrefix(last, "+") { t.Fatalf("felt does not end on its bottom border: %q", last) } if !strings.Contains(lines[len(lines)-2], "Joker:") { t.Fatalf("blank line below the joker note: %q", lines[len(lines)-2]) } offFelt := renderPaiGowFelt(casino.TableConfig{Game: casino.GamePaiGow, PaiGow: casino.PaiGowConfig{A2345SecondHighest: pgBoolPtr(false)}}, "none", false) if strings.Contains(offFelt, "A2345 is second-highest straight.") { t.Fatal("disabled felt still advertises the A2345 rule") } } func TestPaiGowEnvyColumnCentersUnderLabel(t *testing.T) { cfg := casino.TableConfig{Game: casino.GamePaiGow, PaiGow: casino.PaiGowConfig{Envy: true}} lines := strings.Split(renderPaiGowFelt(cfg, "none", false), "\n") header := lines[5] row := lines[6] hi := strings.Index(header, "ENVY") vi := strings.Index(row, "500") if hi < 0 || vi < 0 { t.Fatalf("missing header/value:\n%s\n%s", header, row) } // A value must sit within the label's four-column span. if vi < hi || vi+len("500") > hi+len("ENVY") { t.Fatalf("ENVY header and value are not aligned: header=%d value=%d\n%s\n%s", hi, vi, header, row) } } func TestPaiGowStopMidRoundQueuesDeparture(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") // A lone main wager deals at once, so stop reaches the committed-round path. g.executeBet(sess, []string{"20"}, "bet 20") conn.data = nil g.executeCommand(sess, "stop", nil, "stop") text := stripANSI(string(conn.data)) if !strings.Contains(text, "leave after this round") { t.Fatalf("stop mid-round did not queue a departure: %s", text) } 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. 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") } }