package game import ( "fmt" "strings" "testing" "unicode/utf8" "thehouseoficarus/internal/casino" "thehouseoficarus/internal/color" ) func TestPlinkoCommands(t *testing.T) { g, s, conn := casinoCrapsGame(t, "name: Plinko\ncasino_tables:\n - id: plinko\n game: plinko\n") g.executePlay(s, []string{"plinko"}, "play plinko") g.executeCommand(s, "difficulty", []string{"expert"}, "difficulty expert") g.executeCommand(s, "balls", []string{"10"}, "balls 10") table, ok := g.Casino.Participation(s.Player.Name) if !ok || table == nil { t.Fatal("not seated") } snap := table.Status(s.Player.Name)[0].Snapshot.(*casino.PlinkoSnapshot) if snap.Total != 10 || snap.Risk != "expert" || snap.Pockets[0] != 10000 { t.Fatalf("commands failed: %+v", snap) } g.executeBet(s, []string{"1"}, "bet 1") for _, removed := range []string{"Welcome to PLINKO", "*** PLINKO", "=== P L I N K O", "White = one ball", "Matching letters share", "(base "} { if strings.Contains(string(conn.data), removed) { t.Fatalf("obsolete prose still printed: %q", removed) } } if !table.Busy(s.Player.Name) || len(s.Player.PendingWagers) != 1 { t.Fatal("drop did not start") } conn.data = nil for i := 0; i < 32 && table.Busy(s.Player.Name); i++ { g.emitCasinoEvents(g.Casino.Tick()) } if table.Busy(s.Player.Name) || len(s.Player.PendingWagers) != 0 { t.Fatal("drop did not settle") } text := stripANSI(string(conn.data)) if strings.Contains(text, "->") || strings.Count(text, "Returned:") != 1 || strings.Contains(text, "credits returned") || strings.Contains(text, "net +") { t.Fatal(text) } if !strings.Contains(text, "Wager") || !strings.Contains(text, "Staked") { t.Fatalf("settlement receipt missing: %s", text) } conn.data = nil g.doLookTarget(s, "plinko") if !strings.Contains(stripANSI(string(conn.data)), "% RTP:") { t.Fatal("look missing paytable") } } func TestPlinkoBallShades(t *testing.T) { if plinkoBallColor(1, 1) != color.NearestXterm256(255, 255, 255) { t.Fatal("single must be white") } for total := 2; total <= 10; total++ { last := 256 for count := 1; count <= total; count++ { r, g, b := color.Xterm256ToRGB(plinkoBallColor(count, total)) if r != 255 || g != b || g > last { t.Fatal("invalid white-red gradient") } last = g } if last != 0 { t.Fatal("all balls must be pure red") } } } func TestPlinkoRenderRows(t *testing.T) { for _, mode := range []string{"none", "ansi", "xterm256"} { for _, unicode := range []bool{false, true} { for row := 0; row <= 16; row++ { s := &casino.PlinkoSnapshot{Row: row, Total: 10} s.Counts[0] = 10 out := renderPlinko(s, mode, unicode) for _, line := range strings.Split(stripANSI(out), "\n") { if utf8.RuneCountInString(line) != plinkoWidth { t.Fatal("board must keep its full width, including exterior spaces") } } wantLines := 1 if row == plinkoRows { wantLines = 2 // base rail } if got := len(strings.Split(out, "\n")); got != wantLines { t.Fatalf("row %d printed %d lines, want %d", row, got, wantLines) } switch mode { case "none": if strings.Contains(out, "\x1b") { t.Fatal("plain board must not contain escapes") } case "ansi", "xterm256": // The board is background-free: the player's terminal theme // shows through instead of a fixed gradient. for bg := 0; bg < 256; bg++ { if strings.Contains(out, color.BgCode(mode, bg)) { t.Fatalf("%s board must not paint a background", mode) } } } if mode == "none" && !strings.Contains(out, "*") { t.Fatal("plain overlap fallback missing") } } } } } func TestPlinkoUsesSharedHints(t *testing.T) { for _, mode := range []string{"on", "once", "off"} { t.Run(mode, func(t *testing.T) { g, s, conn := casinoCrapsGame(t, "name: Plinko\ncasino_tables:\n - id: plinko\n game: plinko\n") s.Player.Options = map[string]any{"color": "none", "unicode": true, "casino_hints": mode} g.executePlay(s, []string{"plinko"}, "play plinko") text := string(conn.data) if strings.Contains(text, "difficulty easy|medium|hard|expert") != (mode != "off") { t.Fatalf("join hints (%s): %s", mode, text) } if mode != "off" && strings.Index(text, "% RTP:") > strings.Index(text, "bet N") { t.Fatal("hints must follow the paytable") } conn.data = nil g.executeTableAction(s, "balls 2") if strings.Contains(string(conn.data), "bet N") != (mode == "on") { t.Fatalf("settings hints (%s): %s", mode, conn.data) } g.executeBet(s, []string{"1"}, "bet 1") table, _ := g.Casino.Participation(s.Player.Name) for i := 0; i < 32 && table.Busy(s.Player.Name); i++ { conn.data = nil g.emitCasinoEvents(g.Casino.Tick()) } if strings.Contains(string(conn.data), "bet N") != (mode == "on") { t.Fatalf("settlement hints (%s): %s", mode, conn.data) } conn.data = nil g.executeVerbs(s, nil, "what") if !strings.Contains(string(conn.data), "balls N") { t.Fatal("explicit hints missing") } }) } } func plinkoTestBoardRow(s *casino.PlinkoSnapshot) []rune { lines := strings.Split(renderPlinko(s, "none", true), "\n") return []rune(lines[0]) } func TestPlinkoPegsNeverMove(t *testing.T) { pockets := [17]float64{16, 7.8, 5.9, 4.4, 3.1, 2, 1.1, 0.5, 0.2, 0.5, 1.1, 2, 3.1, 4.4, 5.9, 7.8, 16} for row := 0; row <= 16; row++ { s := &casino.PlinkoSnapshot{Row: row, Total: 1, Pockets: pockets} empty := plinkoTestBoardRow(s) left, right := 32-2*row, 36+2*row pegs := map[int]bool{} for k := 1; k <= row; k++ { x := left + 4*k pegs[x] = true if empty[x] != '·' { t.Fatalf("row %d: peg missing at its true position %d", row, x) } } if right < plinkoWidth && empty[right] != '\\' || empty[left] != '/' { t.Fatalf("row %d: border moved", row) } for lane := 0; lane <= row; lane++ { s.Counts = [17]int{} s.Counts[lane] = 1 filled := plinkoTestBoardRow(s) x := 34 - 2*row + 4*lane if filled[x] != '●' { t.Fatalf("ball not in its own lane: row %d lane %d", row, lane) } for px := range pegs { if filled[px] != '·' { t.Fatalf("row %d lane %d overwrote peg at %d", row, lane, px) } } } } } func TestPlinkoHintsOverlayReminderRow(t *testing.T) { pockets := [17]float64{16, 7.8, 5.9, 4.4, 3.1, 2, 1.1, 0.5, 0.2, 0.5, 1.1, 2, 3.1, 4.4, 5.9, 7.8, 16} labels := casino.PlinkoPocketLabels(pockets) for row := 0; row <= 16; row++ { s := &casino.PlinkoSnapshot{Row: row, Total: 1, Pockets: pockets} lines := strings.Split(stripANSI(renderPlinko(s, "none", true)), "\n") wantLines := 1 if row == plinkoRows { wantLines = 2 } if len(lines) != wantLines { t.Fatalf("row %d printed %d lines, want %d", row, len(lines), wantLines) } board := []rune(lines[0]) for k, label := range labels { x := 2 + 4*k if row == plinkoHintRow { if board[x] != label { t.Fatalf("row %d: reminder %c missing at pocket column %d (got %q)", row, label, x, board[x]) } } else if board[x] == label { t.Fatalf("row %d: reminder %c leaked into the board at %d", row, label, x) } } } } func TestPlinkoLegendDownTheLeft(t *testing.T) { s := &casino.PlinkoSnapshot{Pockets: [17]float64{16, 7.8, 5.9, 4.4, 3.1, 2, 1.1, 0.5, 0.2, 0.5, 1.1, 2, 3.1, 4.4, 5.9, 7.8, 16}, Total: 1} labels := casino.PlinkoPocketLabels(s.Pockets) if string(labels[:]) != "ABCDEFGHIHGFEDCBA" { t.Fatalf("wrong labels: %s", string(labels[:])) } entries := plinkoLegendEntries(s) if len(entries) != 9 { t.Fatalf("want 9 distinct legend entries, got %d", len(entries)) } for row := 0; row < len(entries); row++ { s.Row = row line := string(plinkoTestBoardRow(s)) want := fmt.Sprintf("%c %s", entries[row].label, entries[row].text) if !strings.HasPrefix(line[1:], want) { t.Fatalf("row %d: legend entry not on the left: %q", row, line) } if entries[row].label == 'A' && !strings.Contains(line, "16x") { t.Fatal("legend missing the 16x edge payout") } } // The legend stays clear of the pyramid as it widens. s.Row = len(entries) - 1 if left := 32 - 2*s.Row; left <= 10 { t.Fatalf("legend would collide with the pyramid: left border %d", left) } // The standalone paytable prints the same entries on one line. s.Row = 0 paytable := stripANSI(renderPlinko(&casino.PlinkoSnapshot{Pockets: s.Pockets, Risk: "easy", Paytable: true}, "none", true)) for _, e := range entries { if strings.Count(paytable, fmt.Sprintf("%c%s", e.label, e.text)) != 1 { t.Fatalf("paytable legend missing or duplicated %c", e.label) } } // Rounded ties, not just mirrored positions, share one legend entry. s.Row = 16 s.Counts = [17]int{} rail := strings.Split(renderPlinko(s, "none", true), "\n")[1] if !strings.Contains(rail, "[A] [B] [C] [D] [E] [F] [G] [H] [I] [H] [G] [F] [E] [D] [C] [B] [A]") { t.Fatal(rail) } s.Pockets[7], s.Pockets[8], s.Pockets[9] = 0.1, 0.1, 0.1 if got := plinkoLegendEntries(s); len(got) != 8 { t.Fatalf("rounded ties duplicated: %d entries", len(got)) } } func TestPlinkoPaytableIsCompact(t *testing.T) { s := &casino.PlinkoSnapshot{ Paytable: true, Risk: "easy", Total: 1, RTP: 97.9998779296875, Pockets: [17]float64{16, 7.8, 5.8, 4.3, 3, 2, 1.1, 0.4, 0.2, 0.4, 1.1, 2, 3, 4.3, 5.8, 7.8, 16}, } want := "easy 1 ball 98.00% RTP: A16x B7.8x C5.8x D4.3x E3x F2x G1.1x H0.4x I0.2x" for _, mode := range []string{"none", "ansi", "xterm256"} { out := renderPlinko(s, mode, true) if plain := stripANSI(out); plain != want { t.Fatalf("%s paytable: %q", mode, plain) } if mode != "none" { for bg := 0; bg < 256; bg++ { if strings.Contains(out, color.BgCode(mode, bg)) { t.Fatalf("%s paytable must not paint a background", mode) } } } } } func TestPlinkoLandingPockets(t *testing.T) { s := &casino.PlinkoSnapshot{Row: plinkoRows, Total: 3} s.Pockets[0], s.Pockets[16] = 16, 16 s.Counts[0], s.Counts[8] = 1, 2 for _, mode := range []string{"none", "ansi", "xterm256"} { rail := strings.Split(renderPlinko(s, mode, true), "\n")[1] plain := []rune(stripANSI(rail)) for k := range s.Counts { left, right := '[', ']' if s.Counts[k] > 0 { left, right = '>', '<' } if plain[1+4*k] != left || plain[3+4*k] != right { t.Fatalf("%s: incorrect landing marker at pocket %d: %s", mode, k, string(plain)) } } } }